UX-09: Details link for current HA, "would" in shadow mode, plain terms
- 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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user