UX-09: Details link for current HA, "would" in shadow mode, plain terms
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s

- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old
  /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and
  older. Slug from GET /addons/self/info, version from GET /info, hostname
  as fallback.
- Shadow mode: the over-rating alarm and row say what would have been sent,
  the peak alarm no longer says the battery is giving all it can.
- "controller" -> "the add-on" / "control". Settings named by their labels;
  four labels changed to match (Peak reserve, Battery level entity, Solar
  production ..., Reversed-sign solar entities).
- Shadow banner and inverter link notes moved to messages.py and checked.
- DOCS Notifications and Status page rewritten for home owners, with a
  "For installers" part each. Allowlist 168 -> 167.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
2026-09-27 04:10:39 +02:00
co-authored by Claude Opus 5.5
parent d57767fe38
commit 00e3ee9de8
16 changed files with 666 additions and 264 deletions
+44 -7
View File
@@ -3796,7 +3796,8 @@ class Controller:
# UX-08: the words are messages.output_clamp()'s; the numbers are the
# ones `reason` was written from (ClampWatchdog.facts), asked first.
await self.hass.call("persistent_notification", "create",
messages.output_clamp(self.clamp_wd.facts).data())
messages.output_clamp(self.clamp_wd.facts,
shadow=self.shadow).data())
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
@@ -3839,7 +3840,8 @@ class Controller:
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create",
messages.peak_saturated(g.ceiling_w).data())
messages.peak_saturated(
g.ceiling_w, shadow=self.shadow).data())
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
@@ -4797,9 +4799,14 @@ class Controller:
# `live_units()` has stopped listing has had its share handed to its
# siblings, or - at a roster of one - has taken the site to 0 W. A
# dropped link that shows nothing on the page reads as a healthy site.
for note in self.liveness_notes():
out.append({"ok": False, "warn": "NOT COMMANDABLE" not in note,
"text": note})
# Red only for a unit that is no longer live; the rest are amber.
# The same notes, in the same order, as `liveness_notes()`.
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
@@ -4814,8 +4821,8 @@ class Controller:
if _cw.active and _cw.bug:
out.append({"ok": False, "warn": False, "id": "power_too_high",
"banner": messages.banner_power_too_high(),
"text": messages.row_power_too_high(_cw.facts,
_cw.over_rating)})
"text": messages.row_power_too_high(
_cw.facts, _cw.over_rating, shadow=self.shadow)})
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
@@ -5363,6 +5370,30 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
stop.set()
async def docs_link_facts(session, token: str) -> dict:
"""`{slug, ha_version}` from the Supervisor, each None when unreadable.
`GET /addons/self/info` and `GET /info` are on the Supervisor's bypass
list for an add-on's own token, so they need no role (see selfconfig.py).
"""
out = {"slug": None, "ha_version": None}
for key, url, field in (("slug", "http://supervisor/addons/self/info",
"slug"),
("ha_version", "http://supervisor/info",
"homeassistant")):
try:
async with session.get(url, timeout=10, headers={
"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
data = body.get("data", body) if isinstance(body, dict) else {}
out[key] = data.get(field) if isinstance(data, dict) else None
except Exception as err: # noqa: BLE001 - a link, never a stop
_LOG.info("docs link: %s unreadable (%s)", url, err)
_LOG.info("docs link: slug %s, Home Assistant %s", out["slug"],
out["ha_version"])
return out
async def amain() -> None:
opts = load_options()
logging.basicConfig(
@@ -5394,6 +5425,12 @@ async def amain() -> None:
except Exception as _e: # noqa: BLE001
_LOG.error("core api probe failed: %s", _e)
# UX-09: the Details link in every notification needs this add-on's
# slug and Home Assistant's version (see messages.docs_url()). Both
# are best effort: without them the link falls back to the hostname,
# or to naming the section only.
messages.set_site(**await docs_link_facts(session, _tok))
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
# configuration validate.py rejects - specs §1.5: a config the system
# cannot honour is refused at startup, not survived at runtime.