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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user