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
+119 -5
View File
@@ -150,15 +150,129 @@ check(f"the samples cover every notification id in the catalogue "
check("the payload carries exactly the three keys Home Assistant accepts",
all(set(n.data()) == {"notification_id", "title", "message"}
for n in SAMPLES))
# The Details link (UX-09). Home Assistant 2026.2+ serves add-on pages at
# /config/app/<slug>/<tab> and 2026.5 dropped /hassio/addon/<slug>/<tab>, so
# the path follows the version the Supervisor reports; unknown means current.
_LINE = ("Details: [add-on documentation](/config/app/local_goodwe_controller/"
"documentation), section “Battery is not keeping up” under "
"Notifications.")
os.environ["HOSTNAME"] = "3f2a9c1b7d4e" # a container id, as on TEST-23
messages.set_site("local_goodwe_controller", "2026.9.1")
check("with the Supervisor's slug the Details line is exactly the current-HA "
"link plus the section by name, even when the hostname is a container id",
SAMPLES[0].data()["message"].endswith("\n\n" + _LINE))
_link = {v: messages.docs_url("local_goodwe_controller", v)
for v in ("2026.1.3", "2026.2.0", "2026.4.2", "2026.10.0b1",
"2025.12.5", None, "rubbish")}
check(f"Home Assistant 2026.1 and older get the old /hassio path, 2026.2 and "
f"newer and an unknown version the /config/app path ({_link})",
_link == {
"2026.1.3": "/hassio/addon/local_goodwe_controller/documentation",
"2025.12.5": "/hassio/addon/local_goodwe_controller/documentation",
"2026.2.0": "/config/app/local_goodwe_controller/documentation",
"2026.4.2": "/config/app/local_goodwe_controller/documentation",
"2026.10.0b1": "/config/app/local_goodwe_controller/documentation",
None: "/config/app/local_goodwe_controller/documentation",
"rubbish": "/config/app/local_goodwe_controller/documentation"})
messages.set_site("../../etc", "2026.9.1")
check("a slug that is not this add-on's shape is not put in a link",
"](" not in SAMPLES[0].data()["message"])
messages.set_site(None, None)
os.environ["HOSTNAME"] = "abcd1234-goodwe-controller"
check("inside the add-on the Details line links to its documentation page",
"(/hassio/addon/abcd1234_goodwe_controller/documentation)"
check("without the Supervisor's answer the slug comes from a Supervisor-style "
"hostname",
"(/config/app/abcd1234_goodwe_controller/documentation)"
in SAMPLES[0].data()["message"])
os.environ["HOSTNAME"] = "some-laptop"
check("...and anywhere else it names the section without a guessed link",
"](" not in SAMPLES[0].data()["message"]
and "under Notifications" in SAMPLES[0].data()["message"])
SAMPLES[0].data()["message"].endswith(
"\n\nDetails: add-on documentation, section “Battery is not keeping "
"up” under Notifications."))
del os.environ["HOSTNAME"]
# Where the slug and the version come from: the Supervisor, read once at start.
import asyncio # noqa: E402
from app.main import docs_link_facts # noqa: E402
class _Resp:
def __init__(self, body):
self.body = body
async def __aenter__(self):
if isinstance(self.body, Exception):
raise self.body
return self
async def __aexit__(self, *a):
return False
async def json(self, content_type=None):
return self.body
class _Session:
def __init__(self, answers):
self.answers, self.asked = answers, []
def get(self, url, **kw):
self.asked.append((url, kw.get("headers", {}).get("Authorization")))
return _Resp(self.answers[url])
_ok = _Session({
"http://supervisor/addons/self/info": {"result": "ok", "data": {
"slug": "local_goodwe_controller", "version": "0.12.0"}},
"http://supervisor/info": {"result": "ok", "data": {
"homeassistant": "2026.9.1", "supervisor": "2026.08.0"}}})
_down = _Session({"http://supervisor/addons/self/info": OSError("refused"),
"http://supervisor/info": ValueError("not json")})
check("at start the add-on asks the Supervisor for its slug and HA's version, "
"with its own token",
asyncio.run(docs_link_facts(_ok, "tok")) == {
"slug": "local_goodwe_controller", "ha_version": "2026.9.1"}
and all(a == "Bearer tok" for _u, a in _ok.asked))
check("...and a Supervisor that does not answer costs the link, never the "
"start",
asyncio.run(docs_link_facts(_down, "tok")) == {
"slug": None, "ha_version": None})
# Shadow mode (UX-09): nothing the add-on decides reaches the inverter, so no
# text may say a command was sent or that the battery is giving power.
_c1 = [messages.Clamp(None, 7412, 5000, "rating")]
_c2 = [messages.Clamp("inverter-a", -3000, -2500, "device"),
messages.Clamp("inverter-b", 6000, 4600, "max_w+device")]
_live = [messages.output_clamp(c).body for c in (_c1, _c2, ())]
_shad = [messages.output_clamp(c, shadow=True).body for c in (_c1, _c2, ())]
check("live, the over-rating alarm says what was sent and that control runs",
" and sent 5 000 W" in _live[0] and " and sent 2 500 W" in _live[1]
and all("Control is still running" in b or "control is still running"
in b for b in _live))
check("in shadow mode it says what would have been sent, and that the "
"inverters stay at 0 W",
"would have sent 5 000 W" in _shad[0]
and "would have sent 2 500 W" in _shad[1]
and "would have been cut back" in _shad[2]
and all(" sent" not in b.replace("would have sent", "") for b in _shad)
and all("0 W" in b and "hadow mode" in b for b in _shad))
_row_live = messages.row_power_too_high(_c1, 3)
_row_shad = messages.row_power_too_high(_c1, 3, shadow=True)
check("the over-rating row says 'sent' live and 'would send' in shadow mode",
"sent 5 000 W" in _row_live and "would" not in _row_live
and "would send 5 000 W" in _row_shad and " sent" not in _row_shad
and "shadow mode" in _row_shad)
_sat = messages.peak_saturated(5000, shadow=True).body
check("in shadow mode the peak alarm does not claim the battery is giving "
"power, and keeps its action",
"giving all it can" not in _sat and "gives nothing" in _sat
and "Switch off large loads" in _sat
and "giving all it can" in messages.peak_saturated(5000).body)
check("shadow mode changes no notification's id or severity",
all(messages.output_clamp(c, shadow=True).id == messages.ID_OUTPUT_CLAMP
and messages.output_clamp(c, shadow=True).severity == messages.ALARM
for c in (_c1, _c2, ()))
and messages.peak_saturated(1, shadow=True).id == messages.ID_PEAK_SATURATED
and messages.peak_saturated(1, shadow=True).severity == messages.ALARM)
check("the output-clamp alarm quotes asked before sent",
messages.output_clamp([messages.Clamp(None, 7412, 5000, "rating")]).body
.index("7 412 W") < messages.output_clamp(
@@ -305,7 +419,7 @@ ALLOW_FILE = HERE / "ux08_allowlist.txt"
# The allowlist may never grow. The test demands the list be exactly this long,
# so deleting lines means lowering this number in the same change, and adding
# one means raising it where a reviewer sees it.
ALLOWLIST_MAX = 168
ALLOWLIST_MAX = 167
def read_allowlist(path):