Files
goodwe-addon/goodwe_controller/test_messages.py
T
Claude dev agent 667b8e9145
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s
UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:32 +02:00

1849 lines
85 KiB
Python

"""UX-08: user-facing text follows STYLE.md. RUN: python3 -B test_messages.py
1. Every notification in `app/messages.py` obeys the rule. The test renders
each one with realistic values and checks the title length, the number of
sentences and words for its severity, the banned terms, the entity ids and
the Details line.
2. No notification is worded anywhere else. An AST scan of `app/` fails any
`persistent_notification` call whose payload is not built by
`app/messages.py`.
3. The status page. Every row and banner in `app/messages.py` obeys the rule
for its colour: a row one sentence of at most 15 words, a red row up to 3
sentences and 30 words, a banner at most 12 words.
THE RATCHET for the kinds not rewritten yet. Every status-row and banner
literal left in `app/`, every validator refusal, every WARNING+ log line and every option
name and description in `translations/en.yaml` must either obey its limit
or appear in `ux08_allowlist.txt`. A new string that breaks the rule and is
not listed goes red. So does a listed entry that no longer matches a
failing string, so the list can only shrink: remove the line when you
rewrite the string.
4. DOCS.md has a section for every notification, under "## Notifications".
5. Mutations: each rule is shown to catch a spoiled input (habit 1 of
MUTATION.md).
Run `python3 -B test_messages.py --violations` to print what part 3 sees.
"""
import ast
import hashlib
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
from app import messages # noqa: E402
import translations_lint as TL # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --- the rule (STYLE.md sections 1 and 4): tools/style_rules.py, shared with
# test_ui_text.py since UX-13 P5.
from style_rules import (BANNED, ENTITY, PLACEHOLDER, SHOUT, SNAKE, # noqa: E402,F401
sentences, text_problems, title_problems, words)
def body_problems(body, severity):
out = []
n_s, n_w = sentences(body), len(words(body))
if severity == messages.ALARM:
if n_s > 3:
out.append(f"alarm body over 3 sentences ({n_s})")
if n_w > 70:
out.append(f"alarm body over 70 words ({n_w})")
long = [s for s in re.split(r"(?<=[.!?])\s+", body) if len(words(s)) > 30]
if long:
out.append(f"sentence over 30 words ({len(words(long[0]))})")
else:
if n_s > 1:
out.append(f"{severity} body over 1 sentence ({n_s})")
if n_w > 25:
out.append(f"{severity} body over 25 words ({n_w})")
if ";" in body:
out.append("semicolon: split it into sentences or drop a clause")
return out + text_problems(body, keys=False, entities=False)
def notice_problems(n):
out = []
if n.severity not in (messages.ALARM, messages.WARNING, messages.INFO,
messages.RESOLVED):
out.append(f"unknown severity {n.severity!r}")
out += title_problems(n.title) + body_problems(n.body, n.severity)
if not ends_in_details(n.data()["message"], n.docs):
out.append("message does not end in its Details line")
return out
def ends_in_details(message, docs):
return message.endswith("\n\n" + messages.details(docs))
# ============================================================================== #
print("1. every notification obeys the rule")
SAMPLES = messages.samples()
for n in SAMPLES:
p = notice_problems(n)
check(f"{n.severity:8} {n.title!r}: {len(words(n.body))} words, "
f"{sentences(n.body)} sentence(s){' - ' + '; '.join(p) if p else ''}",
not p)
ids = {v for k, v in vars(messages).items() if k.startswith("ID_")}
seen = {re.sub(r"inverter_b$", "{slug}", n.id) for n in SAMPLES}
check(f"the samples cover every notification id in the catalogue "
f"(missing: {sorted(ids - seen)})", ids <= seen)
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("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",
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, self.timeouts = answers, [], []
def get(self, url, **kw):
self.asked.append((url, kw.get("headers", {}).get("Authorization")))
self.timeouts.append(kw.get("timeout"))
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})
check("...and each question has a short timeout, so a Supervisor that hangs "
f"delays control start by seconds, not minutes ({_ok.timeouts})",
len(_ok.timeouts) == 2
and all(isinstance(t, (int, float)) and 0 < t <= 3 for t in _ok.timeouts))
# 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(
[messages.Clamp(None, 7412, 5000, "rating")]).body.index("5 000 W"))
# ============================================================================== #
print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
# 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):
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, 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, expand), render(node.orelse, expand)
if a is None and b is None:
return None
return (a or "") + " / " + (b or "")
return None
def notification_calls(tree):
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and node.args
and ast.unparse(node.func).endswith("call")
and render(node.args[0]) == "persistent_notification"):
yield node
bad_calls, n_calls = [], 0
for f in sorted(APP.glob("*.py")):
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
n_calls += 1
payload = call.args[2] if len(call.args) > 2 else None
ok = (isinstance(payload, ast.Call)
and isinstance(payload.func, ast.Attribute)
and payload.func.attr == "data"
and "messages." in ast.unparse(payload.func.value))
if not ok:
bad_calls.append(f"{f.name}:{call.lineno}")
check(f"the scan found the add-on's notification calls ({n_calls})", n_calls >= 7)
check(f"every one is built by app/messages.py (not: {bad_calls})", not bad_calls)
# ============================================================================== #
print("\n3. the ratchet: other user-facing text obeys its limit or is listed")
LIMITS = { # kind: (words, keys ok, entity ids ok)
"row": (15, False, False),
"banner": (12, False, False),
"refusal": (30, True, True),
"log": (25, True, True),
"option-name": (6, False, False),
"option-desc": (30, True, True),
"note": (25, True, True), # UX-13: a settings-page warning
"ui-name": (6, False, False), # UX-13: app/ui_text, a name or choice
"ui-desc": (25, False, False), # UX-13: app/ui_text, one-line help
}
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
def strings_in(node, expand=False):
"""Top-level string expressions inside `node` (not their parts)."""
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, expand)
def _module_constants(tree):
"""Module-level `NAME = "text"` assignments."""
return {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)}
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
def collect():
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
out = []
for f in sorted(APP.glob("*.py")):
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
# The file's own constants, and those it imports from another app
# module (`from .validate import TAXES_PROFILE_OPTION`), so a key
# named by a constant from elsewhere is expanded too.
CONSTANTS.clear()
CONSTANTS.update(_module_constants(tree))
for n in tree.body:
if isinstance(n, ast.ImportFrom) and n.level == 1 and n.module:
theirs = _MODCONST.get(n.module, {})
CONSTANTS.update({a.asname or a.name: theirs[a.name]
for a in n.names if a.name in theirs})
for node in ast.walk(tree):
if isinstance(node, ast.Dict):
for k, v in zip(node.keys, node.values):
key = render(k) if k is not None else None
if key in ("text", "banner"):
for n, t in strings_in(v):
out.append(("row" if key == "text" else "banner",
f"{f.name}:{n.lineno}", t))
elif isinstance(node, ast.Assign) and f.name == "main.py":
tgt = ast.unparse(node.targets[0]).replace(" ", "").strip("()")
if tgt in ("level,banner", "banner"):
for n, t in strings_in(node.value):
if len(words(t)) > 1:
out.append(("banner", f"{f.name}:{n.lineno}", t))
elif isinstance(node, ast.Call):
name = ast.unparse(node.func)
if LOG_CALL.search(name) or name.endswith("._moan"):
arg = node.args[1 if name.endswith("._moan") else 0] \
if node.args else None
if arg is not None:
for n, t in strings_in(arg):
out.append(("log", f"{f.name}:{n.lineno}", t))
elif name.endswith("_advise") and node.args:
for n, t in strings_in(node.args[-1], expand=True):
if len(words(t)) > 1:
out.append(("note", 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, expand=True):
if len(words(t)) > 1:
out.append(("refusal", f"{f.name}:{n.lineno}", t))
# 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"{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"), "")
# UX-13: the new page's own words, held to the option texts' rule. Home's
# sentences (`home_words`) are templates, checked rendered in part 8.
for f in sorted((APP / "ui_text").glob("*.json")):
def walk_ui(node, path):
for key, v in node.items():
if not path and key == "home_words":
continue
if isinstance(v, dict):
walk_ui(v, f"{path}{key}.")
elif isinstance(v, str):
out.append(("ui-desc" if key == "description" else
"ui-name", f"{f.name}:{path}{key}", v))
walk_ui(json.loads(f.read_text(encoding="utf-8")), "")
return out
def problems_of(kind, text):
lim, keys, ents = LIMITS[kind]
p = []
if len(words(text)) > lim:
p.append(f"over {lim} words ({len(words(text))})")
if ";" in text:
p.append("semicolon")
if kind in ("note", "ui-desc") and sentences(text) > 1:
p.append(f"over 1 sentence ({sentences(text)})")
return p + text_problems(text, keys=keys, entities=ents)
def key_of(kind, text):
return f"{kind} {hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
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 = 0
def read_allowlist(path):
keys = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
keys.add(" ".join(line.split()[:2]))
return keys
found = collect()
violating = {}
for kind, where, text in found:
p = problems_of(kind, text)
if p:
violating.setdefault(key_of(kind, text), (kind, where, text, p))
if "--violations" in sys.argv:
for k, (kind, where, text, p) in sorted(violating.items(),
key=lambda kv: kv[1][:2]):
print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}")
sys.exit(0)
STATUS = messages.status_samples()
def row_problems(colour, text):
"""STYLE.md section 1 for a status row (by colour) or a banner (None)."""
n_s, n_w = sentences(text), len(words(text))
out = []
if colour is None:
if n_w > 12:
out.append(f"banner over 12 words ({n_w})")
elif colour == messages.RED:
if n_s > 3:
out.append(f"red row over 3 sentences ({n_s})")
if n_w > 30:
out.append(f"red row over 30 words ({n_w})")
else:
if n_s > 1:
out.append(f"{colour} row over 1 sentence ({n_s})")
if n_w > 15:
out.append(f"{colour} row over 15 words ({n_w})")
if ";" in text:
out.append("semicolon: split it into sentences or drop a clause")
return out + text_problems(text, keys=False, entities=False)
for fn, colour, text in STATUS:
p = row_problems(colour, text)
check(f"{colour or 'banner':6} {text!r}{' - ' + '; '.join(p) if p else ''}",
not p)
_catalogue = {k for k, v in vars(messages).items()
if callable(v) and k.startswith(("row_", "banner_"))}
_missing = sorted(_catalogue - {fn for fn, _c, _t in STATUS})
check(f"the samples cover every row and banner in the catalogue "
f"(missing: {_missing})", not _missing)
# A red row with no banner of its own heads the page as "Not ready - <its first
# sentence>". The three trips have their own branch in status_payload(), and
# main.py gives the other two a "banner" key (both are sampled above).
_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", "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)
# A latch row must say how it clears: that is the one action it exists for.
_clears = {
"row_direction_trip": "A restart does not clear this",
"row_site_direction_trip": "Only starting control clears this",
"row_circulating_trip": "Only starting control clears this",
"row_not_delivering": "only stopping and starting control clears this",
}
_unclear = [fn for fn, _c, t in STATUS if fn in _clears and _clears[fn] not in t]
check(f"every latch row says how it clears (missing: {_unclear})",
not _unclear and {fn for fn, _c, _t in STATUS} >= set(_clears))
allow = read_allowlist(ALLOW_FILE)
by_kind = {}
for kind, *_ in found:
by_kind[kind] = by_kind.get(kind, 0) + 1
for _fn, colour, _t in STATUS:
kind = "row" if colour else "banner"
by_kind[kind] = by_kind.get(kind, 0) + 1
check(f"the scan found every ratcheted kind ({by_kind})",
all(by_kind.get(k, 0) >= 5 for k in LIMITS))
new = sorted(k for k in violating if k not in allow)
for k in new:
kind, where, text, p = violating[k]
print(f" NEW {where}: {'; '.join(p)} :: {text[:90]!r}\n"
f" (rewrite it to STYLE.md; listing it needs a reason)")
check(f"no new string breaks the rule ({len(new)} new)", not new)
stale = sorted(k for k in allow if k not in violating)
for k in stale:
print(f" STALE {k} - that string now obeys the rule or is gone: "
f"delete its line from ux08_allowlist.txt")
check(f"no allowlist entry is stale, so the list only shrinks "
f"({len(stale)} stale)", not stale)
check(f"the allowlist is exactly its cap ({len(allow)} entries, cap "
f"{ALLOWLIST_MAX}): lower the cap when you delete lines",
len(allow) == ALLOWLIST_MAX)
# A refusal names a setting by its Configuration-tab label (UX-08 2c). The
# catalogue's labels must be the en.yaml names without group prefix and unit.
_TR = TL.parse_translation_file(HERE / "translations" / "en.yaml")["configuration"]
def _label_stems(key):
names = [c[key]["name"] for c in (_TR, _TR["inverters"]["fields"])
if isinstance(c.get(key), dict)]
out = set()
for n in names:
n = n.split("·")[-1].strip()
while re.search(r"\s*\([^()]*\)$", n):
n = re.sub(r"\s*\([^()]*\)$", "", n)
out.add(n)
return out
_drift = sorted(f"{k}: {v!r}" for k, v in messages.LABELS.items()
if v not in _label_stems(k))
check(f"every label a refusal quotes is the setting's name in en.yaml "
f"(drifted: {_drift})", not _drift)
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))
# ============================================================================== #
print("\n4. DOCS.md holds the detail of every notification")
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
part = DOCS.split("\n## Notifications\n", 1)
check("DOCS.md has a '## Notifications' section", len(part) == 2)
section = part[-1].split("\n## ", 1)[0] if len(part) == 2 else ""
heads = set(re.findall(r"^### (.+)$", section, re.M))
missing = sorted({n.docs for n in SAMPLES} - heads)
check(f"...with a heading for every Details line (missing: {missing})", not missing)
check("DOCS.md explains the status page rows under "
f"'## {messages.DOCS_STATUS}'", f"\n## {messages.DOCS_STATUS}\n" in DOCS)
# ============================================================================== #
print("\n5. mutations: each rule catches a spoiled input")
N = messages.Notice
check("a six-word title is refused",
bool(title_problems("The meter reading may be stuck")))
check("a GoodWe prefix is refused", bool(title_problems("GoodWe: meter stuck")))
check("a second sentence in a warning is refused",
bool(body_problems("The battery lags. Check it.", messages.WARNING)))
check("a semicolon run-on is refused, in an alarm too",
bool(body_problems("It stopped; check it.", messages.ALARM)))
check("a fourth sentence in an alarm is refused",
bool(body_problems("One. Two. Three. Four.", messages.ALARM)))
check("an internal name is refused", bool(body_problems(
"The integrator reached its bound.", messages.WARNING)))
check("a card id is refused", bool(body_problems(
"See SAFETY-25.", messages.WARNING)))
check("shouting is refused", bool(body_problems(
"Control is STILL RUNNING.", messages.WARNING)))
check("an entity id in an alarm is refused", bool(body_problems(
"Check sensor.battery_power now.", messages.ALARM)))
check("...but allowed in a refusal", not problems_of(
"refusal", "Check sensor.battery_power in Developer Tools."))
check("a semicolon in a refusal or an option text is refused",
bool(problems_of("refusal", "It is wrong; fix it."))
and bool(problems_of("option-desc", "It is wrong; fix it.")))
check("a label that drifted from en.yaml is caught",
"Inverter power" not in _label_stems("max_w"))
check("an option key in an alarm is refused", bool(body_problems(
"Lower peak_soc_floor.", messages.ALARM)))
check("a message that lost its Details line is refused",
ends_in_details(N("x", messages.WARNING, "Meter stuck", "It is.")
.data()["message"], "Meter stuck")
and not ends_in_details("It is.", "Meter stuck"))
_fake = " x = {'text': 'THE METER IS FROZEN AND NOTHING WORKS AT ALL'}\n"
_n = [t for node in ast.walk(ast.parse(_fake.strip()))
if isinstance(node, ast.Dict) for _, t in strings_in(node.values[0])]
check("a second sentence in an amber row is refused",
bool(row_problems(messages.AMBER, "Meter stuck. Check it.")))
check("a fourth sentence in a red row is refused",
bool(row_problems(messages.RED, "One. Two. Three. Four.")))
check("a 16-word green row is refused",
bool(row_problems(messages.GREEN, " ".join(["word"] * 16))))
check("a 13-word banner is refused",
bool(row_problems(None, " ".join(["word"] * 13))))
check("an entity id in a status row is refused",
bool(row_problems(messages.GREEN, "Battery level: sensor.soc = 54")))
check("a semicolon in a status row is refused",
bool(row_problems(messages.RED, "It stopped; check it.")))
check("a new shouting status row is seen by the scan and is not listed",
_n and problems_of("row", _n[0])
and key_of("row", _n[0]) not in allow)
_bad = ast.parse("await self.hass.call('persistent_notification', 'create', "
"{'title': 'x', 'message': 'y'})", mode="exec")
check("a hand-written notification payload is seen by part 2",
any(not (isinstance(c.args[2], ast.Call)) for c in notification_calls(_bad)))
# ============================================================================== #
print("\n6. log lines as they are built at run time (UX-10)")
# Part 3 reads a WARNING+ line's own literal and counts each placeholder as one
# word. So a reason text built elsewhere and passed in as `%s` was never seen:
# the 2d test found "…clamped to +2000 W; … This is a BUG SIGNAL … Cut back 1
# times" in the log while part 3 was green, because `loop_clamp_note()` built
# it. This part closes that gap in two steps.
# a. Every dynamic part of every WARNING+ line in `app/` is classified here,
# as data (a number, a name, a path, an exception's own text) or as a text
# the add-on builds. A new dynamic part that is in neither list fails, so a
# new builder cannot slip past.
# b. Every built text is produced by the real code and checked. It is the
# evidence behind an alarm, so it gets the red-alarm shape: at most 3
# sentences of at most 30 words, 70 in all. It keeps its reason code. The
# whole line, as logged, may carry no semicolon, banned term, capitals,
# "1 times", "(s)" or a doubled full stop.
from datetime import datetime as _dt, timedelta as _td, timezone as _tz # noqa: E402
from app import clamp as _clamp, freshness as _fresh, peakguard as _pg # noqa: E402
from app import store as _store # noqa: E402
from app.circulation import CirculationConfig, CirculationWatchdog # noqa: E402
from app.control import Tuning, compute # noqa: E402
from app.delivery import (DeliveryConfig, DeliveryWatchdog, UnitDelivery, # noqa: E402
delinquent)
from app.freeze import FreezeWatchdog, FreezeWatchdogConfig # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
from app.tariff import Projection # noqa: E402
from app.watchdog import (AGAINST_METER, AGAINST_OWN_POWER, SignWatchdog, # noqa: E402
SignWatchdogConfig)
_T0 = _dt(2026, 9, 27, 9, 0, tzinfo=_tz.utc)
PLURAL = re.compile(r"\b1 [a-z]{2,}s\b(?<!\b1 [a-z]ss)|\w\(s\)")
COUNT_PLURAL = re.compile(r"%d [a-z]{2,}s\b")
def dynamic_parts(tree, fname):
"""(key, call) for every dynamic part of a WARNING+ log call in `tree`."""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
name = ast.unparse(node.func)
moan = name.endswith("._moan")
if not (LOG_CALL.search(name) or moan):
continue
i = 1 if moan else 0
if len(node.args) <= i:
continue
fmt = node.args[i]
parts = [ast.unparse(a) for a in node.args[i + 1:]]
if isinstance(fmt, ast.JoinedStr):
parts += [ast.unparse(v.value) for v in fmt.values
if isinstance(v, ast.FormattedValue)]
elif not (isinstance(fmt, ast.Constant) and isinstance(fmt.value, str)):
parts.append(ast.unparse(fmt))
for part in parts:
yield f"{fname}:{part}", node
# a. DATA: shown as it is, never worded by the add-on. An exception's text is
# the library's (hass.py words the Home Assistant ones, UX-10 item 3).
DATA = {
# hass.py: the _moan() wrapper and what its callers pass
"hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err",
"hass.py:obj", "hass.py:raw", "hass.py:domain", "hass.py:service",
"hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what",
# UX-13 P2: the entity list's wait, and an error's text or else its name
"hass.py:STATES_TIMEOUT_S", "hass.py:str(err) or type(err).__name__",
# main.py
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
"main.py:err", "main.py:', '.join(sorted(stranded))",
"main.py:sorted(on_roster)", "main.py:inv.name",
"main.py:inv.batt_fresh.bound.release_s", "main.py:inv.batt_fresh.bound.move_w",
"main.py:inv.batt_fresh.bound.unchanged_s", "main.py:inv.batt_fresh.held",
"main.py:value", "main.py:float(value)",
"main.py:entity", "main.py:WRITE_BUDGET_S",
"main.py:_e",
"main.py:messages.times(self.clamp_wd.engagements)",
"main.py:messages.when(brussels(self.tariff_at))",
"main.py:messages.duration(TARIFF_MAX_AGE_S)",
"main.py:messages.count(len(targets), 'inverter')",
"main.py:messages.seconds(self.p1.max_age_s)",
# maintenance.py, mqtt.py, p1.py
"maintenance.py:phase", "mqtt.py:err", "p1.py:err", "p1.py:self.host",
"p1.py:self.topic", "p1.py:poll_s", "p1.py:ingest.max_age_s",
"p1.py:backoff", "p1.py:self.url", "p1.py:str(err) or type(err).__name__",
"p1.py:self.poll_s",
# price.py
"price.py:doc.get('deprecated')", "price.py:len(dropped)",
"price.py:messages.count(len(times), 'time')", "price.py:plugin_id",
"price.py:override",
"price.py:', '.join((str(i) for i in dropped[:5])) + (' and more' if len(dropped) > 5 else '')",
"price.py:*(d.isoformat() for d in brussels_date_range(since, until))",
"price.py:pid",
"price.py:'/'.join(sorted((c.value for c in caps.sources - {Component.ENERGY})))",
"price.py:' and '.join(ignored) + (' is' if len(ignored) == 1 else ' are')",
"price.py:getattr(cls, 'id', cls)", "price.py:e", "price.py:type(e).__name__",
# price_history.py, selfconfig.py, static_tariff.py, store.py, tariff.py
"price_history.py:self.path", "price_history.py:day_words(want)",
"price_history.py:messages.count(len(points), 'price')",
"price_history.py:err",
"selfconfig.py:messages.count(len(problems), 'problem')",
"selfconfig.py:', '.join(keys) or 'none this page also edited'",
"static_tariff.py:e", "store.py:err", "store.py:key", "store.py:raw",
"tariff.py:self.path", "tariff.py:err", "tariff.py:start", "tariff.py:month",
# validate.py: option values and keys (the refusals are part 3's)
"validate.py:integ_w", "validate.py:max_w", "validate.py:disch_w",
"validate.py:maint_where", "validate.py:maint_cap", "validate.py:pid",
"validate.py:i", "validate.py:TAXES_PROFILE_OPTION", "validate.py:negative",
"validate.py:EXPORT_FORMULA_OPTION", "validate.py:xspec.passthrough",
"validate.py:xspec.offset_eur_kwh", "validate.py:'=' * 72",
"validate.py:'-' * 72",
"web.py:err",
}
# b. BUILT TEXT: each producer, run for real. A reason that is only ever
# stored and restored (a saved safety stop) is produced by the same builder.
def _sign(against, cmd=1500.0, got=-1400.0):
wd = SignWatchdog(SignWatchdogConfig(min_w=250.0, hold_s=60.0),
against=against)
for s in (0, 61):
wd.evaluate(cmd, got, True, _T0 + _td(seconds=s))
return wd.reason
def _sign_texts():
return [_sign(AGAINST_METER), _sign(AGAINST_OWN_POWER, -1100.0, 2000.0)]
def _circ_texts():
wd = CirculationWatchdog(CirculationConfig(min_w=250.0, hold_s=60.0))
for s, w in ((0, 5200.0), (30, 6000.0), (61, 5800.0)):
wd.evaluate(w, _T0 + _td(seconds=s))
return [wd.reason]
def _delivery_texts():
units = [UnitDelivery("inverter-a", 1000.0, 990.0),
UnitDelivery("inverter-b", 2000.0, 0.0)]
sentence = delinquent(units, 200.0)["inverter-b"]
wd = DeliveryWatchdog(DeliveryConfig(hold_s=120.0))
for s in (0, 121):
wd.evaluate(sentence, _T0 + _td(seconds=s))
return [wd.reason]
def _freeze_texts():
out = []
for dev in (None, 4700.0):
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=600.0))
wd.evaluate(3600.0, 4480.0 if dev else 5000.0, 5000.0, _T0,
dev_min=-dev if dev else None, dev_max=dev)
out.append(wd.reason)
return out
def _stale_texts():
one = _fresh.Freshness(_fresh.BOUNDS["batt"])
for t in range(0, 40):
one.submit(-1200.0, -1200.0 if t < 1 else 1300.0, float(t))
two = _fresh.Freshness(_fresh.BOUNDS["batt"])
for t in range(0, 40):
two.submit(-1200.0, -1200.0 if t < 1 else 1300.0, float(t))
for t in range(40, 80):
two.submit(-1200.0, 0.0, float(t))
for t in range(80, 130):
two.submit(-1200.0, 1300.0, float(t))
return [one.reason, two.reason]
def _clamp_texts():
over = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6,
integrator_max_w=9000.0), i_w=8000.0)
at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6),
i_w=4500.0)
write = [(n, _clamp.clamp_to_rating(w, 5000.0, -2500.0, 2500.0))
for n, w in (("inverter-a", 3000.0), ("inverter-b", -7412.0))]
# One inverter is not named (main.py passes None), several are.
return [_clamp.loop_clamp_note(over), _clamp.loop_clamp_note(at),
_clamp.write_clamp_note([(None, write[0][1])]),
_clamp.write_clamp_note(write[:1]), _clamp.write_clamp_note(write)]
def _peak_texts():
proj = Projection(projected_w=4338.0, elapsed_w=4338.0, elapsed_s=799.0,
covered_frac=1.0, complete=False, block_start=_T0,
held_w=0.0)
kw = dict(cap_w=3500.0, reading=None, soc_floor=30.0, max_hold_s=30.0)
sat = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=80.0, **kw)
low = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=20.0, **kw)
unread = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=None, **kw)
return [sat.reason, low.reason, unread.reason]
def _migration_texts():
out = []
for moved, kept, lost, wrong in (
(2, 0, 0, 0), (2, 1, 1, 0), (2, 0, 0, 1), (0, 2, 0, 0),
(0, 2, 1, 0)):
rep = _store.MigrationReport()
rep.ran = moved > 0 or lost > 0
rep.from_version = 1
rep.roster = ["inverter-a"]
rep.moved = {f"unit/inverter-a/k{i}": True for i in range(moved)}
rep.kept = [f"unit/inverter-a/old{i}" for i in range(kept)]
for i in range(lost):
note = f"unit/inverter-a/k{i} was written as True and reads back as None"
rep.problems.append(note)
rep.lost.append(note)
for i in range(wrong):
note = (f"unit/garage/k{i} was migrated into namespace 'garage', "
f"which is not in the inverter list")
rep.problems.append(note)
rep.misdirected.append(note)
out.append(rep.explain())
return out
def _tariff_texts():
hours = [_T0 + _td(minutes=15 * i) for i in range(8)]
pts = [PricePoint(h, 0.2, i >= 4) for i, h in enumerate(hours)]
read = PriceRead.series(pts, PriceProduct.DAY_AHEAD, "x", _T0)
return [TariffPlan(read, None, "provider").words,
TariffPlan(PriceRead.no_data("the price feed timed out"), None,
"none").words]
def _static(spec):
"""Templates for a family of builders that is not one function: the first
argument of every call to `spec`, or every value assigned to `spec`, in
the named file (placeholders count as one word, as in part 3)."""
fname, kind, name = spec
out = []
for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))):
tree = ast.parse(f.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if kind == "call" and isinstance(node, ast.Call) and node.args \
and ast.unparse(node.func).split(".")[-1] == name:
out += [t for _n, t in strings_in(node.args[0])]
elif kind == "assign" and isinstance(node, ast.Assign) \
and ast.unparse(node.targets[0]) == name:
out += [t for _n, t in strings_in(node.value)]
elif kind == "return" and isinstance(node, ast.FunctionDef) \
and node.name == name:
out += [t for r in ast.walk(node) if isinstance(r, ast.Return)
and r.value is not None for _n, t in strings_in(r.value)]
return [t for t in out if len(words(t)) > 1]
# UX-11: a WARNING+ line quotes a safety reason by its code only. The reason,
# with its figures, is on a DEBUG line beside it and in the action log.
SAFETY_REASONS = {
"sign": _sign_texts, "circulation": _circ_texts,
"delivery": _delivery_texts, "clamp": _clamp_texts, "peak": _peak_texts,
"freeze": _freeze_texts, "stale": _stale_texts,
}
def _coded(maker):
"""The codes a WARNING+ line shows for the reasons `maker` builds, plus
the fallback for a restored stop with no reason saved."""
return lambda: ([messages.reason_code(t) for t in maker()]
+ [messages.reason_code("")])
TEXT = {
"main.py:messages.reason_code(first.sign_wd.reason)": _coded(_sign_texts),
"main.py:messages.reason_code(inv.sign_wd.reason)": _coded(_sign_texts),
"main.py:messages.reason_code(self.site_sign_wd.reason)":
_coded(_sign_texts),
"main.py:messages.reason_code(self.circ_wd.reason)": _coded(_circ_texts),
"main.py:messages.reason_code(inv.delivery_wd.reason)":
_coded(_delivery_texts),
"main.py:messages.reason_code(self.clamp_wd.reason)": _coded(_clamp_texts),
"main.py:messages.reason_code(g.reason)": _coded(_peak_texts),
"main.py:messages.reason_code(self.freeze_wd.reason)":
_coded(_freeze_texts),
"main.py:messages.reason_code(inv.batt_fresh.reason)": _coded(_stale_texts),
"main.py:rep.explain()": _migration_texts,
"store.py:rep.explain()": _migration_texts,
"main.py:plan.words": _tariff_texts,
# Families: every builder of the text, as a template.
"main.py:refusal": lambda: (_static(("main.py", "return", "write_refusal"))
+ _static(("allocator.py", "return", "authorises"))),
"main.py:' / '.join(resolution.scope_errors)":
lambda: _static(("arbiter.py", "return", "scope_violations"))
+ _static(("arbiter.py", "call", "append")),
"price_forecast.py:gate.reason": lambda: _static(("*", "call", "block")),
"static_tariff.py:read.reason": lambda: _static(("*", "call", "no_data")),
"price_history.py:self.last_error":
lambda: _static(("price_history.py", "assign", "self.last_error")),
"price_history.py:bad": lambda: _static(("price_history.py", "return",
"_parse_points")),
"maintenance.py:msg": lambda: _static(("maintenance.py", "assign", "msg")),
"validate.py:aged": lambda: _static(("*", "return", "stale_card")),
"validate.py:xaged": lambda: _static(("*", "return", "stale_card")),
# Refusals: part 3 holds them to the refusal rule already.
"selfconfig.py:problems[0]": None,
"validate.py:problem": None,
"p1.py:problem": None,
}
# UX-11: a WARNING+ line, as logged with its built text filled in, is at most
# 25 words (STYLE.md). These families are not safety reasons and were not
# rewritten by UX-11. Each is listed with why, the list may only shrink
# (LONG_LINE_CAP), and a listed key that no longer runs long fails.
LONG_LINE_OK = {
"main.py:plan.words": "tariff plan words, TARIFF-11 pins them, follow-up",
"static_tariff.py:read.reason": "price feed reasons, follow-up",
"main.py:rep.explain()": "state migration report, names saved keys",
"store.py:rep.explain()": "state migration report, names saved keys",
"main.py:refusal": "write refusals, a software fault only",
"main.py:' / '.join(resolution.scope_errors)":
"claim scope errors, a software fault only",
"maintenance.py:msg": "maintenance gate reasons, follow-up",
"price_forecast.py:gate.reason": "trading gate reasons, follow-up",
"price_history.py:self.last_error": "price history errors, follow-up",
}
LONG_LINE_CAP = 9
LINE_WORDS = 25
def reason_problems(text):
"""STYLE.md's red-alarm shape, for a text a log line quotes."""
out = []
n_s, n_w = sentences(text), len(words(text))
if n_s > 3:
out.append(f"over 3 sentences ({n_s})")
if n_w > 70:
out.append(f"over 70 words ({n_w})")
long = [x for x in re.split(r"(?<=[.!?])\s+", text) if len(words(x)) > 30]
if long:
out.append(f"a sentence over 30 words ({len(words(long[0]))})")
return out + line_problems(text)
def logged_shape(line):
"""UX-10 review: the line as logged, wrapper and reason together, has the
red-alarm shape too. The reason alone passing is not enough: the 2d-style
"title: reason. tail. tail." line read as 4 sentences with a 3-sentence
reason inside it."""
out = []
if sentences(line) > 3:
out.append(f"over 3 sentences ({sentences(line)})")
long = [x for x in re.split(r"(?<=[.!?])\s+", line) if len(words(x)) > 30]
if long:
out.append(f"a sentence over 30 words ({len(words(long[0]))})")
return out
def line_problems(text):
"""What no WARNING+ line may carry, whatever its length."""
out = []
if ";" in text:
out.append("semicolon")
if re.search(r"(?<!\.)\.\.(?!\.)|\.{4,}|\.:|:\s*\.", text):
out.append("a doubled full stop")
if PLURAL.search(text):
out.append("plural: " + PLURAL.search(text).group(0))
return out + text_problems(text, keys=True, entities=True)
def _fill(template, fmt_node, key_part, sample):
"""The line as logged: `sample` in the text slot, `X` in the others."""
if isinstance(fmt_node, ast.JoinedStr):
return template.replace("{" + key_part + "}", sample)
return template # %-style: filled by the caller
def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
"""-> (unclassified keys, stale keys, [(key, sample, problems)])."""
text = TEXT if text is None else text
data = DATA if data is None else data
long_ok = LONG_LINE_OK if long_ok is None else long_ok
long_seen = set() if long_seen is None else long_seen
seen, bad = set(), []
for fname, tree in files:
for key, call in dynamic_parts(tree, fname):
seen.add(key)
if key in data or key not in text:
continue
maker = text[key]
if maker is None:
continue
i = 1 if ast.unparse(call.func).endswith("._moan") else 0
fmt = call.args[i]
template = render(fmt) or ""
args = [ast.unparse(a) for a in call.args[i + 1:]]
part = key.split(":", 1)[1]
for sample in maker():
p = reason_problems(sample)
if isinstance(fmt, ast.JoinedStr):
line = template.replace("{" + part + "}", sample)
else:
slots = iter(["%s" if a != part else "\0" for a in args])
line = re.sub(r"%[-+ #0-9.]*[sdrfgx]",
lambda m: next(slots, m.group(0)), template)
line = PLACEHOLDER.sub("X", line).replace("\0", sample)
p += [f"as logged: {x}" for x in line_problems(line)
+ logged_shape(line) if x not in p]
if len(words(line)) > LINE_WORDS:
long_seen.add(key)
if key not in long_ok:
p.append(f"as logged: over {LINE_WORDS} words "
f"({len(words(line))})")
if p:
bad.append((key, sample, p))
unknown = sorted(seen - set(data) - set(text))
stale = sorted((set(data) | set(text)) - seen)
return unknown, stale, bad
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))]
_long_seen = set()
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
for k, sample, p in _bad:
print(f" BAD {k}: {'; '.join(p)} :: {sample!r}")
check(f"every dynamic part of a WARNING+ line is classified as data or as a "
f"built text (unclassified: {_unknown})", not _unknown)
check(f"...and no classification is left over from a line that is gone "
f"(stale: {_stale})", not _stale)
_n_texts = sum(len(m()) for m in TEXT.values() if m is not None)
check(f"UX-11: no WARNING+ line runs over {LINE_WORDS} words as logged, except "
f"the {len(LONG_LINE_OK)} listed families, and none of them is a safety "
f"reason (cap {LONG_LINE_CAP})",
len(LONG_LINE_OK) <= LONG_LINE_CAP
and not any("reason_code" in k
or TEXT.get(k) in SAFETY_REASONS.values()
for k in LONG_LINE_OK))
check(f"...and every listed family still runs long, so the list only shrinks "
f"(no longer long: {sorted(set(LONG_LINE_OK) - _long_seen)})",
set(LONG_LINE_OK) <= _long_seen)
def _code_lines_without_evidence(files):
"""WARNING+ calls quoting `messages.reason_code(X)` whose next statement
is not a DEBUG call that logs X itself (UX-11: nothing is lost)."""
out, n = [], 0
for fname, tree in files:
for node in ast.walk(tree):
for body in (getattr(node, f, None) for f in ("body", "orelse")):
if not isinstance(body, list):
continue
for i, st in enumerate(body):
call = st.value if isinstance(st, ast.Expr) else None
if not (isinstance(call, ast.Call)
and LOG_CALL.search(ast.unparse(call.func))):
continue
coded = [a.args[0] for a in call.args
if isinstance(a, ast.Call) and a.args
and ast.unparse(a.func) == "messages.reason_code"]
if not coded:
continue
n += 1
nxt = body[i + 1] if i + 1 < len(body) else None
dbg = (nxt.value if isinstance(nxt, ast.Expr)
and isinstance(nxt.value, ast.Call)
and ast.unparse(nxt.value.func).endswith(".debug")
else None)
want = ast.unparse(coded[0])
if dbg is None or not any(
ast.unparse(a) == want
or ast.unparse(a).startswith(want + " or ")
for a in dbg.args):
out.append(f"{fname}:{call.lineno}")
return n, out
_n_coded, _no_evidence = _code_lines_without_evidence(_FILES)
check(f"every WARNING+ line that quotes a reason code ({_n_coded}) has the "
f"reason itself on a DEBUG line right after it (missing: {_no_evidence})",
_n_coded >= 14 and not _no_evidence)
_n_m, _m_bad = _code_lines_without_evidence([("x.py", ast.parse(
"_LOG.error('Stop: %s.', messages.reason_code(wd.reason))\n"
"_LOG.debug('Something else.')\n"))])
check("...and a line whose DEBUG evidence was dropped is caught",
_n_m == 1 and _m_bad == ["x.py:1"])
_evidence_bad = [(n, t, reason_problems(t)) for n, m in SAFETY_REASONS.items()
for t in m() if reason_problems(t)]
check(f"the safety reasons behind those lines, on the DEBUG line and in the "
f"action log, keep the red-alarm shape ({_evidence_bad})",
not _evidence_bad)
_codes = {n: [messages.reason_code(t) for t in m()]
for n, m in SAFETY_REASONS.items()}
check(f"...and every one carries a code the line can quote ({_codes})",
all(re.fullmatch(r"[A-Z]+(?:_[A-Z]+)+", c)
and c not in (messages.NO_REASON_SAVED, messages.NO_REASON_CODE)
for cs in _codes.values() for c in cs))
check(f"every built text, run for real, obeys the rule, and so does the line "
f"that quotes it ({_n_texts} texts, {len(_bad)} bad)", not _bad)
_counts = [f"{f}:{n.lineno}" for f, t in _FILES for n in ast.walk(t)
if isinstance(n, ast.Call) and (LOG_CALL.search(ast.unparse(n.func))
or ast.unparse(n.func).endswith("._moan"))
for a in n.args[:2] if isinstance(a, ast.Constant)
and isinstance(a.value, str) and COUNT_PLURAL.search(a.value)]
check(f"no WARNING+ line puts a plural noun after a bare count, which reads "
f"'1 times' (use messages.times() and friends; found: {_counts})",
not _counts)
# UX-10 review: the two clamp lines the owner quoted, as logged for one
# inverter, fit the red shape the tight way: at most 3 sentences and 30 words
# in all, code and numbers kept. The real literals from main.py, filled with
# the real notes.
def _clamp_line(opening):
for n in ast.walk(dict(_FILES)["main.py"]):
if isinstance(n, ast.Call) and LOG_CALL.search(ast.unparse(n.func)) \
and n.args and (render(n.args[0]) or "").startswith(opening):
return n.args[0].value
return None
_over, _at, _w1, _w1n, _w2 = _clamp_texts()
_clamp_lines = {
"error, loop": (_clamp_line("Power request too high"), _over),
"error, write": (_clamp_line("Power request too high"), _w1),
"warning, loop": (_clamp_line("The battery gives less than asked"), _at),
}
_clamp_shown = {k: (f % (messages.reason_code(r), messages.times(1))
if f else None)
for k, (f, r) in _clamp_lines.items()}
for k, v in _clamp_shown.items():
print(f" {k}: {v!r} ({len(words(v or ''))} words)")
check("UX-11: the clamp lines are at most 25 words and keep OUTPUT_CLAMP and "
"the count, and the numbers are in the reason the DEBUG line beside "
"them logs",
all(v and len(words(v)) <= LINE_WORDS
and "OUTPUT_CLAMP" in v and "1 time so far" in v
and "+9000" not in v for v in _clamp_shown.values())
and "software fault" in _clamp_shown["error, loop"]
and all(x in _over for x in ("+9000 W", "+5000 W", "+2000 W")))
# Mutations: the scan catches what the 2d scan missed.
_OLD_CLAMP = ("OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, "
"and it was clamped to +2000 W; the loop's command this cycle was "
"+440 W. This is a BUG SIGNAL, not a limit being used: something "
"upstream computed a setpoint the actuator cannot deliver.")
_mut_text = dict(TEXT, **{"main.py:messages.reason_code(self.clamp_wd.reason)":
lambda: [_OLD_CLAMP]})
_u, _s, _b = scan_log_lines([f for f in _FILES if f[0] == "main.py"],
text=_mut_text)
check("an injected bad reason (the 2d clamp text) is caught, on both clamp "
f"lines ({sorted({k for k, _t, _p in _b if 'clamp' in k})})",
[k for k, _t, _p in _b].count(
"main.py:messages.reason_code(self.clamp_wd.reason)") == 2)
# UX-11: the line that quoted the whole STALE_READING reason (60 words on
# TEST-23) is caught by the length rule alone.
_old_stale = dict(TEXT, **{"main.py:messages.reason_code(inv.batt_fresh.reason)":
_stale_texts})
_u, _s, _b = scan_log_lines([f for f in _FILES if f[0] == "main.py"],
text=_old_stale)
check("...and the UX-10 stale line, which quoted the whole reason, is caught "
f"for its length ({[p for k, _t, p in _b if 'batt_fresh' in k][:1]})",
any("batt_fresh" in k and any(f"over {LINE_WORDS} words" in x for x in p)
for k, _t, p in _b))
_new_site = ast.parse("_LOG.warning('Something broke: %s', self.new_wd.reason)")
_u, _s, _b = scan_log_lines([("main.py", _new_site)])
check(f"...and a new line quoting a text nobody classified is caught ({_u})",
_u == ["main.py:self.new_wd.reason"])
check("...and '1 times', '(s)', a semicolon and a doubled full stop in a "
"logged line are caught",
all(line_problems(t) for t in ("Cut back 1 times so far.",
"2 malformed point row(s) dropped.",
"It failed; retrying.",
"Cut back to +2000 W.. Only this.",
"index 0, 1, .... Those are left out.",
"moved to one part: . Problems")))
check("...and so is the 2d-style line, a 2-sentence reason inside a title and "
"two tails, which reads as 4 sentences although the reason passes",
logged_shape("Power request too high: OUTPUT_CLAMP: cut to +5000 W. The "
"command was +1000 W. Control is still running. Cut back "
"1 time so far.")
and not reason_problems("OUTPUT_CLAMP: cut to +5000 W. The command was "
"+1000 W"))
check("...while '1 s' and '1 time' are fine",
not line_problems("Held for 1 s, cut back 1 time so far."))
# INFO lines are not held to the length rule, but the 2d test found "§8.3",
# ISO timestamps and "keys ARE" in them. Their literals may carry none.
INFO_JARGON = re.compile(r"§\s?\d|isoformat|\b(?:ARE|NOT|AND)\b")
_info_bad = [f"{f}:{n.lineno}" for f, t in _FILES for n in ast.walk(t)
if isinstance(n, ast.Call) and n.args
and re.search(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.info$",
ast.unparse(n.func))
for _n, s in strings_in(n.args[0])
if INFO_JARGON.search(ast.unparse(n.args[0])) or SHOUT.search(s)]
check(f"no INFO line carries a section number, an ISO timestamp or capitals "
f"(found: {_info_bad})", not _info_bad)
check("...and one that did is caught",
bool(INFO_JARGON.search('"§8.3 price history: %s"'))
and bool(SHOUT.search("the flat keys ARE NOT that pack's history")))
# ============================================================================== #
print("\n7. log levels, log noise and the display bugs (UX-10)")
import logging # noqa: E402
from app import hass as _hass, p1 as _p1, validate as _validate, web as _web # noqa: E402
from app.arbiter import Claim # noqa: E402
from app.main import Controller, why_key # noqa: E402
from app import control as _control # noqa: E402
from app import main as _main # noqa: E402
class _Rec(logging.Handler):
def __init__(self):
super().__init__(logging.DEBUG)
self.records = []
def emit(self, record):
self.records.append(record)
def _captured(logger_name, fn):
rec, lg = _Rec(), logging.getLogger(logger_name)
old = lg.level
lg.addHandler(rec)
lg.setLevel(logging.DEBUG)
try:
fn()
finally:
lg.removeHandler(rec)
lg.setLevel(old)
return rec.records
def _level_of(fname, words_in_line):
"""The level of the log call in `fname` whose literal holds the words."""
tree = ast.parse((APP / fname).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.args \
and re.search(r"_LOG\.\w+$", ast.unparse(node.func)):
text = render(node.args[0]) or ""
if words_in_line in text:
return ast.unparse(node.func).rsplit(".", 1)[-1]
return None
check("the shadow line of every cycle is DEBUG, not INFO in capitals "
f"({_level_of('main.py', 'Shadow mode: would send')})",
_level_of("main.py", "Shadow mode: would send") == "debug"
and _level_of("main.py", "SHADOW MODE: not commanding") is None)
check("'Shadow mode is on' and 'is off', which follow a setting or a button, "
f"are INFO ({_level_of('main.py', 'Shadow mode is on')}, "
f"{_level_of('main.py', 'Shadow mode is off')})",
_level_of("main.py", "Shadow mode is on") == "info"
and _level_of("main.py", "Shadow mode is off") == "info")
_access = {}
for _lvl in (logging.INFO, logging.DEBUG):
logging.getLogger("goodwe.web").setLevel(_lvl)
_access[logging.getLevelName(_lvl)] = _web.access_log()
logging.getLogger("goodwe.web").setLevel(logging.NOTSET)
_start_src = " ".join(
ast.unparse(n) for n in ast.walk(ast.parse(
(APP / "web.py").read_text(encoding="utf-8")))
if isinstance(n, ast.AsyncFunctionDef) and n.name == "start")
check(f"the page's own polls are not logged at INFO: aiohttp's access log is "
f"off unless the log level is debug ({_access})",
_access["INFO"] is None
and isinstance(_access["DEBUG"], logging.Logger)
and "access_log=access_log()" in _start_src)
_validate.WARN_ONCE.seen.clear() # part 3 ran the validator already
_twice = _captured("goodwe.config", lambda: [
_validate.validate_startup_config(
dict(_BASE, price_provider="generic_dayahead", static_profile=""),
-5000, 5000) for _ in range(2)])
_sp = [r for r in _twice if "static_profile is empty" in r.getMessage()]
check(f"a settings warning is logged once per start, though the check runs "
f"twice ({len(_sp)} line(s))", len(_sp) == 1
and _sp[0].levelno == logging.WARNING)
def _outage():
o = _hass.Outage()
o.down("api", "error 502", "read sensor.x: 502, message='Bad Gateway'")
o.down("api", "error 502", "read sensor.y: 502")
o.down("websocket", "the meter connection closed",
"Received message 8:1000 is not str")
o.up("api")
o.up("websocket")
o.down("api", "no connection", "Cannot connect to host supervisor:80")
_out = _captured("goodwe.hass", _outage)
_warn = [r.getMessage() for r in _out if r.levelno == logging.WARNING]
_info = [r.getMessage() for r in _out if r.levelno == logging.INFO]
check(f"a Home Assistant restart is one plain WARNING line, and one INFO line "
f"when both connections are back ({_warn}, {_info})",
_warn[:1] == ["Home Assistant is not answering (error 502), perhaps it "
"is restarting. The add-on keeps trying."]
and _info == ["Home Assistant answers again."] and len(_warn) == 2)
check("...and the library's own text is kept, at DEBUG",
any("Received message 8:1000" in r.getMessage() for r in _out
if r.levelno == logging.DEBUG)
and not any("8:1000" in m or "Bad Gateway" in m for m in _warn + _info))
class _Resp400:
def __init__(self, body):
self.status, self.reason, self._body = 400, "Bad Request", body
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def text(self):
return self._body
class _Session400:
def __init__(self, body):
self.body = body
def post(self, *a, **k):
return _Resp400(self.body)
def _refused(body):
ha = _hass.HomeAssistant(_Session400(body), token="t")
ok = []
recs = _captured("goodwe.hass", lambda: ok.append(asyncio.run(
ha.call("number", "set_value", {"entity_id": "number.sp", "value": 1}))))
return (ok[0] if ok else None), [r.getMessage() for r in recs
if r.levelno == logging.WARNING]
def _one(res):
"""(write result, the one WARNING line) or a named absence that fails."""
ok, lines = res
return ok, (lines[0] if len(lines) == 1 else f"<{len(lines)} lines>")
_bare_ok, _bare = _one(_refused("400: Bad Request"))
_full_ok, _full = _one(_refused(
'{"message": "Value 6000 for number.sp is outside valid range"}'))
check(f"a write Home Assistant refuses while it loads is one plain WARNING, "
f"still a failed write, without the empty '400: Bad Request' ({_bare!r})",
_bare_ok is False
and _bare.startswith("Home Assistant refused number.set_value for "
"number.sp (error 400, no details).")
and "Bad Request" not in _bare)
check(f"...while a body that says more is still quoted ({_full!r})",
_full_ok is False and "outside valid range" in _full)
class _WS:
def __init__(self, kind):
self.kind = kind
async def receive(self):
return type("M", (), {"type": self.kind, "data": "{}"})()
def _closed_ws():
try:
asyncio.run(_p1._receive_json(_WS(_p1.aiohttp.WSMsgType.CLOSE)))
except ConnectionResetError as e:
return str(e)
return None
check(f"a socket Home Assistant closed is a plain ConnectionResetError, which "
f"the reconnect loop reports through that one line ({_closed_ws()!r})",
(_closed_ws() or "").startswith("Home Assistant closed the connection"))
# "Why: Other". Every winner and every safety reason the code can produce has
# words. The keys are found in the code: each `Claim.set(source, ..., reason)`
# through main.why_key(), plus main.py's own refusal to split ("unsplit").
_SOURCES = {}
for _f in sorted(APP.glob("*.py")):
_tree = ast.parse(_f.read_text(encoding="utf-8"))
_consts = _module_constants(_tree)
for _n in ast.walk(_tree):
if isinstance(_n, ast.Call) and ast.unparse(_n.func) == "Claim.set" \
and _n.args:
_src = _n.args[0]
_src = (_src.value if isinstance(_src, ast.Constant)
else _consts.get(getattr(_src, "id", ""), None))
_rsn = next((k.value for k in _n.keywords if k.arg == "reason"),
_n.args[3] if len(_n.args) > 3 else None)
_rsn = _rsn.value if isinstance(_rsn, ast.Constant) else ""
_SOURCES.setdefault((_src, _rsn), f"{_f.name}:{_n.lineno}")
_keys = {why_key(s, [Claim.set(s, 0, 0.0, r)]) for s, r in _SOURCES}
_keys |= {why_key("failsafe", []),
why_key("failsafe", [Claim.set("loop", 0, 1.0, "tracking")])}
# SAFETY-52: the loop's claim reason is computed, so the scan cannot see it.
_keys |= {why_key("loop", [Claim.set("loop", 0, 0.0, _r)])
for _r in (_control.REFUSED, _control.PROBING)}
check("...including the loop's two battery-refuses reasons, which get words of "
"their own", {_control.REFUSED, _control.PROBING} <= _keys)
check(f"the scan found the claims ({len(_SOURCES)}), including the latched "
f"per-inverter stop the 2d test saw as 'Other'",
("failsafe", "sign disagreement") in _SOURCES and len(_SOURCES) >= 8)
_missing_why = sorted(k for k in _keys if k not in messages.WHY_WINNER)
check(f"every winner and safety reason has words in messages.WHY_WINNER "
f"(missing: {_missing_why}, keys: {sorted(_keys)})", not _missing_why)
check("...so the latched stop reads as a safety stop, not 'Other' "
f"({messages.why(why_key('failsafe', [Claim.set('failsafe', 0, 0.0, 'sign disagreement')]), 0)!r})",
messages.why(why_key("failsafe", [Claim.set("failsafe", 0, 0.0,
"sign disagreement")]), 0)
== "Safety stop, power went the wrong way: 0 W")
_without = {k: v for k, v in messages.WHY_WINNER.items()
if k != "sign disagreement"}
check("...and a table that lost that entry is caught",
any(k not in _without for k in _keys))
class _H:
async def number(self, *a, **k):
return None
async def limits(self, *a):
return (-2000.0, 2000.0, 1.0)
async def call(self, *a, **k):
return True
async def set_number(self, *a, **k):
return True
class _S:
data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _P:
enabled = False
def publish(self, *a, **k):
pass
_c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
"max_w": 2000, "stale_input_s": 15}, _H(), _S(), _P(),
stop=asyncio.Event())
_c.p1_enabled = True
_c.p1 = _p1.P1Ingest(max_age_s=30.0)
def _meter_row():
"""The meter row, or a named absence that fails every check below."""
rows = [r for r in _c.checks() if r.get("id") == "meter"]
return rows[0] if len(rows) == 1 else {
"ok": None, "warn": None, "text": f"<{len(rows)} meter rows>"}
_r0 = _meter_row()
_c.p1.started_mono -= 31.0
_r1 = _meter_row()
_c.p1.submit(_p1.make_sample(_p1.SOURCE_HA, 500.0, 0.0, phases=1))
_r2 = _meter_row()
check(f"right after a start, before the first meter reading and inside its "
f"limit, the meter row is amber and waits, not red at 0 s ({_r0})",
_r0["warn"] and not _r0["ok"]
and _r0["text"] == "Meter: waiting for the first reading, limit 30 "
"seconds")
check(f"...past the limit with still no reading it is red, as before ({_r1})",
not _r1["ok"] and not _r1["warn"]
and _r1["text"].startswith("Meter: no reading for 31 seconds"))
check(f"...and a reading makes it green ({_r2['text']!r})",
_r2["ok"] and _r2["text"].startswith("Meter: 500 W"))
_c.auto = True
_c.p1_enabled = False
asyncio.run(_c.cycle())
_w0 = _c.why_words
_c._why_waiting_since -= _td(seconds=7)
_w7 = _c.why_words
check(f"'A reading is missing for N seconds' counts while control waits, "
f"instead of the age the last cycle saw ({_w0!r} -> {_w7!r})",
_w0 == "A reading is missing for 0 seconds, control waits"
and _w7 == "A reading is missing for 7 seconds, control waits")
_c.why_words = "Keeping the grid near zero: 0 W"
check("...and once a cycle decides again, the line is that decision",
_c.why_words == "Keeping the grid near zero: 0 W")
# UX-11 item 3: past the hold, the Why line says which reading is missing,
# not "A reading is missing: 0 W". Driven through cycle() for real.
_c.inputs_bad_since = _dt.now(_tz.utc) - _td(seconds=20)
asyncio.run(_c.cycle())
check(f"past the hold, the Why line names the missing reading "
f"({_c.why_words!r})",
_c.why_words == "No usable meter reading for over 15 seconds: 0 W")
# UX-11 item 2: the red meter row says the log shows why. A meter that goes
# quiet logs one WARNING per episode, not one per cycle, and INFO when back.
_c.p1_enabled = True
_c.p1 = _p1.P1Ingest(max_age_s=30.0)
def _meter_at(age_s):
import time as _time
_c.p1.submit(_p1.make_sample(_p1.SOURCE_HA, 500.0, 0.0, phases=1,
ingest_mono=_time.monotonic() - age_s))
def _quiet_episodes():
# Two quiet episodes close together, then a third 10 minutes on.
for age in (1, 40, 45, 50, 1, 2, 60, 65, 1):
_meter_at(age)
asyncio.run(_c.read_inputs())
_c._meter_quiet_warned -= _main.METER_QUIET_REPEAT_S
for age in (40, 45, 1):
_meter_at(age)
asyncio.run(_c.read_inputs())
_mq = [r for r in _captured("goodwe", _quiet_episodes)
if "meter" in r.getMessage().lower()]
_mq_warn = [r.getMessage() for r in _mq if r.levelno == logging.WARNING]
_mq_info = [r.getMessage() for r in _mq if r.levelno == logging.INFO]
_mq_dbg = [r.getMessage() for r in _mq if r.levelno == logging.DEBUG]
check(f"a meter that goes quiet logs one WARNING per episode, not per cycle, "
f"and one INFO when readings return, and a flapping meter at most one "
f"pair per {_main.METER_QUIET_REPEAT_S:.0f} s ({_mq_warn}, {_mq_info}, "
f"{_mq_dbg})",
len(_mq_warn) == 2 and _mq_info == ["Meter readings are back."] * 2
and len(_mq_dbg) == 2
and _mq_warn[0] == "No meter reading for over 30 seconds, so control "
"waits and then sets the battery to 0 W."
and len(words(_mq_warn[0])) <= LINE_WORDS)
# UX-11 item 1: a state file saved before UX-11 holds the long reasons, and
# they load unchanged. The ERROR line at start shows the code, and the saved
# text goes on the DEBUG line beside it. Old and new files look the same,
# because the saved reason's format did not change and it opens with its code.
_OLD = {
"sign": "SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
"+1500 W for 180 s continuously (limits: both over 300 W, held "
"over 180 s)",
"delivery": "UNIT_NOT_DELIVERING: inverter-a is standing at +2000 W and "
"delivering +0 W - 2000 W of its share (100 % of it) is not "
"arriving - while inverter-b is not in that state. Nothing "
"this add-on reads explains it - held for 121 s continuously "
"(limit: over 120 s)",
"circ": "CIRCULATING_POWER: 5800 W measured going round between the "
"inverters for 61 s, peaking at 6000 W, over the limits of 250 W "
"and 60 s. The meter cannot see it, because it nets to zero there",
}
class _OldStore(_S):
data = {"unit/inverter-a/sign_tripped": True,
"unit/inverter-a/sign_reason": _OLD["sign"],
"unit/inverter-a/delivery_tripped": True,
"unit/inverter-a/delivery_reason": _OLD["delivery"],
"circ_tripped": True, "circ_reason": _OLD["circ"],
"site_sign_tripped": True, "site_sign_reason": ""}
_restart = []
_rs = _captured("goodwe", lambda: _restart.append(Controller(
{"setpoint_entity": "number.sp", "meter_source": "off", "max_w": 2000},
_H(), _OldStore(), _P(), stop=asyncio.Event())))
_rs_err = [r.getMessage() for r in _rs if r.levelno == logging.ERROR]
_rs_dbg = [r.getMessage() for r in _rs if r.levelno == logging.DEBUG]
for _m in _rs_err:
print(f" {_m!r} ({len(words(_m))} words)")
check("an old state file's long reasons load unchanged, so the stops stay on",
_restart and _restart[0].inverters[0].sign_wd.tripped
and _restart[0].inverters[0].sign_wd.reason == _OLD["sign"]
and _restart[0].inverters[0].delivery_wd.reason == _OLD["delivery"]
and _restart[0].circ_wd.reason == _OLD["circ"])
check("...each ERROR line at start is one plain sentence with the code, at "
f"most {LINE_WORDS} words, and a stop saved without a reason says so",
len(_rs_err) == 4
and all(len(words(m)) <= LINE_WORDS and "(limits" not in m
for m in _rs_err)
and any(m.endswith(": SIGN_DISAGREEMENT.") for m in _rs_err)
and any(m.endswith(": UNIT_NOT_DELIVERING.") for m in _rs_err)
and any(m.endswith(": CIRCULATING_POWER.") for m in _rs_err)
and any(m.endswith(f": {messages.NO_REASON_SAVED}.") for m in _rs_err))
check("...and the saved text, figures and all, is on a DEBUG line beside it",
all(any(t in m for m in _rs_dbg) for t in _OLD.values()))
# UX-11 review: `log_level` defaults to info and `events` starts empty, so the
# saved figures also go on the action log, where `status` shows them.
_rs_ev = _restart[0].events if _restart else []
check(f"...and in the action log, which an installer reads at the default "
f"log level ({len(_rs_ev)} events)",
all(any(t in e for e in _rs_ev) for t in _OLD.values())
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
_main_src = (APP / "main.py").read_text(encoding="utf-8")
check("the action log counts 'engagements' with a real plural and the "
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src
and messages.count(25, "engagement") == "25 engagements"
and messages.count(1, "engagement") == "1 engagement")
# ============================================================================== #
print("\n8. the Home screen's sentences (UX-13 P1)")
# app/homeview.py builds every sentence the Home screen shows. Each is rendered
# with real values by `text_samples()` and held to STYLE.md: a title at most 5
# words, a plan line at most 2 sentences of at most 12 words each (the design
# note's new row type), any other line 1 sentence of at most 25 words.
from app import homeview # noqa: E402
PLAN_SENTENCE_WORDS, PLAN_SENTENCES = 12, 2
def home_problems(kind, text):
if kind == "title":
return title_problems(text)
out = text_problems(text, keys=False, entities=False)
if ";" in text:
out.append("semicolon")
if kind == "line":
if sentences(text) > 1:
out.append(f"over 1 sentence ({sentences(text)})")
if len(words(text)) > 25:
out.append(f"over 25 words ({len(words(text))})")
return out
if sentences(text) > PLAN_SENTENCES:
out.append(f"plan line over {PLAN_SENTENCES} sentences")
long = [x for x in re.split(r"(?<=[.!?])\s+", text)
if len(words(x)) > PLAN_SENTENCE_WORDS]
if long:
out.append(f"plan sentence over {PLAN_SENTENCE_WORDS} words "
f"({len(words(long[0]))}): {long[0]!r}")
return out
for _lang in ("en", "nl"): # UX-13 P5: Home in English and Dutch
_home = homeview.text_samples(_lang)
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)]
if p]
for k, t, p in _home_bad:
print(f" {_lang} {k}: {t!r}: {p}")
check(f"every Home sentence in {_lang} obeys its STYLE limit "
f"({len(_home)} rendered, {len(_home_bad)} over)",
len(_home) > 80 and not _home_bad)
_shadowed = [t for k, t in homeview.text_samples() if "ould" in t]
check("in shadow mode the Home plan says what the add-on WOULD do: every "
"shadow variant of a plan text carries 'would'",
all("ould" in f(True) for f in (
lambda sh: homeview.run_grid_what(2.5, sh),
lambda sh: homeview.run_give_what(2.5, sh),
lambda sh: homeview.run_cycle_what("idle", sh),
lambda sh: homeview.run_cycle_why(sh),
lambda sh: homeview.run_peak_what(3500, sh),
lambda sh: homeview.run_house_what(sh),
lambda sh: homeview.run_house_why(sh),
lambda sh: homeview.plan_head("house", sh),
lambda sh: homeview.plan_head("trade", sh),
lambda sh: homeview.line_peak_limit(3500, sh)))
and not any("ould" in f(False) for f in (
lambda sh: homeview.run_grid_what(2.5, sh),
lambda sh: homeview.run_house_what(sh),
lambda sh: homeview.plan_head("house", sh)))
and len(_shadowed) > 10)
check("mutation: a 13-word plan sentence and a 6-word title are caught",
home_problems("plan", "One two three four five six seven eight nine "
"ten eleven twelve thirteen.")
and home_problems("title", "One two three four five six"))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")