UX-08 2d: plain log lines, the rest of the status page, and DOCS
checks / unit (pull_request) Successful in 8m55s
infra-pin / fresh (pull_request) Successful in 1s

Every WARNING and ERROR line is one plain line of at most 25 words, with
no card ids, capitals or semicolons; the reasoning of the ten longest is
on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0).

The status page: the month-peak and peak-guard rows, the stale-reading
row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the
Stopped banner and the line under the buttons, which now shows the plain
hint of each event instead of the raw action log. The Supervisor question
at start logs one line with a real reason.

Refusals are counted as shown (labels expanded, long cases driven), seven
were shortened, the card and parse errors lost their internals, a blank
value reads "empty", and the panel no longer files inverters.max_w under
the flat max_w. Option names lose "(JSON)", the NL texts are checked too.

DOCS gets a glossary (control board, isolator, Developer Tools) and card
ids move from headings to a "For installers" line.

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 08:01:16 +02:00
co-authored by Claude Opus 5.5
parent a02cd4b713
commit 6a95447248
38 changed files with 1294 additions and 742 deletions
+177 -26
View File
@@ -289,20 +289,66 @@ print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
def render(node):
"""A string expression as text, placeholders as {expr}; None otherwise."""
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
# setting is expanded to the label and key `messages.setting()` prints, so
# `{S('max_w')}` counts as “Inverter rating” (max_w), three words, not one.
SETTING_CALLS = ("S", "_S", "setting", "messages.setting")
CONSTANTS = {} # module-level NAME = "text", per file, for keys
def _key(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return CONSTANTS.get(node.id)
return None
def expand_setting(node):
"""The shown text of a placeholder that names a setting, else None."""
if isinstance(node, ast.Call) and node.args:
name = ast.unparse(node.func)
if name in SETTING_CALLS:
key = _key(node.args[0])
if key not in messages.LABELS:
return None
unit = node.args[1] if len(node.args) > 1 else None
plain = unit is None or (isinstance(unit, ast.Constant)
and not unit.value)
return messages.setting(key, "" if plain else "inverter-b")
if name == "_labels" and isinstance(node.args[0], (ast.List, ast.Tuple)):
keys = [_key(e) for e in node.args[0].elts]
if all(k in messages.LABELS for k in keys):
return " and ".join(messages.setting(k) for k in keys)
if (isinstance(node, ast.Subscript)
and ast.unparse(node.value) == "messages.LABELS"):
key = _key(node.slice)
return messages.LABELS.get(key) if key else None
return None
def render(node, expand=False):
"""A string expression as text, placeholders as {expr}; None otherwise.
`expand` shows setting placeholders as the page shows them."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
return "".join(v.value if isinstance(v, ast.Constant)
else "{" + ast.unparse(v.value) + "}" for v in node.values)
out = []
for v in node.values:
if isinstance(v, ast.Constant):
out.append(v.value)
continue
shown = expand_setting(v.value) if expand else None
out.append(shown if shown is not None
else "{" + ast.unparse(v.value) + "}")
return "".join(out)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
a, b = render(node.left), render(node.right)
a, b = render(node.left, expand), render(node.right, expand)
if a is None and b is None:
return None
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
if isinstance(node, ast.IfExp):
a, b = render(node.body), render(node.orelse)
a, b = render(node.body, expand), render(node.orelse, expand)
if a is None and b is None:
return None
return (a or "") + " / " + (b or "")
@@ -346,14 +392,14 @@ LIMITS = { # kind: (words, keys ok, entity ids ok)
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
def strings_in(node):
def strings_in(node, expand=False):
"""Top-level string expressions inside `node` (not their parts)."""
t = render(node)
t = render(node, expand)
if t is not None:
yield node, t
return
for child in ast.iter_child_nodes(node):
yield from strings_in(child)
yield from strings_in(child, expand)
def collect():
@@ -363,6 +409,12 @@ def collect():
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
CONSTANTS.clear()
CONSTANTS.update({t.id: n.value.value for n in tree.body
if isinstance(n, ast.Assign)
and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
for t in n.targets if isinstance(t, ast.Name)})
for node in ast.walk(tree):
if isinstance(node, ast.Dict):
for k, v in zip(node.keys, node.values):
@@ -387,24 +439,26 @@ def collect():
out.append(("log", f"{f.name}:{n.lineno}", t))
elif re.search(r"problems\.(append|extend)$", name):
for a in node.args:
for n, t in strings_in(a):
for n, t in strings_in(a, expand=True):
if len(words(t)) > 1:
out.append(("refusal", f"{f.name}:{n.lineno}", t))
tr = TL.parse_translation_file(HERE / "translations" / "en.yaml")
# UX-08 2d: the Dutch option texts are held to the same length.
for lang in ("en", "nl"):
tr = TL.parse_translation_file(HERE / "translations" / f"{lang}.yaml")
def walk(conf, path):
for key, v in (conf or {}).items():
if not isinstance(v, dict):
continue
if isinstance(v.get("name"), str):
out.append(("option-name", f"en.yaml:{path}{key}",
v["name"].split("·")[-1].strip()))
if isinstance(v.get("description"), str):
out.append(("option-desc", f"en.yaml:{path}{key}",
v["description"]))
if isinstance(v.get("fields"), dict):
walk(v["fields"], f"{path}{key}.")
walk(tr.get("configuration"), "")
def walk(conf, path):
for key, v in (conf or {}).items():
if not isinstance(v, dict):
continue
if isinstance(v.get("name"), str):
out.append(("option-name", f"{lang}.yaml:{path}{key}",
v["name"].split("·")[-1].strip()))
if isinstance(v.get("description"), str):
out.append(("option-desc", f"{lang}.yaml:{path}{key}",
v["description"]))
if isinstance(v.get("fields"), dict):
walk(v["fields"], f"{path}{key}.")
walk(tr.get("configuration"), "")
return out
@@ -413,7 +467,7 @@ def problems_of(kind, text):
p = []
if len(words(text)) > lim:
p.append(f"over {lim} words ({len(words(text))})")
if kind != "log" and ";" in text: # logs are phase 2d
if ";" in text:
p.append("semicolon")
return p + text_problems(text, keys=keys, entities=ents)
@@ -426,7 +480,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 = 43
ALLOWLIST_MAX = 0
def read_allowlist(path):
@@ -491,6 +545,30 @@ _own_banner = {"row_direction_trip", "row_site_direction_trip",
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
and len(words(messages.banner_not_ready({"text": t}))) > 12]
# The hint under the buttons (UX-08 2d): the last event in plain words, one
# per key of messages.HINTS, held to the rule for a green row.
for key, text in messages.hint_samples():
p = row_problems(messages.GREEN, text)
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
_calls, _bare = 0, []
for f in sorted(APP.glob("*.py")):
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if (isinstance(node, ast.Call)
and ast.unparse(node.func).endswith("log_event")):
_calls += 1
h = next((k.value for k in node.keywords if k.arg == "hint"), None)
if not (isinstance(h, ast.Call)
and ast.unparse(h.func) in ("messages.hint", "hint")):
_bare.append(f"{f.name}:{node.lineno}")
check(f"every event the add-on records gives the page its plain words, from "
f"messages.hint() ({_calls} calls, without: {_bare})",
_calls >= 40 and not _bare)
_raw = ("OUTPUT_CLAMP", "BUG SIGNAL", "FAILSAFE", "SIGN_DISAGREEMENT",
"ALLOCATOR", "UNMET", "ARBITER", "PEAK GUARD")
check("no hint carries the raw event codes the page used to show",
not [t for _k, t in messages.hint_samples()
if any(r in t for r in _raw)])
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
f"(too long: {_long})", not _long)
@@ -555,6 +633,79 @@ check("...and a refusal quotes the label and then the key, so the config "
"panel still files it under that setting",
messages.setting("max_w", "inverter-b")
== "“Inverter rating” for inverter-b (inverters.max_w)")
# UX-08 2d: the refusals as the validator SHOWS them. The scan above expands
# setting labels in a template, but a reason that carries a parser's error
# (`{e}`) is only as long as that error, so the long ones found by the 2c test
# are driven for real here and held to the same 30 words.
from app import custom_formula as _cf # noqa: E402
from app.validate import validate_startup_config # noqa: E402
_BASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000}
_CARD = _cf._LEGS["price_formula"].example.replace("15.55", "15.05")
_TABLE = ('{"periods":[{"name":"day","from":"07:00","to":"22:00","days":'
'"mon-fri","eur_per_kwh":0.1},{"name":"night","from":"22:00","to":'
'"07:00","days":"mon-fri","eur_per_kwh":0.08},{"name":"night",'
'"from":"00:00","to":"24:00","days":"sat,sun","eur_per_kwh":0.08}]}')
_ROSTER = {k: v for k, v in _BASE.items()
if k not in ("setpoint_entity", "soc_entity", "batt_entity",
"max_w")}
_ROSTER["inverters"] = [
dict(name=n, setpoint_entity=f"number.sp_{n[-1]}", max_w=w,
soc_entity=f"sensor.soc_{n[-1]}", batt_entity=f"sensor.batt_{n[-1]}",
bms_soc_floor=10)
for n, w in (("inverter-a", 2000), ("inverter-b", 200))]
_DRIVEN = {
"formula card": dict(_BASE, price_provider="custom_formula",
price_formula=_CARD),
"formula not valid": dict(_BASE, price_provider="custom_formula",
price_formula='{"index": "epex_be"} x'),
"injection card": dict(_BASE, price_export_formula=_CARD),
"roster thresholds": dict(_ROSTER, maintenance_charge_w=100,
maintenance_discharge_w=100),
"levies table and flat": dict(_BASE, price_taxes_profile=_TABLE,
price_taxes_eur_kwh=0.1198),
"feed and entities": dict(_BASE, price_provider="generic_dayahead",
price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
"fixed tariff and entities": dict(_BASE, static_profile=_TABLE,
price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
"fixed tariff not valid": dict(_BASE, static_profile='{"periods": []} x'),
}
_LEAKS = ("c_eur_per_kwh", "vat_pct", "its own card", "line 1 column",
"None", "JSON", "one you do not use")
_shown, _long, _leaky = [], [], []
for _name, _o in _DRIVEN.items():
_ps = validate_startup_config(_o, -5000, 5000)
_shown.append((_name, len(_ps)))
_long += [f"{_name}: {len(words(t))} words: {t}" for t in _ps
if len(words(t)) > 30]
_leaky += [f"{_name}: {t}" for t in _ps if any(x in t for x in _LEAKS)]
check(f"every driven case is refused ({_shown})",
all(n >= 1 for _n, n in _shown)
and dict(_shown)["roster thresholds"] >= 2)
check(f"...and every refusal, as SHOWN, is at most 30 words (too long: "
f"{_long})", not _long)
check(f"...and shows no parser text, file units or Python's None (found: "
f"{_leaky})", not _leaky)
# The config panel files a reason beside every setting whose key it names, but
# `inverters.max_w` names a setting of one inverter in the list, not the flat
# `max_w` (UX-08 2d, from the 2c test).
from app.configui import problems_by_field # noqa: E402
_by, _un = problems_by_field(
["“Inverter rating” for inverter-b (inverters.max_w) is 200 W.",
"“Inverter rating” (max_w) is 0."], ["max_w", "inverters"])
check("a roster refusal shows beside `inverters` only, and a flat one beside "
f"`max_w` ({ {k: len(v) for k, v in _by.items()} })",
_by.get("inverters") == [
"“Inverter rating” for inverter-b (inverters.max_w) is 200 W."]
and _by.get("max_w") == ["“Inverter rating” (max_w) is 0."]
and not _un)
print(f"CI: UX-08 allowlist {len(allow)} entries "
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
for k in LIMITS))