diff --git a/STYLE.md b/STYLE.md index dd5a0a3..7e645ae 100644 --- a/STYLE.md +++ b/STYLE.md @@ -9,7 +9,7 @@ This applies to everything a home owner or installer reads: notifications, phone - **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md. - **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its own codes, because that is the evidence an installer searches for. - **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3). -- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown. +- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON. - **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French. ## 2. Numbers diff --git a/goodwe_controller/DECISIONS/UX-08-short-notifications.md b/goodwe_controller/DECISIONS/UX-08-short-notifications.md index 9750f74..d5a7ae1 100644 --- a/goodwe_controller/DECISIONS/UX-08-short-notifications.md +++ b/goodwe_controller/DECISIONS/UX-08-short-notifications.md @@ -52,7 +52,7 @@ The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line 1. **No code prefixes on log lines.** Nothing outside the repositories greps them, except `/home/homelab/clampwatch.sh`, a one-off site watch from 2026-09-05 that greps `OUTPUT_CLAMP` and `Engagements so far`. `OUTPUT_CLAMP` is still in the reason the line quotes; "Engagements so far" is now "Cut back N times so far". The tests that found a line by its card id (`SAFETY-37`, `SAFETY-44`, `CONTROL LOOP DIED`, `UNGRADED`) now find it by its words. 2. **Reason texts keep their codes.** A line quotes a watchdog's reason (`%s`), such as `STALE_READING: …` or `OUTPUT_CLAMP: …`. Those are the evidence an installer searches for and many tests pin them, so they were not rewritten. The line around them is plain. 3. **Reasoning moved to DEBUG, not deleted,** for the 10 lines marked DROP-to-DEBUG (for example the 14 768 W history behind the over-rating alarm, why SAFETY-42 reports and does not act, and the Eneco card advice). The 25-word line keeps the action and the numbers. SAFETY-57's promise stays on the ERROR line, as an ordered pair: "until it changes, or 17 s after the command drops below 250 W". -4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. +4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. It stays reachable as `events` in the `status` JSON (review of #136), because some entries are written nowhere else: a dropped inverter's reason, the unplaced watts, the peak guard's projection and the shadow decision. 5. **The top table.** *Why* is `messages.why()`: the arbiter's winner and limit in words ("Keeping the grid near zero: 440 W, held back by the peak limit"). `self.reason` keeps `Resolution.explain()` for the log and the checks. *Tariff*, *Price history*, *Arbitrage* and *Shadow mode* keep every figure TARIFF-11 and SAFETY-47 put there, each in its own slot, in words. Labels: *Battery level*, *Solar production*, *Power request*. 6. **The month-peak row became two rows**, because one sentence of 15 words cannot hold the peak, its time, its share of the limit and TARIFF-13's coverage figure. The store's state at start (`restore_state`) left the row: it is the INFO line at start. 7. **The peak guard rows** name the projection first and the limit second, as before (SAFETY-38). The blind row says what it means for the owner: the battery does not charge until the period can be measured again. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 82477d5..d20c55f 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -9,10 +9,10 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins. ## Words used on this page -- **Control board**: the small computer wired to the inverter's RS485 meter - bus (a T-CAN485 board with an ESP32 chip). It passes the add-on's power - request to the inverter. If the add-on goes quiet for about 30 seconds, it - sets the inverter to 0 W by itself. +- **Control board**: the small computer on the inverter's meter cable. It + passes the add-on's power request to the inverter. If the add-on goes quiet + for about 30 seconds, it sets the inverter to 0 W by itself. (For + installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.) - **Isolator**: the switch that cuts an inverter off from the mains. Switching it off stops the inverter, whatever the add-on or the control board asks. - **Developer Tools**: the Home Assistant page that lists every entity and its @@ -3183,7 +3183,10 @@ buttons reach the inverter. *For installers: the Configuration page is UX-02. The line under the buttons is `messages.hint()`, the plain form of the newest entry of the add-on's own -action log (`Controller.events`), which the page no longer shows.* +action log (`Controller.events`). The page no longer shows that log, but the +`status` JSON still carries its last 40 entries as `events`: open the add-on +page and add `status` to its address. The action-log tables in this document +refer to it.* ## Status page diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 7b6dfda..7cd312e 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -5260,6 +5260,11 @@ class Controller: return { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, + # The action log itself, newest first. The page does not show it + # (UX-08 2d), but an installer can still read it in the `status` + # JSON, because several events (a dropped inverter's reason, the + # peak guard's projection) are written nowhere else. + "events": list(self.events), } async def handle_action(self, what: str): diff --git a/goodwe_controller/app/p1.py b/goodwe_controller/app/p1.py index 0f19017..6c0f5c8 100644 --- a/goodwe_controller/app/p1.py +++ b/goodwe_controller/app/p1.py @@ -472,7 +472,7 @@ class P1Ingest: """ self.parse_errors += 1 self.last_error = str(err) - _LOG.warning("Meter reading refused: %s", err) + _LOG.warning("A meter message could not be read: %s", err) # -- what consumers read ------------------------------------------------- def age_s(self) -> float | None: diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index 7459936..d9cf7c8 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -402,6 +402,18 @@ def strings_in(node, expand=False): 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 = [] @@ -409,12 +421,16 @@ def collect(): 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({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)}) + 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): diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 187e5ad..06c23ea 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -1221,6 +1221,19 @@ rows = [r for r in c.checks() if r.get("id") == "peak_guard"] print(" status row: " + (rows[0]["text"] if rows else "(none)")) check("the status page carries exactly one peak-guard row, flagged", len(rows) == 1 and rows[0]["warn"]) +# UX-08 2d: the engaged row names what the battery is asked for FIRST and the +# limit SECOND. Both are watts, so only the ordered pair tells them apart. +from app import messages as _msg_on # noqa: E402 +_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay " + f"under {_msg_on.watts(c.peak_guard.ceiling_w)}") +_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} " + f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}") +check("...and it names the battery's share first and the limit second, and " + "the two render differently so the swap is visible", + len(rows) == 1 and _on in rows[0]["text"] + and _on_swapped not in rows[0]["text"] + and _msg_on.watts(c.peak_guard.floor_w) + != _msg_on.watts(c.peak_guard.ceiling_w)) check("an engagement that is not saturated raises NO notification - §10.3's " "'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house " "load bigger than the inverter is not a bug", diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 1012389..39940ea 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -375,6 +375,13 @@ check("the status page says so in words: a red row, at 0 W, cleared by start", for c in ctrl.checks())) check("the banner leads with it", ctrl.status_payload()["banner"] == messages.banner_direction(False)) +check("...the line under the buttons says it in plain words, and the action " + "log with the raw reason is still reachable in the status JSON", + ctrl.status_payload()["hint"].endswith( + messages.hint("direction", unit=ctrl.inverters[0].name)) + and ctrl.status_payload()["events"] == ctrl.events + and any(e.split(" ", 1)[1].startswith("FAILSAFE ") + for e in ctrl.status_payload()["events"])) check("it stays at 0 W on later cycles too", hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)