diff --git a/STYLE.md b/STYLE.md
index 7bbb891..34af509 100644
--- a/STYLE.md
+++ b/STYLE.md
@@ -6,7 +6,7 @@ This applies to everything a home owner or installer reads: notifications, phone
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "
" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
-- **Status row**: at most 15 words. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label. **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.
+- **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. **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. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
- **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.
diff --git a/goodwe_controller/DECISIONS/UX-08-short-notifications.md b/goodwe_controller/DECISIONS/UX-08-short-notifications.md
index 488a082..da54cb9 100644
--- a/goodwe_controller/DECISIONS/UX-08-short-notifications.md
+++ b/goodwe_controller/DECISIONS/UX-08-short-notifications.md
@@ -12,3 +12,17 @@ The owner decided on 2026-09-27: a title of at most about 5 everyday words and a
5. **SAFETY-32 at a roster of one.** The body no longer ends in a separate "WHICH PACK" paragraph. The pack's name is part of the first sentence ("The battery of inverter-b …"), and at a roster of one that is the only difference.
6. **Dropped as background, now in DOCS:** "charging stays blocked" (peak reserve), "why no other guard fired" (circulating power, not delivering), and the saturation-freeze distinction (over-rating). "The meter cannot see this" stays in the circulating-power card, as the owner asked.
7. **The Details link** opens the add-on's documentation page (`/hassio/addon//documentation`, with the slug taken from the container hostname) and names the section. A `#heading` anchor was not used because nobody has checked that Home Assistant's documentation tab renders heading ids; if it does, adding one is a one-line change. Outside an add-on container it names the section without a link.
+
+# Phase 2b: status rows and page banners
+
+Every row of the Commissioning list and every banner is now in `app/messages.py` (`row_*`, `banner_*`), and `test_messages.py` checks a rendering of each. The row and banner kinds are gone from `ux08_allowlist.txt` (200 → 168 entries). DOCS.md "Status page" explains the rows. **Unchanged:** every row's colour (ok / warn / bad), which rows show when, and the banner cascade order.
+
+## Where a choice was made
+1. **A red row may use 3 sentences (30 words).** STYLE.md gave rows 15 words; the owner allows red safety states 3 sentences. A red row needs room for what happened, the state (0 W) and how it clears. STYLE.md now says so, and the test applies it by colour.
+2. **Figures left the rows, not the product.** The reason texts (freeze, sign, circulating, delivery, peak guard) carry reason codes and limits; they stay on the ERROR/WARNING log line and the event list, as before. Rows keep the numbers a reader acts on: the stall age and the 0 W deadline, the meter age and limit, asked and sent for an over-rating, the peak limit, and "opposite for 12 of 60 seconds" while a check counts. So 2a item 2 ("also on the status row") no longer holds, and DOCS.md says "in the add-on log".
+3. **No entity id on any row**, even the green ones that used to print `sensor.x = 54`. The sensor rows name the setting (*Battery power for inverter-b*). Solar sources are numbered in the order of the settings list (*source 2 of 3*, *entry 1 of the reversed-sign list*). This retires TEL-12's rule that a one-entity site reads 0.11.0's sentence byte for byte. A `tools/tel11_pv_trace.py --compare` against a pre-UX-08 tree therefore shows the PV rows as changed telemetry, and the control documents stay equal. The per-source watts and units left the multi-source row. The total, the count and which source is missing remain.
+4. **UX-07 item 1.** `ClampWatchdog` now also counts `over_rating` and `bound` separately. After an episode, each count shows under its own alarm's title ("Power request too high: 2 times since start, not now", "Battery is not keeping up: …"). A site with both gets two amber rows where it had one.
+5. **The "Not ready" banner** is the first red row's own `banner`, or else its first sentence. The peak-reserve and over-rating rows set `banner` to their notification title. The three trips keep their own banner branches, reworded (e.g. "Inverters worked against each other. Every inverter is set to 0 W.").
+6. **How a latch clears stays on the row**, shortened: "Only starting control clears this", "A restart does not clear this", "only stopping and starting control clears this" (not delivering). SAFETY-25's sentence stays on the peak-reserve row.
+7. **Rows got an `id`** (`meter_frozen`, `direction`, `pv`, …), so tests find a row by what it is, not by its words. The page ignores it.
+8. **Left for later:** texts that rows embed from other modules and that were not flagged as rows (the month-peak line, `peakguard.explain()`, the liveness and stale notes, `shadow.banner()`). The month-peak row only lost its semicolons.
diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md
index 6260c62..fbd8814 100644
--- a/goodwe_controller/DOCS.md
+++ b/goodwe_controller/DOCS.md
@@ -477,7 +477,7 @@ History recorded before you set it keeps the old sign. See
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
**no** unit, or with a unit that is not one of those, is **refused**, and the
-status page says which entity and which unit. That is deliberate: there is no
+status page says so (*the sensor has no unit*). That is deliberate: there is no
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
here would be a silent factor of 1000 in the one channel that exists to be
believed a week later.
@@ -534,7 +534,7 @@ about:
|---|---|---|
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
-| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
+| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which source is missing |
| all three gone | `unknown` | as above |
**It does not publish the sum of the ones that answered.** Two inverters out of
@@ -546,8 +546,8 @@ it say something false. So the total goes to `unknown` and the recorder stores a
gap, exactly as it does for a single dead sensor.
The diagnostic is not thrown away — it is put where a person will see it. The
-status page names the entity that is missing, the reason, **and** what the
-others reported. That page is read live by a human who wants to fix something;
+status page names the missing source by its place in the `pv_entities` list
+(*source 2 of 3*). That page is read live by a human who wants to fix something;
the recorded number is read cold by an analysis that would believe it.
Note the **third row against the second**: a member honestly reporting `0 W` is
@@ -569,7 +569,7 @@ It is **instrumentation**. It is not an input to the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
battery being regulated; and its status row is a **caution**, never a
-`NOT READY`. Wiring production into the control law would be a behaviour change
+*Not ready*. Wiring production into the control law would be a behaviour change
on a system driving a real battery — and it would destroy the measurement,
because the loop would then be reacting to the thing being measured.
@@ -791,8 +791,8 @@ repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
the exact operating point the controller aims for. Freeze detection is a
separate problem and needs the low-power case solved first.
-You read it yourself instead: the add-on's status page shows it on the P1 line,
-as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
+You read it yourself instead: the add-on's status page shows it on the Meter
+row, as `… 4 readings refused, unchanged for 5 minutes`. On a house drawing real
power that figure stays in the seconds; minutes of it while the load is clearly
not near zero is the meter to go and look at.
@@ -1050,9 +1050,9 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
point **opposite ways** — charging the battery while the house imports, or
discharging it while the house exports — with both over `sign_disagree_w`, for
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
-there. The banner reads `FAILSAFE`, the commissioning list says which two
-numbers disagreed and for how long, and the `Controller status` entity reports
-`failsafe`.
+there. The banner reads *Command and meter point opposite ways*, the add-on
+log says which two numbers disagreed and for how long, and the `Controller
+status` entity reports `failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
@@ -1194,7 +1194,8 @@ start, because a `persistent_notification` is in memory and that is precisely ho
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
other latches.
-The banner reads **`NOT DELIVERING — …`** as a *warning*, not a failure,
+The banner reads **` is not delivering. Control keeps running.`** as a
+*warning*, not a failure,
and it **displaces** `Running — holding grid at zero`. That last sentence is a
claim the site cannot make while a unit is absent, and a page that goes on
reading healthy while the fault runs is the whole shape this check exists to
@@ -1428,7 +1429,7 @@ What exists today, and what does not:
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
-quarter-hour average to guard, and the status page says **NOT COVERED**. There
+quarter-hour average to guard, and the status page says *Peak guard: off*. There
the mitigation is still operational: set `max_w` no higher than the loop actually
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
there), fit the e-stop, and watch for the alarm.
@@ -1712,13 +1713,13 @@ the add-on UI.
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
-`off` there is no signal to threshold and the status page says **NOT COVERED**
-rather than pretending the guard is armed.
+`off` there is no signal to threshold and the status page says *Stuck-meter
+check: off* rather than pretending the guard is armed.
The same row appears for a second reason: a setpoint device that reports a range
from which no command magnitude is available in both directions leaves no rail
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
-reported as **NOT COVERED** too, and the detector stays silent rather than
+reported as *off* too, and the detector stays silent rather than
degenerating into the bare "unchanged for N seconds" rule this design exists to
avoid.
@@ -2851,8 +2852,8 @@ discharge, and the running 15-minute average is still projected over the peak
limit. That 15-minute period will set or raise the month's billed peak
(capaciteitstarief). The controller is not at fault: the house load is larger
than the inverter. Switching off load in the next few minutes is the only thing
-that still changes the bill. The projection and the watts are in the add-on log
-and on the status page. See *The capacity-peak guard*.
+that still changes the bill. The projection and the watts are in the add-on
+log. See *The capacity-peak guard*.
### Battery stopped limiting the peak
@@ -2888,8 +2889,8 @@ listening to the add-on; this has happened on this hardware, once for
6 h 25 min. Switch the inverters off at the isolator. The alarm is latched: a
Home Assistant or add-on restart does not clear it, and the card is raised
again every 5 min until you start control on the add-on page. The watts and the time
-window are in the add-on log and on the status page. See *The circulating-power
-alarm*.
+window are in the add-on log; the *Circulating* line on the status page shows the
+live watts. See *The circulating-power alarm*.
### An inverter is not delivering
@@ -2919,7 +2920,7 @@ regulating against a number that has stopped moving. The age is still fresh, so
no other guard stops it. This is an alarm only: control keeps running and keeps
following the reading. Held for a whole 15-minute period, it can set the month's
billed peak. Check the meter; if it is stuck, stop control on the add-on page.
-The limit and command figures are in the add-on log and on the status page. See
+The limit and command figures are in the add-on log. See
*The frozen-meter alarm*.
## The Web UI
@@ -2935,6 +2936,30 @@ inverter.** The configuration page reads and writes the add-on's own settings
and can restart the add-on; the set of commands that reach the hardware is
exactly the three buttons it always had.
+## Status page
+
+The banner at the top says in one line what matters most now. Below it, the
+**Commissioning** list has one short row per check: ✓ green is fine, ! amber
+needs a look, ✗ red needs action. The rows name settings in words, never a Home
+Assistant entity id. The full figures of any alarm are in the add-on log, on the
+ERROR or WARNING line of that alarm. The texts are in `app/messages.py`.
+
+| Row starts with | What it tells you |
+|---|---|
+| Controller | When the controller last ran. Red when it has stalled: the control board then sets the inverter to 0 W. |
+| Meter | The grid reading, its age, readings refused, and how long its value has not changed. |
+| Stuck-meter check | Whether the frozen-meter alarm can run. It needs the HomeWizard local source (`homewizard_local`), an inverter rating, and a power range the inverter can reach. *The inverter's own limit* means the setpoint device's range sets the threshold, below `max_w`. |
+| Meter reading may be stuck | The frozen-meter alarm is on. See *Meter reading may be stuck* under Notifications. |
+| *month*: peak … | This month's peak, and how close it is to the soft limit. |
+| Peak guard, Load too high, Battery at its peak reserve | What the capacity-peak guard is doing. *Peak guard: off* means `meter_source` is `off`, so there is no 15-minute average to guard. |
+| Battery level, Battery power, Grid power | The reading of that sensor. *No usable reading* means the sensor is missing **or** its reading is held back as too old (a separate stale-reading row then says so). Check it in Developer Tools before replacing it. |
+| Inverter control | The setpoint entity, and the power range the inverter accepts. |
+| Power request too high, Battery is not keeping up | The two loop alarms, each with its own count since start. After an episode clears, the count stays, ending in *not now*. See *The winding-loop alarm*. |
+| Direction check, Site direction check | The sign checks (SAFETY-02/30). *Opposite for 12 of 60 seconds* counts toward a stop. When it trips, the inverter, or every inverter for the site check, is set to 0 W until you start control. |
+| Circulating power check, Inverters worked against each other | SAFETY-31, only with several inverters. |
+| Delivery check, *name* is not delivering | SAFETY-42, only with several inverters. |
+| Solar production | The PV reading. Sources are numbered in the order of `pv_entities`. The *reversed-sign list* is `pv_invert_entities`. A strongly negative reading by day means that source's sign is reversed. |
+
## Status entities
If an MQTT broker is available the add-on publishes setpoint, grid power,
diff --git a/goodwe_controller/app/clamp.py b/goodwe_controller/app/clamp.py
index 9f8bda7..f321e93 100644
--- a/goodwe_controller/app/clamp.py
+++ b/goodwe_controller/app/clamp.py
@@ -358,6 +358,10 @@ class ClampWatchdog:
def __init__(self):
self.active = False
self.engagements = 0 # every engagement, never deduplicated
+ # UX-08: the same count split by alarm type, so the status page names
+ # each with its own alarm's title after the episode clears.
+ self.over_rating = 0 # bug=True: "Power request too high"
+ self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
self.since: datetime | None = None
self.last_alarm: datetime | None = None
@@ -399,6 +403,10 @@ class ClampWatchdog:
"""
if engaged:
self.engagements += 1
+ if bug:
+ self.over_rating += 1
+ else:
+ self.bound += 1
self.clean_cycles = 0
self.run_len += 1
rising = not self.active
diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py
index 0a10050..ffb9ce4 100644
--- a/goodwe_controller/app/main.py
+++ b/goodwe_controller/app/main.py
@@ -4551,27 +4551,24 @@ class Controller:
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
- out.append({"ok": True, "warn": False,
- "text": "control loop: starting"})
+ out.append({"ok": True, "warn": False, "id": "controller",
+ "text": messages.row_controller_starting()})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
- out.append({"ok": False, "warn": False,
- "text": f"control loop: NO PASS FOR {_pass_age:.0f} s"
- f" (layer 1 zeroes the inverter after "
- f"{FIRMWARE_INGEST_VETO_S:.0f} s without a "
- f"heartbeat)"})
+ out.append({"ok": False, "warn": False, "id": "controller",
+ "text": messages.row_controller_stalled(
+ _pass_age, FIRMWARE_INGEST_VETO_S)})
else:
- out.append({"ok": True, "warn": False,
- "text": f"control loop: last pass {_pass_age:.0f} s ago"})
+ out.append({"ok": True, "warn": False, "id": "controller",
+ "text": messages.row_controller_ran(_pass_age)})
if self.p1_enabled:
age = self.p1.published_age_s
if self.p1.stale:
- out.append({"ok": False, "warn": False,
- "text": f"P1 meter ({o.get('meter_source')}): no reading for "
- f"{age:.0f} s (limit {self.p1.max_age_s:.0f} s)"
- + (f" - last error: {self.p1.last_error}"
- if self.p1.last_error else "")})
+ # The last error is on the add-on log's WARNING line (p1.py).
+ out.append({"ok": False, "warn": False, "id": "meter",
+ "text": messages.row_meter_stale(
+ age, self.p1.max_age_s)})
else:
# ⚠️ unchanged_s is REPORTED, never thresholded and never folded
# into the age - see HomeWizardLocalSource.unchanged_s for why
@@ -4583,33 +4580,26 @@ class Controller:
# only homewizard_local has one, and p1_source is None until
# amain() builds the transport.
unchanged = getattr(self.p1_source, "unchanged_s", None)
- out.append({"ok": True, "warn": False,
- "text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
- f"{age:.0f} s old, {self.p1.samples} telegrams, "
- f"{self.p1.parse_errors} rejected"
- + (f", measurement unchanged for {unchanged:.0f} s"
- if unchanged is not None else "")})
+ out.append({"ok": True, "warn": False, "id": "meter",
+ "text": messages.row_meter(
+ self.p1.net_w, age, self.p1.parse_errors,
+ unchanged)})
# SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
# a detector nobody can see is armed is a detector nobody trusts
# when it fires. ok=False/warn=False so the banner goes red - this
# is an alarm, and the banner is the loudest surface we own. It
# changes NOTHING about control; see check_meter_frozen().
if self.freeze_wd.active:
- out.append({"ok": False, "warn": False,
- "text": "METER MAY BE FROZEN - " + self.freeze_wd.reason
- + ". The loop is STILL FOLLOWING it and is "
- "still running. Check the meter. Held here "
- "for a quarter of an hour this sets the "
- "month's billed capacity peak."})
+ # The figures are in `freeze_wd.reason`, on the log and the
+ # event list (check_meter_frozen()).
+ out.append({"ok": False, "warn": False, "id": "meter_frozen",
+ "text": messages.row_meter_frozen()})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
- out.append({"ok": False, "warn": True,
- "text": f"freeze detector: NOT COVERED on "
- f"meter_source {o.get('meter_source')} - only "
- f"homewizard_local sees whether the meter's own "
- f"document changes"})
+ out.append({"ok": False, "warn": True, "id": "freeze_check",
+ "text": messages.row_freeze_off("source")})
elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")):
@@ -4619,12 +4609,11 @@ class Controller:
# and an operator does not need to learn two vocabularies for
# it. What differs is only which term is missing: there,
# `unchanged_s`; here, a rail the command could ever stand at.
- out.append({"ok": False, "warn": True,
- "text": f"freeze detector: NOT COVERED - "
- + rail_unreachable(
- self.site_max_w,
- dev_min=self.site_dev_bound("dev_min"),
- dev_max=self.site_dev_bound("dev_max"))})
+ _no_rating = (not self.site_max_w
+ or not is_commandable(self.site_max_w))
+ out.append({"ok": False, "warn": True, "id": "freeze_check",
+ "text": messages.row_freeze_off(
+ "rating" if _no_rating else "range")})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included. This row used to interpolate
@@ -4643,17 +4632,12 @@ class Controller:
# reads `min` and `max` independently and returns None for
# whichever attribute is missing or unparseable - so this
# renders whatever is there rather than formatting a None.
- _cap = ("" if _rail >= rail_w(self.site_max_w)
- else " - below max_w because "
- + "; ".join(
- f"{inv.setpoint_entity or inv.name} accepts "
- f"only {inv.dev_min}...{inv.dev_max} W"
- for inv in self.inverters))
- out.append({"ok": True, "warn": False,
- "text": f"freeze detector: armed (alerts after "
- f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
- f"while the command is over "
- f"{_rail:.0f} W{_cap})"})
+ # UX-08: "the inverter's own limit" says the device set it; which
+ # inverter and its range are DOCS and the settings, not entity ids.
+ out.append({"ok": True, "warn": False, "id": "freeze_check",
+ "text": messages.row_freeze_on(
+ self.freeze_wd.cfg.hold_s, _rail,
+ own_limit=_rail < rail_w(self.site_max_w))})
# TARIFF-01. The month's billed peak, and - the load-bearing half -
# a DIFFERENT row when the store could not answer. "no peak yet" is
@@ -4673,8 +4657,8 @@ class Controller:
"text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit"
- + f"; {self.peak.recorded} quarter(s) banked"
- + f"; on start: {self.peak.restore_state}"})
+ + f", {self.peak.recorded} quarter(s) banked"
+ + f", on start: {self.peak.restore_state}"})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
@@ -4684,12 +4668,12 @@ class Controller:
# decision that has not been taken.
_pg = self.peak_guard
if _pg is None:
- out.append({"ok": True, "warn": True,
+ out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": "peak guard: no decision yet - the first "
"control cycle has not run"})
elif _pg.saturated:
- out.append({"ok": False, "warn": False,
- "text": "PEAK GUARD SATURATED - " + _pg.reason})
+ out.append({"ok": False, "warn": False, "id": "peak_guard",
+ "text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited:
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
@@ -4713,22 +4697,27 @@ class Controller:
# the whole sentence, because the row is where an operator
# reads what the floor does and does not bound. Shortening the
# ROW instead would be weakening AC 3.
- out.append({"ok": False, "warn": False,
- "banner": _pg.explain()
- + " - and peak_soc_floor bounds THE PEAK "
- "GUARD ONLY (SAFETY-25, see below)",
- "text": _pg.explain()
- + ". NOTE: peak_soc_floor bounds THE PEAK "
- "GUARD ONLY - it is not a pack-wide "
- "reserve, and ordinary self-consumption "
- "has no SoC floor, so the battery may "
- "keep discharging below this level down "
- "to the BMS cut-out (SAFETY-25)"})
+ # UX-08: the same three branches, and the same pack naming, as
+ # check_peak_floor_limited()'s notification. The figures stay in
+ # `_pg.reason`, on the log and the event list.
+ _blocked = [name for name, why
+ in peakguard.pack_permissions(self.packs,
+ _pg.soc_floor)
+ if why is not None]
+ out.append({"ok": False, "warn": False, "id": "peak_guard",
+ "banner": messages.banner_peak_reserve(),
+ "text": messages.row_peak_reserve(
+ _pg.ceiling_w, soc_unread=_pg.soc is None,
+ floor_unknown=(_pg.soc is not None
+ and _pg.soc_floor is None),
+ packs=(_blocked if len(self.inverters) > 1
+ else ()))})
elif _pg.blind:
- out.append({"ok": False, "warn": True, "text": _pg.explain()})
+ out.append({"ok": False, "warn": True, "id": "peak_guard",
+ "text": _pg.explain()})
else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
- "text": _pg.explain()})
+ "id": "peak_guard", "text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
@@ -4737,11 +4726,8 @@ class Controller:
# leave the one install that is unprotected showing nothing at all.
# Same answer, same reason, as the freeze detector's NOT COVERED row
# a few lines up.
- out.append({"ok": False, "warn": True,
- "text": f"peak guard: NOT COVERED on meter_source "
- f"{o.get('meter_source')} - the quarter-hour "
- f"average is measured from P1 telegrams and this "
- f"path has none, so a runaway cannot be capped"})
+ out.append({"ok": False, "warn": True, "id": "peak_guard",
+ "text": messages.row_peak_guard_off()})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on
# a site with two packs is a page that shows one pack and calls it the
@@ -4749,17 +4735,21 @@ class Controller:
# that is what an existing install reads.
rows = []
for inv in self.inverters:
- tag = "" if len(self.inverters) == 1 else f" ({inv.name})"
- rows.append((f"battery SoC{tag}", inv.soc, inv.soc_entity))
- rows.append((f"battery power{tag}", inv.batt, inv.batt_entity))
+ unit = None if len(self.inverters) == 1 else inv.name
+ rows.append((messages.LABEL_SOC, unit, inv.soc, inv.soc_entity))
+ rows.append((messages.LABEL_BATTERY, unit, inv.batt,
+ inv.batt_entity))
if not self.p1_enabled:
# In P1 mode the check above replaces this one; leaving both in
# would report "no entity configured" for a meter_entity that is
# correctly unused, i.e. a permanent false NOT READY.
- rows.insert(0, ("grid power", self.grid, o.get("meter_entity")))
- for label, value, entity in rows:
+ rows.insert(0, (messages.LABEL_GRID, None, self.grid,
+ o.get("meter_entity")))
+ # UX-08: the rows name the setting, never the entity id (STYLE.md).
+ for label, unit, value, entity in rows:
if not entity:
- out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
+ out.append({"ok": False, "warn": False, "id": "sensor",
+ "text": messages.row_sensor_unset(label, unit)})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
@@ -4783,32 +4773,25 @@ class Controller:
# pre-empting the answer. Same reason the wording is not shared
# with `freshness._grade()`: that one is a log line about a
# BEHAVIOUR, this one is a banner about an ENTITY.
- out.append({"ok": False, "warn": False,
- "text": f"{label}: no usable value this cycle from "
- f"{entity} - and this row cannot tell which "
- f"of two things that is: the entity is "
- f"missing or not numeric, or it is "
- f"publishing a number that this controller "
- f"is withholding (a freshness gate does "
- f"exactly that, and names itself in a row "
- f"of its own). Confirm the entity in "
- f"Developer Tools before replacing it"})
+ out.append({"ok": False, "warn": False, "id": "sensor",
+ "text": messages.row_sensor_unusable(label, unit)})
else:
- out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
+ out.append({"ok": True, "warn": False, "id": "sensor",
+ "text": messages.row_sensor(label, value, unit)})
for inv in self.inverters:
sp = inv.setpoint_entity
- tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
+ unit = None if len(self.inverters) == 1 else inv.name
if not sp:
- out.append({"ok": False, "warn": False,
- "text": f"setpoint entity not configured{tag}"})
+ out.append({"ok": False, "warn": False, "id": "control",
+ "text": messages.row_control_unset(unit)})
elif inv.dev_max is None:
- out.append({"ok": False, "warn": False,
- "text": f"setpoint entity {sp} not found"})
+ out.append({"ok": False, "warn": False, "id": "control",
+ "text": messages.row_control_missing(unit)})
else:
- out.append({"ok": True, "warn": False,
- "text": f"setpoint {sp} (range {inv.dev_min:g}…"
- f"{inv.dev_max:g} W)"})
+ out.append({"ok": True, "warn": False, "id": "control",
+ "text": messages.row_control(inv.dev_min,
+ inv.dev_max, unit)})
# DEV-02. Whether this controller can still put a number on each
# inverter, and it is reported in the ONE state that matters: a unit
@@ -4825,28 +4808,35 @@ class Controller:
# evidence with it. Reported only when there is something to report:
# unlike the freeze detector this guard has no "not covered" state, it
# runs on every write on every transport.
- if self.clamp_wd.active and self.clamp_wd.bug:
- out.append({"ok": False, "warn": False,
- "text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
- + f" Engagements: {self.clamp_wd.engagements}."})
- elif self.clamp_wd.active:
+ # UX-07 item 1 / UX-08: each count is shown under ITS alarm's name -
+ # over-rating cuts as "Power request too high", integrator-bound cycles
+ # as "Battery is not keeping up" - never the two summed under one.
+ _cw = self.clamp_wd
+ if _cw.active and _cw.bug:
+ out.append({"ok": False, "warn": False, "id": "power_too_high",
+ "banner": messages.banner_power_too_high(),
+ "text": messages.row_power_too_high(_cw.facts,
+ _cw.over_rating)})
+ elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
- out.append({"ok": False, "warn": True,
- "text": "LOOP AT INTEGRATOR BOUND - " + self.clamp_wd.reason
- + f" Engagements: {self.clamp_wd.engagements}."})
- elif self.clamp_wd.engagements:
- out.append({"ok": False, "warn": True,
- "text": f"output clamp: not engaged now, but it has "
- f"engaged {self.clamp_wd.engagements} time(s) in "
- f"{self.clamp_wd.episodes} episode(s) since "
- f"start - see the event log"})
+ out.append({"ok": False, "warn": True, "id": "not_keeping_up",
+ "text": messages.row_not_keeping_up(_cw.bound)})
+ else:
+ if _cw.over_rating:
+ out.append({"ok": False, "warn": True, "id": "power_too_high",
+ "text": messages.row_before(
+ messages.T_OUTPUT_CLAMP, _cw.over_rating)})
+ if _cw.bound:
+ out.append({"ok": False, "warn": True, "id": "not_keeping_up",
+ "text": messages.row_before(
+ messages.T_LOOP_BOUND, _cw.bound)})
for inv in self.inverters:
if inv.last_write_ok is False:
- tag = "" if len(self.inverters) == 1 else f" ({inv.name})"
- out.append({"ok": False, "warn": False,
- "text": f"last write to the inverter{tag} was "
- f"REJECTED - check the log"})
+ out.append({"ok": False, "warn": False, "id": "write",
+ "text": messages.row_write_refused(
+ None if len(self.inverters) == 1
+ else inv.name)})
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed
# is a watchdog nobody trusts when it fires.
@@ -4861,7 +4851,7 @@ class Controller:
# inverter's own power sensor. `sign_wd.against` is the same string the
# trip sentence uses, so the two cannot drift apart.
for inv in self.inverters:
- tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
+ unit = None if len(self.inverters) == 1 else inv.name
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
# shipped roster of one the two sentences below are the ones that
# have always been on the page, word for word - AC 8 is about what
@@ -4870,25 +4860,16 @@ class Controller:
# somebody has learned to recognise. At N>=2 the noun is load-
# bearing: the row would otherwise send them to look at P1 for a
# fault that is visible only on one inverter's own power sensor.
- vs = ("" if len(self.inverters) == 1
- else f" against {inv.sign_wd.against}")
+ # UX-08: at N>=2 `unit` is set, and the row says "its own power".
+ # The figures stay in `sign_wd.reason`, on the log and event list.
if inv.sign_wd.tripped:
- what = ("the meter" if len(self.inverters) == 1
- else inv.sign_wd.against)
- out.append({"ok": False, "warn": False,
- "text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
- + inv.sign_wd.reason
- + f". The inverter is held at 0 W. Find out why the "
- f"command opposed {what} before starting control "
- "again; starting clears this. Restarting the "
- "add-on does NOT clear it."})
+ out.append({"ok": False, "warn": False, "id": "direction",
+ "text": messages.row_direction_trip(unit)})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
- out.append({"ok": True, "warn": False,
- "text": f"sign watchdog{tag}: armed{vs} (trips after "
- f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over "
- f"{inv.sign_wd.cfg.min_w:.0f} W)"
- + (f" - opposed for {held:.0f} s now" if held else "")})
+ out.append({"ok": True, "warn": False, "id": "direction",
+ "text": messages.row_direction_check(
+ held, inv.sign_wd.cfg.hold_s, unit)})
# --- SAFETY-30: the SITE row -----------------------------------------
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
@@ -4898,25 +4879,13 @@ class Controller:
# operator learns to skim the list.
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
- out.append({"ok": False, "warn": False,
- "text": "SITE SIGN-DISAGREEMENT FAILSAFE - "
- + self.site_sign_wd.reason
- + ". EVERY inverter is held at 0 W. This is the "
- "SUM of the commands against the meter, so it is "
- "not one inverter's fault to find - look at the "
- "loop, the integrator and whatever won the "
- "arbitration. Starting control clears this; "
- "restarting the add-on does NOT."})
+ out.append({"ok": False, "warn": False, "id": "site_direction",
+ "text": messages.row_site_direction_trip()})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
- out.append({"ok": True, "warn": False,
- "text": f"site sign watchdog: armed against the sum of "
- f"the commands (trips after "
- f"{self.site_sign_wd.cfg.hold_s:.0f} s opposed, "
- f"both over "
- f"{self.site_sign_wd.cfg.min_w:.0f} W)"
- + (f" - opposed for {_sheld:.0f} s now"
- if _sheld else "")})
+ out.append({"ok": True, "warn": False, "id": "site_direction",
+ "text": messages.row_site_direction_check(
+ _sheld, self.site_sign_wd.cfg.hold_s)})
# --- SAFETY-31 -----------------------------------------------------
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
@@ -4924,28 +4893,20 @@ class Controller:
# here would invite the operator to clear "the other one".
_circ = self.site_circulating_w
if self.circ_wd.tripped:
- out.append({"ok": False, "warn": False,
- "text": "CIRCULATING POWER - " + self.circ_wd.reason
- + ". EVERY inverter is held at 0 W. The meter "
- "cannot see this fault, which is why nothing "
- "else reported it. Starting control clears "
- "this; restarting the add-on does NOT, and "
- "neither does restarting Home Assistant."})
+ out.append({"ok": False, "warn": False, "id": "circulating",
+ "text": messages.row_circulating_trip()})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
- out.append({"ok": True, "warn": False,
- "text": "circulating power: n/a at one inverter - "
- "sum(|Bi|) - |sum(Bi)| is identically zero"})
+ out.append({"ok": True, "warn": False, "id": "circulating",
+ "text": messages.row_one_inverter(
+ "Circulating power check")})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
- out.append({"ok": True, "warn": False,
- "text": "circulation watchdog: armed (trips after "
- f"{self.circ_wd.cfg.hold_s:.0f} s over "
- f"{self.circ_wd.cfg.min_w:.0f} W)"
- + (f" - now {_circ:.0f} W" if _circ else "")
- + (f", opposed for {held:.0f} s" if held else "")})
+ out.append({"ok": True, "warn": False, "id": "circulating",
+ "text": messages.row_circulating_check(
+ _circ, held, self.circ_wd.cfg.hold_s)})
# --- SAFETY-42 -------------------------------------------------------
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
@@ -4958,42 +4919,26 @@ class Controller:
# caution is what a fault nobody is acting on actually is.
if len(self.inverters) > 1:
for inv in self.inverters:
- tag = f" ({inv.name})"
if inv.delivery_wd.tripped:
- out.append({"ok": False, "warn": True,
- "text": f"NOT DELIVERING{tag} - "
- + inv.delivery_wd.reason
- + ". Nothing has been commanded differently and "
- "the rest of the roster is still regulating, "
- "so the site is short by this unit's share. "
- "Starting control clears this row; restarting "
- "the add-on does NOT, and neither does "
- "restarting Home Assistant."})
+ out.append({"ok": False, "warn": True, "id": "delivery",
+ "text": messages.row_not_delivering(inv.name)})
else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
- out.append({"ok": True, "warn": False,
- "text": f"delivery watchdog{tag}: armed (reports after "
- f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
- f"its share by over "
- f"{self.tuning.saturation_w:.0f} W, with a "
- f"sibling that is not)"
- + (f" - short for {_dheld:.0f} s now"
- if _dheld else "")})
+ out.append({"ok": True, "warn": False, "id": "delivery",
+ "text": messages.row_delivery_check(
+ inv.name, _dheld,
+ inv.delivery_wd.cfg.hold_s)})
else:
# AC 5, and the same honesty `circulating power: n/a` uses one block
# up. At one inverter the reduction this check is has no proper
# subset to find, for EVERY input, so "armed" would be a claim to be
# watching something that cannot happen.
- out.append({"ok": True, "warn": False,
- "text": "delivery watchdog: n/a at one inverter - a unit "
- "short of its share is indistinguishable from a "
- "plant that cannot deliver until there is a "
- "sibling to compare it with"})
+ out.append({"ok": True, "warn": False, "id": "delivery",
+ "text": messages.row_one_inverter("Delivery check")})
if not o.get("estop_fitted", False):
- out.append({"ok": False, "warn": True,
- "text": "no e-stop fitted: if this host dies the battery stays "
- "latched at its last command"})
+ out.append({"ok": False, "warn": True, "id": "estop",
+ "text": messages.row_no_estop()})
else:
out.append({"ok": True, "warn": False, "text": "e-stop fitted"})
@@ -5017,105 +4962,54 @@ class Controller:
# control path. The reason comes from `pv.watts()` rather than being
# re-derived here, so the page cannot disagree with the reading.
#
- # --- TEL-12: AND AT A ROSTER OF ONE THESE TWO SENTENCES ARE 0.11.0's --
- # ⚠️ THE `len(...) == 1` BRANCH IS 0.11.0's CODE UNCHANGED, DOWN TO THE
- # f-string, so an install that set `pv_entity` reads the row it already
- # reads. That is an ACCEPTANCE CRITERION rather than a courtesy, and it
- # is graded across trees by `tools/tel11_pv_trace.py` rather than argued
- # here: `pv.total()` passes a single entity's `why` through verbatim for
- # exactly this reason. Multi-entity sentences are separate BECAUSE they
- # have to say something the single-entity ones never could.
+ # TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08
+ # replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so
+ # a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows
+ # these rows as changed telemetry; the control documents stay equal.
roster = self.pv_roster
if roster:
- # TEL-14: each flipped entity is marked where its watts are printed;
- # an unflipped one gets "", so 0.12.0's sentences are unchanged.
+ # UX-08: sources are numbered in the order of the settings list,
+ # never named by entity id (STYLE.md). The per-source figures and
+ # units are what `pv.total()` returns; they stay in `pv_parts`.
flip = pv_read.inverted(self.o)
-
- def inv(ent):
- return " [sign inverted]" if ent in flip else ""
-
- if len(roster) == 1:
- if self.pv_w is None:
- out.append({"ok": False, "warn": True,
- "text": f"PV production: no reading from "
- f"{roster[0]} - {self.pv_why}"})
- else:
- # habit 14: the value sits inside its own literal text, in
- # its own slot, beside the entity it was read from and the
- # unit it was read in - so a transposition of any two of the
- # three is visible in the sentence rather than merely
- # present in it.
- out.append({"ok": True, "warn": False,
- "text": f"PV production: {self.pv_w:.0f} W "
- f"from {roster[0]} (reported "
- f"in {self.pv_why})" + inv(roster[0])})
+ n_src = len(roster)
+ if self.pv_w is None and n_src == 1:
+ out.append({"ok": False, "warn": True, "id": "pv",
+ "text": messages.row_pv_none(self.pv_why)})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
- # THOSE WORDS. `pv.total()`'s note is the argument; this is
- # where the operator meets it. The partial information is NOT
- # thrown away - every entity that reported is named with its
- # watts and every one that did not is named with its reason -
- # because this surface is read LIVE by a human who wants the
- # diagnostic, whereas the MQTT row is read back COLD a week
- # later by an attribution that would believe a smaller number.
- # Different reader, different instant, different rule.
- out.append({"ok": False, "warn": True,
- "text": "PV production: NO SITE TOTAL - "
- + self.pv_why + ". Reporting: "
- + (", ".join(
- f"{ent} {watt:.0f} W" + inv(ent)
- for ent, watt, _y in self.pv_parts
- if watt is not None) or "nothing")})
+ # THOSE WORDS - and which sources are missing, by position.
+ out.append({"ok": False, "warn": True, "id": "pv",
+ "text": messages.row_pv_partial(
+ [i + 1 for i, (_e, watt, _y)
+ in enumerate(self.pv_parts) if watt is None],
+ n_src)})
else:
- # habit 14 at a roster: the total in its own slot, then EVERY
- # contributor in its own slot with its own watts and its own
- # unit. A sum that disagrees with the addends it is printed
- # beside is visible in the sentence.
- out.append({"ok": True, "warn": False,
- "text": f"PV production: {self.pv_w:.0f} W from "
- f"{len(self.pv_parts)} entities ("
- + ", ".join(
- f"{ent} {watt:.0f} W in {why}"
- + inv(ent)
- for ent, watt, why in self.pv_parts)
- + ")"})
+ out.append({"ok": True, "warn": False, "id": "pv",
+ "text": messages.row_pv(
+ self.pv_w, n_src,
+ n_src == 1 and roster[0] in flip)})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
- # right rule (its docstring says why) and is also the rule an
- # operator upgrading from 0.11.0 is most likely to trip over: they
- # add all their inverters to the new field and the old key is left
- # standing, doing nothing. A replacement nobody is told about is the
- # "one of which silently wins" `config.yaml` refuses elsewhere - and
- # refusing to START over a telemetry key would be observability with
- # a foot in the control path, so this is a CAUTION and never a `bad`
- # row.
+ # rule an operator upgrading from 0.11.0 is most likely to trip
+ # over. A CAUTION and never a `bad` row: telemetry must not stop
+ # control.
if pv_read.entity_is_ignored(self.o):
- out.append({"ok": False, "warn": True,
- "text": "PV production: `pv_entities` is set, so "
- "`pv_entity` is IGNORED - the "
- f"{len(roster)} entities in the "
- "list are what is summed. Clear "
- "`pv_entity` to remove this caution"})
+ out.append({"ok": False, "warn": True, "id": "pv",
+ "text": messages.row_pv_single_ignored()})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
- for ent, watt, _y in self.pv_parts:
+ for i, (ent, watt, _y) in enumerate(self.pv_parts):
if watt is not None and watt < pv_read.NEGATIVE_W:
- out.append({"ok": False, "warn": True,
- "text": f"PV production: {ent} reads "
- f"{watt:.0f} W - far below zero, so "
- f"its sign is probably reversed. If "
- f"it is producing now, "
- + ("REMOVE it from "
- "`pv_invert_entities`"
- if ent in flip else
- "ADD it to `pv_invert_entities`")
- + " in the Configuration tab"})
+ out.append({"ok": False, "warn": True, "id": "pv",
+ "text": messages.row_pv_negative(
+ i + 1, n_src, watt, ent in flip)})
+ _inv_list = [str(x or "").strip()
+ for x in (self.o.get("pv_invert_entities") or [])]
for ent in pv_read.stray_inverts(self.o):
- out.append({"ok": False, "warn": True,
- "text": f"PV production: `pv_invert_entities` "
- f"names {ent}, which is not a PV entity in "
- f"use, so it flips nothing - check the "
- f"spelling against `pv_entities`"})
+ out.append({"ok": False, "warn": True, "id": "pv",
+ "text": messages.row_pv_stray(
+ _inv_list.index(ent) + 1)})
return out
def status_payload(self) -> dict:
@@ -5145,9 +5039,7 @@ class Controller:
# is the fault the operator has NO OTHER WAY of learning about: a
# sign disagreement is visible at the meter and on every dashboard
# in the house, and circulating power is visible nowhere at all.
- level, banner = "bad", ("CIRCULATING POWER — the inverters were "
- "pushing against each other. Site held at "
- "0 W.")
+ level, banner = "bad", messages.banner_circulating()
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument
@@ -5156,28 +5048,23 @@ class Controller:
# inverter is parked and the fault is in what the site was asking
# for, not in one device - naming a unit here would send an operator
# to swap the wrong box.
- level, banner = "bad", ("FAILSAFE — the site's total command "
- "opposed the meter. Every inverter held at "
- "0 W.")
+ level, banner = "bad", messages.banner_site_direction()
elif any(i.sign_wd.tripped for i in self.inverters):
- # Outranks "NOT READY": nothing is missing, something went wrong.
- # ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter"
- # is what the unit was graded against. At N>=2 it is that unit's own
- # power, so the banner says so - see checks() on why the noun is
- # load-bearing rather than cosmetic.
- level, banner = "bad", (
- "FAILSAFE — the command opposed the meter. "
- "Inverter held at 0 W." if len(self.inverters) == 1 else
- "FAILSAFE — an inverter's command opposed its own measured "
- "power. That inverter held at 0 W.")
+ # Outranks "Not ready": nothing is missing, something went wrong.
+ # At a roster of one the unit was graded against the meter; at N>=2
+ # against its own power, so the banner says so - see checks() on
+ # why the noun is load-bearing rather than cosmetic.
+ level, banner = "bad", messages.banner_direction(
+ len(self.inverters) > 1)
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
# need to say more to an operator reading the checks list than fits
# in the one line at the top of the page - SAFETY-25's floor-limited
# row is the case that forced this - and a headline that is a full
# paragraph, repeated verbatim by the row underneath it, is a
- # headline nobody reads. Rows without the key are unaffected.
- level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
+ # headline nobody reads. UX-08: without the key, the banner takes
+ # the row's first sentence, so it stays a headline.
+ level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif any(i.delivery_wd.tripped for i in self.inverters):
@@ -5194,12 +5081,8 @@ class Controller:
# precisely the shape this whole card exists to remove. It sits
# BELOW "Stopped" because a stopped site is the more urgent fact and
# the banner carries one sentence.
- _nd = ", ".join(i.name for i in self.inverters
- if i.delivery_wd.tripped)
- level, banner = "warn", (
- f"NOT DELIVERING — {_nd} is standing at a share it is not "
- f"producing. The site is still running and short by it; "
- f"nothing has been commanded differently.")
+ level, banner = "warn", messages.banner_not_delivering(
+ [i.name for i in self.inverters if i.delivery_wd.tripped])
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for
diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py
index b1682d7..619192c 100644
--- a/goodwe_controller/app/messages.py
+++ b/goodwe_controller/app/messages.py
@@ -228,6 +228,416 @@ def meter_frozen(unchanged_s) -> Notice:
f"stop control on the add-on page.")
+# --- the status page (UX-08 phase 2b) ----------------------------------------
+# The rows of the Commissioning list and the banner at the top of the add-on
+# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
+# these decide WHAT it says. A row is one sentence of at most 15 words; a red
+# row may use up to 3 sentences and 30 words. A banner is at most 12 words.
+# Rows never name a Home Assistant entity: they name the setting in words.
+# DOCS.md, "Status page", explains every row.
+
+NOT_READY = "Not ready"
+DOCS_STATUS = "Status page"
+
+
+def signed_watts(value) -> str:
+ """`-1200` -> `-1 200 W`: like `watts()`, but the sign is kept."""
+ return f"{float(value):,.0f} W".replace(",", " ")
+
+
+def seconds(value) -> str:
+ """Always in seconds, for a live count set against its limit."""
+ n = f"{float(value):.0f}"
+ return f"{n} second" if n == "1" else f"{n} seconds"
+
+
+def times(n) -> str:
+ return "1 time" if int(n) == 1 else f"{int(n)} times"
+
+
+def for_unit(unit) -> str:
+ """` for inverter-b` on a site with several inverters, else nothing."""
+ return f" for {unit}" if unit else ""
+
+
+def headline(text: str) -> str:
+ """The first sentence of a row, for the banner when the row has no own."""
+ first = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text.strip(), maxsplit=1)[0]
+ return first.rstrip(".")
+
+
+# Banners (the headline at the top of the page).
+def banner_not_ready(row: dict) -> str:
+ return f"{NOT_READY} — {row.get('banner') or headline(row['text'])}"
+
+
+def banner_circulating() -> str:
+ return "Inverters worked against each other. Every inverter is set to 0 W."
+
+
+def banner_site_direction() -> str:
+ return "Total command and meter point opposite ways. All inverters at 0 W."
+
+
+def banner_direction(several: bool) -> str:
+ if several:
+ return "Inverter and own power point opposite ways. It is at 0 W."
+ return "Command and meter point opposite ways. Inverter set to 0 W."
+
+
+def banner_not_delivering(units) -> str:
+ units = list(units)
+ verb = "are" if len(units) > 1 else "is"
+ return f"{names(units)} {verb} not delivering. Control keeps running."
+
+
+# Rows: the controller and the meter.
+def row_controller_starting() -> str:
+ return "Controller: starting"
+
+
+def row_controller_ran(age_s) -> str:
+ return f"Controller: last ran {seconds(age_s)} ago"
+
+
+def row_controller_stalled(age_s, veto_s) -> str:
+ return (f"Controller has not run for {seconds(age_s)}. The control board "
+ f"sets the inverter to 0 W after {seconds(veto_s)}.")
+
+
+def row_meter_stale(age_s, limit_s) -> str:
+ return (f"Meter: no reading for {seconds(age_s)}, limit "
+ f"{seconds(limit_s)}. The add-on log shows why.")
+
+
+def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str:
+ return (f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old"
+ + (f", {rejected} readings refused" if rejected else "")
+ + ("" if unchanged_s is None
+ else f", unchanged for {duration(unchanged_s)}"))
+
+
+def row_meter_frozen() -> str:
+ return (f"{T_METER_FROZEN}. Control still follows it. Check the meter, "
+ f"and stop control if it is stuck.")
+
+
+FREEZE_OFF = {
+ "source": "it only works with the HomeWizard local meter source",
+ "rating": "the inverter rating is not set",
+ "range": "the inverter reports no usable power range",
+}
+
+
+def row_freeze_off(why: str) -> str:
+ """`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run."""
+ return f"Stuck-meter check: off, {FREEZE_OFF[why]}"
+
+
+def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str:
+ return (f"Stuck-meter check: alerts after {duration(hold_s)} unchanged "
+ f"above {watts(rail_w)}"
+ + (", the inverter's own limit" if own_limit else ""))
+
+
+# Rows: the peak guard.
+def row_peak_saturated(ceiling_w) -> str:
+ return (f"{T_PEAK_SATURATED}. Switch off large loads to stay under the "
+ f"{watts(ceiling_w)} peak limit.")
+
+
+def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
+ packs=()) -> str:
+ """SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
+ if soc_unread:
+ why = "Battery level cannot be read"
+ elif floor_unknown:
+ why = "A battery cut-out level is not set"
+ else:
+ why = (f"{names(packs)} battery at its peak reserve" if packs
+ else "Battery at its peak reserve")
+ return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
+ f"use still discharges below the reserve, down to the cut-out level.")
+
+
+def row_peak_guard_off() -> str:
+ """No meter source: the 15-minute average cannot be measured."""
+ return ("Peak guard: off, it needs a meter source instead of the grid "
+ "power sensor")
+
+
+def banner_peak_reserve() -> str:
+ return T_PEAK_RESERVE
+
+
+# Rows: the sensors and the inverter control.
+LABEL_SOC = "Battery level"
+LABEL_BATTERY = "Battery power"
+LABEL_GRID = "Grid power"
+
+
+def row_sensor(label, value, unit=None) -> str:
+ shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \
+ else signed_watts(value)
+ return f"{label}{for_unit(unit)}: {shown}"
+
+
+def row_sensor_unset(label, unit=None) -> str:
+ return f"{label}{for_unit(unit)}: no sensor set in the settings"
+
+
+def row_sensor_unusable(label, unit=None) -> str:
+ """SAFETY-56: it may be missing OR held back as stale; say both."""
+ return (f"{label}{for_unit(unit)}: no usable reading. The sensor may be "
+ f"missing, or its reading is held back as too old. Check it in "
+ f"Developer Tools before replacing it.")
+
+
+def row_control_unset(unit=None) -> str:
+ return f"Inverter control{for_unit(unit)}: not set in the settings"
+
+
+def row_control_missing(unit=None) -> str:
+ return f"Inverter control{for_unit(unit)}: not found in Home Assistant"
+
+
+def row_control(lo_w, hi_w, unit=None) -> str:
+ def w(v):
+ return "?" if v is None else signed_watts(v)
+ return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
+
+
+# Rows: the two loop alarms. The names are the notification titles, so the
+# row and the card always call the same thing by the same name (UX-07 item 1).
+def row_power_too_high(clamps, count) -> str:
+ c = clamps[0] if clamps else None
+ what = (f"asked {watts(c.asked_w)}{for_unit(c.unit)}, sent "
+ f"{watts(c.sent_w)}. Cut back" if c else "cut back")
+ return (f"{T_OUTPUT_CLAMP}: {what} {times(count)} since start. This is a "
+ f"software fault, so please report it.")
+
+
+def banner_power_too_high() -> str:
+ return T_OUTPUT_CLAMP
+
+
+def row_not_keeping_up(count) -> str:
+ return f"{T_LOOP_BOUND}: {times(count)} since start"
+
+
+def row_before(title, count) -> str:
+ """After an episode clears: how often it happened, so the evidence stays."""
+ return f"{title}: {times(count)} since start, not now"
+
+
+def row_write_refused(unit=None) -> str:
+ return (f"{unit or 'The inverter'} refused the last command. Check the "
+ f"add-on log.")
+
+
+# Rows: the direction checks (SAFETY-02/30).
+def row_direction_trip(unit=None) -> str:
+ """Per inverter. `unit` only at several inverters, graded on its own power."""
+ what = (f"{unit}: command and its own power point opposite ways, so it is"
+ if unit else "Command and meter point opposite ways, so the "
+ "inverter is")
+ return (f"{what} set to 0 W. Find out why, then start control. A restart "
+ f"does not clear this.")
+
+
+def row_direction_check(held_s, hold_s, unit=None) -> str:
+ who = (f"Direction check for {unit}, against its own power" if unit
+ else "Direction check")
+ if held_s:
+ return f"{who}: opposite for {held_s:.0f} of {seconds(hold_s)}"
+ return f"{who}: on"
+
+
+def row_site_direction_trip() -> str:
+ return ("Total command and meter point opposite ways, so every inverter is "
+ "set to 0 W. The cause is the total, not one inverter. Only starting "
+ "control clears this.")
+
+
+def row_site_direction_check(held_s, hold_s) -> str:
+ if held_s:
+ return (f"Site direction check: opposite for {held_s:.0f} of "
+ f"{seconds(hold_s)}")
+ return "Site direction check: on, against the meter"
+
+
+# Rows: circulating power (SAFETY-31) and delivery (SAFETY-42).
+def row_circulating_trip() -> str:
+ return ("Inverters worked against each other, so every inverter is set to "
+ "0 W. The meter cannot see this. Only starting control clears this.")
+
+
+def row_circulating_check(now_w, held_s, hold_s) -> str:
+ return ("Circulating power check: on"
+ + (f", now {watts(now_w)}" if now_w else "")
+ + (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
+
+
+def row_one_inverter(check: str) -> str:
+ return f"{check}: not needed with one inverter"
+
+
+def row_not_delivering(unit) -> str:
+ return (f"{unit} is not delivering, and only stopping and starting control "
+ f"clears this")
+
+
+def row_delivery_check(unit, short_s, hold_s) -> str:
+ if short_s:
+ return (f"Delivery check for {unit}: short for {short_s:.0f} of "
+ f"{seconds(hold_s)}")
+ return f"Delivery check for {unit}: on"
+
+
+def row_no_estop() -> str:
+ return ("No emergency stop fitted: if this computer fails, the battery "
+ "keeps its last command")
+
+
+# Rows: solar production (TEL-11/12/14). Sources are numbered in the order of
+# the solar sensor list in the settings.
+def source(n, of) -> str:
+ return "the sensor" if of == 1 else f"source {n}"
+
+
+def pv_problem(why: str) -> str:
+ """`pv.watts()`'s reason, in plain words."""
+ w = str(why)
+ if w.startswith("no state"):
+ return "the sensor is missing"
+ if w.startswith("entity is"):
+ return "the sensor is unavailable"
+ if w.endswith("is not numeric"):
+ return "the reading is not a number"
+ if w.startswith("no unit"):
+ return "the sensor has no unit"
+ if w.startswith("unit "):
+ return "the sensor's unit is not a power unit"
+ return "the reading is not usable"
+
+
+def row_pv(total_w, count, reversed_=False) -> str:
+ return (f"Solar production: {signed_watts(total_w)}"
+ + (f" from {count} sources" if count > 1 else "")
+ + (", sign reversed" if reversed_ else ""))
+
+
+def row_pv_none(why) -> str:
+ return f"Solar production: no reading, {pv_problem(why)}"
+
+
+def row_pv_partial(missing, count) -> str:
+ """`missing` are the 1-based positions of the sources that did not report."""
+ which = names([str(n) for n in missing])
+ return (f"Solar production: no total, source{'s' if len(missing) > 1 else ''} "
+ f"{which} of {count} {'have' if len(missing) > 1 else 'has'} no "
+ f"reading")
+
+
+def row_pv_single_ignored() -> str:
+ return ("Solar production: clear the single sensor setting, the list "
+ "replaces it")
+
+
+def row_pv_negative(n, count, value_w, flipped: bool) -> str:
+ fix = ("remove it from" if flipped else "add it to")
+ who = source(n, count)
+ return (f"Solar production: {who} reads {signed_watts(value_w)}, "
+ f"so {fix} the reversed-sign list")
+
+
+def row_pv_stray(n) -> str:
+ return (f"Solar production: entry {n} of the reversed-sign list is not a "
+ f"solar sensor in use")
+
+
+RED, AMBER, GREEN = "bad", "warn", "ok"
+
+
+def status_samples() -> list:
+ """`(function name, colour, text)` for every row and banner, for the test.
+
+ The colour is the one main.py gives the row; a red row may use three
+ sentences. Banners are listed with the colour None.
+ """
+ clamp = [Clamp(None, 7412, 5000, "rating")]
+ return [
+ ("banner_not_ready", None, banner_not_ready(
+ {"text": row_meter_frozen()})),
+ ("banner_not_ready", None, banner_not_ready(
+ {"text": "x", "banner": banner_peak_reserve()})),
+ ("banner_circulating", None, banner_circulating()),
+ ("banner_site_direction", None, banner_site_direction()),
+ ("banner_direction", None, banner_direction(False)),
+ ("banner_direction", None, banner_direction(True)),
+ ("banner_not_delivering", None, banner_not_delivering(["inverter-b"])),
+ ("banner_peak_reserve", None, banner_peak_reserve()),
+ ("banner_power_too_high", None, banner_power_too_high()),
+ ("row_controller_starting", GREEN, row_controller_starting()),
+ ("row_controller_ran", GREEN, row_controller_ran(2)),
+ ("row_controller_stalled", RED, row_controller_stalled(45, 30)),
+ ("row_meter_stale", RED, row_meter_stale(40, 10)),
+ ("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
+ ("row_meter_frozen", RED, row_meter_frozen()),
+ *(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
+ ("row_freeze_on", GREEN, row_freeze_on(600, 4465, True)),
+ ("row_peak_saturated", RED, row_peak_saturated(5000)),
+ ("row_peak_guard_off", AMBER, row_peak_guard_off()),
+ ("row_peak_reserve", RED, row_peak_reserve(
+ 5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
+ ("row_peak_reserve", RED, row_peak_reserve(
+ 5000, soc_unread=True, floor_unknown=False)),
+ ("row_peak_reserve", RED, row_peak_reserve(
+ 5000, soc_unread=False, floor_unknown=True)),
+ ("row_sensor", GREEN, row_sensor(LABEL_SOC, 54, "inverter-b")),
+ ("row_sensor", GREEN, row_sensor(LABEL_BATTERY, -1200.0, "inverter-b")),
+ ("row_sensor_unset", RED, row_sensor_unset(LABEL_GRID)),
+ ("row_sensor_unusable", RED, row_sensor_unusable(LABEL_BATTERY,
+ "inverter-b")),
+ ("row_control_unset", RED, row_control_unset("inverter-b")),
+ ("row_control_missing", RED, row_control_missing("inverter-b")),
+ ("row_control", GREEN, row_control(-5000, 5000, "inverter-b")),
+ ("row_power_too_high", RED, row_power_too_high(clamp, 3)),
+ ("row_power_too_high", RED, row_power_too_high(
+ [Clamp("inverter-b", 6000, 4600, "max_w+device")], 12)),
+ ("row_power_too_high", RED, row_power_too_high((), 1)),
+ ("row_not_keeping_up", AMBER, row_not_keeping_up(48)),
+ ("row_before", AMBER, row_before(T_OUTPUT_CLAMP, 2)),
+ ("row_before", AMBER, row_before(T_LOOP_BOUND, 1)),
+ ("row_write_refused", RED, row_write_refused("inverter-b")),
+ ("row_direction_trip", RED, row_direction_trip()),
+ ("row_direction_trip", RED, row_direction_trip("inverter-b")),
+ ("row_direction_check", GREEN, row_direction_check(0, 60)),
+ ("row_direction_check", GREEN, row_direction_check(12, 60,
+ "inverter-b")),
+ ("row_site_direction_trip", RED, row_site_direction_trip()),
+ ("row_site_direction_check", GREEN, row_site_direction_check(0, 60)),
+ ("row_site_direction_check", GREEN, row_site_direction_check(5, 60)),
+ ("row_circulating_trip", RED, row_circulating_trip()),
+ ("row_circulating_check", GREEN, row_circulating_check(1200, 20, 60)),
+ ("row_one_inverter", GREEN, row_one_inverter("Delivery check")),
+ ("row_not_delivering", AMBER, row_not_delivering("inverter-b")),
+ ("row_delivery_check", GREEN, row_delivery_check("inverter-b", 30, 120)),
+ ("row_delivery_check", GREEN, row_delivery_check("inverter-b", 0, 120)),
+ ("row_no_estop", AMBER, row_no_estop()),
+ ("row_pv", GREEN, row_pv(3200, 2)),
+ ("row_pv", GREEN, row_pv(1800, 1, True)),
+ ("row_pv_none", AMBER, row_pv_none("no unit_of_measurement on the "
+ "entity - it cannot be read in watts")),
+ ("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
+ ("row_pv_partial", AMBER, row_pv_partial([2], 2)),
+ ("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
+ ("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
+ ("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
+ ("row_pv_stray", AMBER, row_pv_stray(2)),
+ ]
+
+
def samples() -> list:
"""One rendering of every notice, with realistic values, for the test."""
return [
diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py
index f4e4cda..92e4bdb 100644
--- a/goodwe_controller/test_clamp.py
+++ b/goodwe_controller/test_clamp.py
@@ -541,11 +541,24 @@ check("...and the count in the row is the counter's, so nothing is lost",
# is captured mid-run rather than asserted after the hysteresis has cleared it.
check("the status page flags it WHILE it is engaged - amber, since it is the "
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
- any(not c["ok"] and c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"]
+ any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
+ and c["text"].startswith(messages.T_LOOP_BOUND)
for c in ctl_r.red_snapshot))
-check("...and once clear, a warn row still says how many times it engaged",
- any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
- for c in ctl_r.checks()))
+check("...and once clear, a warn row still says how many times it engaged, "
+ "under the integrator bound's own title",
+ any(c["warn"] and c.get("id") == "not_keeping_up"
+ and c["text"].startswith(messages.T_LOOP_BOUND)
+ and messages.times(ctl_r.clamp_wd.bound) in c["text"]
+ and "not now" in c["text"] for c in ctl_r.checks()))
+# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
+# time(s)" for integrator-bound cycles. Each count now sits under its own name.
+check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
+ "row calls them 'Power request too high'",
+ ctl_r.clamp_wd.bound == ctl_r.clamp_wd.engagements > 0
+ and ctl_r.clamp_wd.over_rating == 0
+ and not any(c.get("id") == "power_too_high"
+ or messages.T_OUTPUT_CLAMP in c["text"]
+ for c in ctl_r.checks()))
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
# keeps writing the clamped value. If a later cut changes that it must come here.
@@ -592,7 +605,8 @@ check("AC 2: the clamp counted ZERO engagements", ctl_s.clamp_wd.engagements ==
check("AC 2: no OUTPUT_CLAMP row in the action log",
not [e for e in ctl_s.events if REASON_CODE in e])
check("AC 2: and no clamp row on the status page",
- not [c for c in ctl_s.checks() if "output clamp" in c["text"].lower()])
+ not [c for c in ctl_s.checks()
+ if c.get("id") in ("power_too_high", "not_keeping_up")])
print(" ...and the two alarms do not share a notification_id")
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)}
@@ -1991,8 +2005,8 @@ check("...logged as a WARNING, not an ERROR",
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i))
check("...and the status row is amber, not red",
- any(c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"] for c in red_i)
- and not any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
+ any(c["warn"] and c.get("id") == "not_keeping_up" for c in red_i)
+ and not any(not c["warn"] and c.get("id") == "power_too_high"
for c in red_i))
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
@@ -2023,8 +2037,17 @@ check("...under the over-rating title, quoting a tick's own asked and sent "
and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
_f(d, 'clamped_w')) for d, t, r in over)
for n in clamp_notes(hass_o))
- and any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
+ and any(not c["warn"] and c.get("id") == "power_too_high"
+ and c["text"].startswith(messages.T_OUTPUT_CLAMP)
for c in red_o))
+# The red row quotes the same tick's numbers as the card, asked before sent.
+_row_o = [c["text"] for c in red_o if c.get("id") == "power_too_high"]
+check("...and the red row quotes asked before sent, from one of those ticks",
+ _row_o and any(
+ f"asked {messages.watts(_f(d, 'requested_w'))}" in _row_o[0]
+ and f"sent {messages.watts(_f(d, 'clamped_w'))}" in _row_o[0]
+ and _row_o[0].index("asked") < _row_o[0].index("sent")
+ for d, t, r in over))
# --- 9c. the text, directly, and the two budgets -------------------------------
from app.clamp import loop_clamp_note # noqa: E402
diff --git a/goodwe_controller/test_control_task.py b/goodwe_controller/test_control_task.py
index 2947e1c..ed1f8b0 100644
--- a/goodwe_controller/test_control_task.py
+++ b/goodwe_controller/test_control_task.py
@@ -337,15 +337,19 @@ def rows_for(pass_age):
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
+# UX-08: the wording is app/messages.py's; these pin the meaning - which row,
+# its colour, and the two numbers (age, veto) an operator acts on.
check("before the first pass the row is ok and says 'starting'",
- r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
+ r_new[0]["id"] == "controller" and r_new[0]["ok"] and not r_new[0]["warn"]
+ and "starting" in r_new[0]["text"])
check("29 s after a pass it is ok",
- r_29[0] == {"ok": True, "warn": False,
- "text": "control loop: last pass 29 s ago"})
+ r_29[0]["id"] == "controller" and r_29[0]["ok"] and not r_29[0]["warn"]
+ and "29 seconds ago" in r_29[0]["text"])
check("31 s after a pass it is RED and first, naming the age and the veto",
- r_31[0] == {"ok": False, "warn": False,
- "text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
- "inverter after 30 s without a heartbeat)"})
+ r_31[0]["id"] == "controller" and not r_31[0]["ok"]
+ and not r_31[0]["warn"]
+ and "not run for 31 seconds" in r_31[0]["text"]
+ and "0 W after 30 seconds" in r_31[0]["text"])
async def one_pass():
diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py
index b387b0a..da20040 100644
--- a/goodwe_controller/test_delivery.py
+++ b/goodwe_controller/test_delivery.py
@@ -930,14 +930,18 @@ check("...but the banner does NOT go on saying 'Running - holding grid at "
"zero'. That sentence is a claim the site cannot make while a unit is "
"absent, and 'the page reads healthy while the fault runs' is the shape "
"this whole card exists to remove. It names the unit and stays a WARNING",
- _pay["level"] == "warn" and "NOT DELIVERING" in _pay["banner"]
+ _pay["level"] == "warn" and "is not delivering" in _pay["banner"]
and "inverter-b" in _pay["banner"]
and "holding grid at zero" not in _pay["banner"])
print(f" banner: {_pay['level']!r} / {_pay['banner']!r}")
-_row = [r for r in _cw.checks() if "NOT DELIVERING" in r["text"]]
+_row = [r for r in _cw.checks()
+ if r.get("id") == "delivery" and "not delivering" in r["text"]]
check("the status page carries it as a CAUTION, not a failure: the site is "
"still running and short, which a red cross would misdescribe",
- len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True)
+ len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True
+ and "inverter-b" in _row[0]["text"])
+check("...and the row says how it clears: a stop and start of control",
+ bool(_row) and "stopping and starting control" in _row[0]["text"])
# ===========================================================================
@@ -1173,10 +1177,11 @@ check("...and no store key was written, so a one-inverter install's state file "
"is byte-identical to what it was before this card",
unit_key(_only.name, "delivery_tripped") not in _st1.data
and unit_key(_only.name, "delivery_reason") not in _st1.data)
-_n1row = [r for r in _c1.checks() if "delivery watchdog" in r["text"]]
+_n1row = [r for r in _c1.checks() if r.get("id") == "delivery"]
check("...and the status page says n/a rather than 'armed', which would be a "
"claim to be watching something that cannot happen",
- len(_n1row) == 1 and _n1row[0]["ok"] and "n/a at one inverter" in _n1row[0]["text"])
+ len(_n1row) == 1 and _n1row[0]["ok"]
+ and "not needed with one inverter" in _n1row[0]["text"])
check("...no persistent notification of this kind was raised either",
not [c for c in _h1.calls
if "not_delivering" in str(c[2].get("notification_id", ""))])
diff --git a/goodwe_controller/test_freeze.py b/goodwe_controller/test_freeze.py
index a10166e..e182920 100644
--- a/goodwe_controller/test_freeze.py
+++ b/goodwe_controller/test_freeze.py
@@ -832,7 +832,7 @@ all_notes = all_notes_of(hass_f) # the whole surface
# install has. Flipped here rather than plumbing a whole P1 transport in - the
# row's own content is what is under test.
ctl_f.p1_enabled = True
-frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
+frozen_rows = [c for c in ctl_f.checks() if c.get("id") == "meter_frozen"]
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
@@ -862,6 +862,9 @@ check("...and it says the controller is still running",
bool(notes) and "Control is still running" in notes[0]["message"])
check("the status page carries a failed row saying so",
len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
+check("...and the row says control still follows the reading and what to do",
+ bool(frozen_rows) and "still follows it" in frozen_rows[0]["text"]
+ and "Check the meter" in frozen_rows[0]["text"])
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
# and change this expectation deliberately, not discover it in the field.
@@ -1106,8 +1109,7 @@ def freeze_rows(unchanged_s, grid, **over):
# p1_enabled is what a homewizard_local install has; the row's own content
# is what is under test, not the transport that feeds it.
ctl.p1_enabled = True
- return ctl, [r for r in ctl.checks()
- if r["text"].startswith("freeze detector")]
+ return ctl, [r for r in ctl.checks() if r.get("id") == "freeze_check"]
_ctl_u, _rows_u = freeze_rows(None, 4000.0, meter_source="ha_dsmr")
@@ -1116,30 +1118,31 @@ print(f" uncovered: {_rows_u[0]['text'] if _rows_u else '(no row at all)'}")
print(f" covered: {_rows_c[0]['text'] if _rows_c else '(no row at all)'}")
check("an uncovered transport gets exactly one freeze-detector row",
len(_rows_u) == 1)
-check("...and it says NOT COVERED",
- bool(_rows_u) and "NOT COVERED" in _rows_u[0]["text"])
+# UX-08: the row says the check is off and which source it needs, in words
+# (STYLE.md: no option values). DOCS.md "Status page" maps them to the keys.
+check("...and it says the check is off",
+ bool(_rows_u) and ": off" in _rows_u[0]["text"])
check("...and it does NOT claim the guard is armed - that would be the status "
"page lying on behalf of a guard that cannot run",
- bool(_rows_u) and "armed" not in _rows_u[0]["text"])
-check("...and it names the meter_source it is not covering, so the operator "
- "knows which install this is",
- bool(_rows_u) and "ha_dsmr" in _rows_u[0]["text"])
-check("...and it names homewizard_local as the transport that would be covered",
- bool(_rows_u) and "homewizard_local" in _rows_u[0]["text"])
+ bool(_rows_u) and "alerts after" not in _rows_u[0]["text"])
+check("...and it names HomeWizard local as the source that would be covered",
+ bool(_rows_u) and "HomeWizard local" in _rows_u[0]["text"])
check("...flagged as a warning rather than a silent ok",
bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"])
check("a COVERED transport gets exactly one freeze-detector row",
len(_rows_c) == 1)
-check("...and that one DOES say armed - without this, 'the uncovered row does "
- "not say armed' is satisfied by deleting the armed branch",
- bool(_rows_c) and "armed" in _rows_c[0]["text"]
- and "NOT COVERED" not in _rows_c[0]["text"])
+check("...and that one DOES say it is on - without this, 'the uncovered row "
+ "does not say armed' is satisfied by deleting the armed branch",
+ bool(_rows_c) and "alerts after" in _rows_c[0]["text"]
+ and ": off" not in _rows_c[0]["text"])
check("...and it quotes the hold and the rail it is armed at, derived from the "
"config the controller was actually built with",
bool(_rows_c)
- and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
- and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
+ and f"{messages.duration(_ctl_c.freeze_wd.cfg.hold_s)} unchanged"
+ in _rows_c[0]["text"]
+ and f"above {messages.watts(rail_w(LIVE_INSTALL_MAX_W))}"
+ in _rows_c[0]["text"])
check("...as an ok row, since a covered armed guard is not a warning",
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
@@ -1196,7 +1199,8 @@ check("...and the notification the operator gets names no max_w rail, and the "
_c20.p1_enabled = True
check("...and the red status row is up too, so the page and the notification "
"agree",
- any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
+ any(r.get("id") == "meter_frozen" and not r["ok"] and not r["warn"]
+ for r in _c20.checks()))
# --------------------------------------------------------------------------- #
@@ -1222,23 +1226,25 @@ check("a device smaller than max_w still gets exactly one freeze-detector row",
check("...and the page does NOT print the max_w rail the command can never "
"reach - THE DEFECT, in one assertion",
bool(_rows_s)
- and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
+ and messages.watts(rail_w(LIVE_INSTALL_MAX_W)) not in _rows_s[0]["text"])
check("...it prints the rail the predicate actually uses",
bool(_rows_s)
- and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
- in _rows_s[0]["text"])
-check("...and it names the device range as the reason, since the operator "
- "configured max_w and is owed the reason the guard is armed elsewhere",
- bool(_rows_s) and "4700" in _rows_s[0]["text"]
- and "max_w" in _rows_s[0]["text"])
+ and messages.watts(rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0,
+ dev_max=4700.0)) in _rows_s[0]["text"])
+check("...and it names the inverter's own limit as the reason, since the "
+ "operator configured max_w and is owed the reason the guard is armed "
+ "elsewhere",
+ bool(_rows_s) and "the inverter's own limit" in _rows_s[0]["text"])
+check("...and it does not say so when max_w itself sets the rail",
+ bool(_rows_c) and "own limit" not in _rows_c[0]["text"])
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
# this: the rail the page advertises has to be a magnitude the wire can carry,
# and the wire stops at the device rating.
-_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
-_page_rail = float(_m.group(1)) if _m else None
+_m = re.search(r"above (\d[\d ]*) W", _rows_s[0]["text"]) if _rows_s else None
+_page_rail = float(_m.group(1).replace(" ", "")) if _m else None
print(f" the page advertises a rail of {_page_rail} W against a device that "
f"stops at {_ctl_s.dev_max:.0f} W")
check("...the row states a rail in watts at all, so the parse below means "
@@ -1256,11 +1262,13 @@ check("...as an ok row - a guard armed at a lower rail is still armed, not a "
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
-check("...and it says NOT COVERED - the rail is not merely low there, it is "
- "0 W, which would be TRUE of every command including a parked one",
- bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
-check("...and it NEVER says armed",
- bool(_rows_z) and "armed" not in _rows_z[0]["text"])
+check("...and it says the check is off for the power range - the rail is not "
+ "merely low there, it is 0 W, which would be TRUE of every command "
+ "including a parked one",
+ bool(_rows_z) and ": off" in _rows_z[0]["text"]
+ and "power range" in _rows_z[0]["text"])
+check("...and it NEVER says armed",
+ bool(_rows_z) and "alerts after" not in _rows_z[0]["text"])
check("...flagged as a warning rather than a silent ok, like the uncovered "
"transport row it shares",
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py
index 89f9005..dcc36d2 100644
--- a/goodwe_controller/test_freshness.py
+++ b/goodwe_controller/test_freshness.py
@@ -69,6 +69,7 @@ from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
+from app import messages
from named_absence import first, given, nth, present
from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
@@ -1823,7 +1824,8 @@ def _s56_row(c):
a red with no name attached, which reads like broken infrastructure and gets
retried rather than read. Measured: 0 named reds before, 2 after.
"""
- hits = [r for r in c.checks() if r["text"].startswith("battery power")]
+ hits = [r for r in c.checks() if r.get("id") == "sensor"
+ and r["text"].startswith(messages.LABEL_BATTERY)]
if len(hits) == 1:
return hits[0]
return {"ok": None, "warn": None,
@@ -1933,14 +1935,17 @@ check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
_row56_ref["ok"] is False and _row56_ref["warn"] is False
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
and _row56_ref["text"] == _row56_abs["text"])
-check("...and what it says instead is the observation plus BOTH causes, with "
- f"{_BATT_ENT} in its own slot - `label` and `entity` are two "
- f"interpolations in one sentence and swapping them costs zero bytes, so "
- f"the literal around each is pinned (habit 14)",
- f"battery power: no usable value this cycle from {_BATT_ENT} - and this "
- f"row cannot tell which of two things that is" in _row56_ref["text"]
- and "the entity is missing or not numeric" in _row56_ref["text"]
- and "a number that this controller is withholding" in _row56_ref["text"])
+# UX-08: the row names the setting by its label, never the entity id
+# (STYLE.md), so there is no second interpolation left to transpose.
+check("...and what it says instead is the observation plus BOTH causes, under "
+ "its own label, and the check to make before replacing the sensor - with "
+ f"no entity id ({_BATT_ENT}) on the page row",
+ _row56_ref["text"].startswith(f"{messages.LABEL_BATTERY}: no usable "
+ f"reading")
+ and "may be missing" in _row56_ref["text"]
+ and "held back as too old" in _row56_ref["text"]
+ and "Developer Tools before replacing it" in _row56_ref["text"]
+ and _BATT_ENT not in _row56_ref["text"])
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
@@ -1957,7 +1962,7 @@ check("...and the row's wording is NOT shared with the log line's, which is a "
"other caller does not need",
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
- and "no usable value this cycle" not in _r56_ref)
+ and "no usable reading" not in _r56_ref)
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py
index 5e2e622..110bf2b 100644
--- a/goodwe_controller/test_inverter.py
+++ b/goodwe_controller/test_inverter.py
@@ -811,9 +811,11 @@ check("...and the status page shows BOTH per-inverter rows - one latched, one "
"'the sign watchdog is armed' printed over a latched sibling is the page "
"lying",
len([r for r in _c3r.checks()
- if r["text"].startswith("sign watchdog [")]) == 1
+ if r.get("id") == "direction" and r["ok"]
+ and "inverter-a" in r["text"]]) == 1
and len([r for r in _c3r.checks()
- if "SIGN-DISAGREEMENT FAILSAFE [" in r["text"]]) == 1)
+ if r.get("id") == "direction" and not r["ok"]
+ and not r["warn"] and "inverter-b" in r["text"]]) == 1)
check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At two "
"inverters there are three sign questions on this page and they are three "
"rows: is A doing what it was told, is B, and is the SITE'S TOTAL "
@@ -823,15 +825,15 @@ check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At tw
"per-unit rows would be advertising two guards where the install has "
"three",
len([r for r in _c3r.checks()
- if r["text"].startswith("site sign watchdog:")]) == 1)
+ if r.get("id") == "site_direction"]) == 1)
check("...and the per-inverter rows now say WHAT the unit was compared "
"against, because at a roster of two it is no longer the meter. A row "
"still reading 'the command opposed the meter' would send an operator to "
"look at P1 for a fault that is visible only on one inverter's own power "
"sensor",
- all("its own measured power" in r["text"] for r in _c3r.checks()
- if r["text"].startswith(("sign watchdog [",
- "SIGN-DISAGREEMENT FAILSAFE ["))))
+ len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
+ and all("its own power" in r["text"] for r in _c3r.checks()
+ if r.get("id") == "direction"))
# The operator's clear, which is one button and has to reach every latch.
run(_c3r.handle_action("auto_toggle")) # -> stopped
diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py
index 46582bc..6d278de 100644
--- a/goodwe_controller/test_messages.py
+++ b/goodwe_controller/test_messages.py
@@ -7,8 +7,11 @@
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 RATCHET for the kinds not rewritten yet. Every status-row and banner
- literal, every validator refusal, every WARNING+ log line and every option
+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
@@ -302,7 +305,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 = 200
+ALLOWLIST_MAX = 168
def read_allowlist(path):
@@ -326,10 +329,57 @@ if "--violations" in sys.argv:
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 - ". 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]
+check(f"every other red row makes a 'Not ready' banner of at most 12 words "
+ f"(too long: {_long})", not _long)
+
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)
@@ -361,6 +411,8 @@ 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)
# ============================================================================== #
@@ -394,6 +446,18 @@ check("a message that lost its Details line is refused",
_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)
diff --git a/goodwe_controller/test_p1.py b/goodwe_controller/test_p1.py
index ed83cda..99fe896 100644
--- a/goodwe_controller/test_p1.py
+++ b/goodwe_controller/test_p1.py
@@ -1432,7 +1432,7 @@ def p1_rows(ctl):
a traceback instead of reddening the check that names the rule.
"""
try:
- return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
+ return [c["text"] for c in ctl.checks() if c.get("id") == "meter"]
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" checks() raised {type(err).__name__}: {err}")
return err
diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py
index 1884bbd..cb25739 100644
--- a/goodwe_controller/test_peakguard.py
+++ b/goodwe_controller/test_peakguard.py
@@ -1038,7 +1038,7 @@ check(f"a Controller built with peak_cap_w ABSENT takes the shipped "
c.peak.cap_w == CAP_W)
check("...and before the first cycle the guard has NO decision, which the "
"status page must not spell as 'idle'", c.peak_guard is None)
-pre = [r for r in c.checks() if "peak guard" in r["text"]]
+pre = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row before the first cycle: "
+ (pre[0]["text"] if pre else "(none)"))
check("the status page says so in its own row",
@@ -1054,17 +1054,20 @@ c_off, hass_off = built(TMP / "legacy.db",
{"meter_source": "off", "meter_entity": "sensor.grid"})
c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle())
-off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]]
+off_rows = [r for r in c_off.checks() if r.get("id") == "peak_guard"]
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
f"{nth(hass_off.writes, -1):+.0f} W")
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)"))
check("with no P1 the guard does not run at all - it files nothing, and the "
"legacy path can still charge", c_off.peak_guard is None
and hass_off.writes and hass_off.writes[-1] < 0)
-check("...and the status page says NOT COVERED rather than 'idle' or a green "
- "row, which is the same answer SAFETY-18 already gives on a transport "
- "that cannot see it", len(off_rows) == 1
- and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"])
+check("...and the status page says the guard is off, and why, rather than "
+ "'idle' or a green row - the same answer SAFETY-18 already gives on a "
+ "transport that cannot see it", len(off_rows) == 1
+ and ": off" in off_rows[0]["text"]
+ and "meter source" in off_rows[0]["text"]
+ and "idle" not in off_rows[0]["text"]
+ and not off_rows[0]["ok"] and off_rows[0]["warn"])
# ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY.
# `cycle()` reads the real clock, and `project()` measures the open block against
@@ -1214,7 +1217,7 @@ check("the real cycle files the peak claim and the real arbiter binds the real "
and not c.peak_guard.saturated and "peak" in (c.reason or ""))
check("...and the number written to the inverter is the guard's floor",
hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5))
-rows = [r for r in c.checks() if "peak guard" in r["text"].lower()]
+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"])
@@ -1922,12 +1925,12 @@ asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
check("...once per episode, not once per cycle - a quarter of an hour of "
"identical cards is how an operator learns to dismiss the one that "
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
-rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()]
+rows24 = [r for r in c24.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows24[0]["text"] if rows24 else "(none)"))
check("the status page carries exactly one peak-guard row and it is NOT ok - "
"the money is going to be spent and a human is the only actuator left",
- len(rows24) == 1 and not rows24[0]["ok"]
- and "FLOOR-LIMITED" in rows24[0]["text"])
+ len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
+ and "peak limit will be passed" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
@@ -2479,17 +2482,23 @@ check("...and DOCS.md sends the reader to it rather than leaving the open "
# comment is a scope sentence the operator never sees.
c25, hass25, NET25, _ = wired_over_limit(
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
-rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
+rows25 = [r for r in c25.checks() if r.get("id") == "peak_guard"]
+
+
+def _row_scope(text):
+ """SAFETY-25 in messages.row_peak_reserve()'s words."""
+ return ("Home use still discharges below the reserve" in text
+ and "down to the cut-out level" in text)
+
+
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
"guard only and that self-consumption may keep discharging below it. "
"Without that sentence the page implies a pack-wide reserve at the exact "
"moment the replay above says the loop is still emptying the pack",
len(rows25) == 1
- and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
- and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
- and "not a pack-wide reserve" in rows25[0]["text"]
- and "BMS cut-out" in rows25[0]["text"])
+ and "at its peak reserve" in rows25[0]["text"]
+ and _row_scope(rows25[0]["text"]))
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload()
# renders the first not-ok/not-warn row as the page headline, so before this the
# loudest element on the page was the same 550-character paragraph the row three
@@ -2501,26 +2510,19 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
# The guard's own line, which the banner has always carried and which is not
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
# that is going over the ceiling.
-_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0]
-check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
- "under 90 characters of it - and NOT the row's whole paragraph repeated "
- "at the top of the page. A headline that is a paragraph is a headline "
- "nobody reads, and this one is followed three lines later by the same "
- "text verbatim",
- present(_expl25)
- and _pay25["level"] == "bad"
- and _pay25["banner"].startswith("NOT READY")
- and _expl25 in _pay25["banner"]
- and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
- and "not a pack-wide reserve" not in _pay25["banner"]
- and len(_pay25["banner"]) - len(_expl25) < 90)
-check("...and shortening the banner did NOT shorten the row: the full AC 3 "
+# UX-08: the banner is the notification's own title, and the row below keeps
+# the SAFETY-25 sentence.
+check("the Not ready banner is a short headline - the notification's title - "
+ "and NOT the row repeated at the top of the page. A headline that is a "
+ "paragraph is a headline nobody reads",
+ _pay25["level"] == "bad"
+ and _pay25["banner"] == (f"{messages.NOT_READY} — "
+ f"{messages.T_PEAK_RESERVE}")
+ and not _row_scope(_pay25["banner"]))
+check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"sentence is still what the checks list carries, which is the surface the "
- "operator reads to find out what the floor does not bound",
- len(rows25) == 1
- and len(rows25[0]["text"]) > 300
- and "not a pack-wide reserve" in rows25[0]["text"]
- and "BMS cut-out" in rows25[0]["text"])
+ "operator reads to find out what the reserve does not bound",
+ len(rows25) == 1 and _row_scope(rows25[0]["text"]))
_body25 = hass25.last_message("goodwe_peak_soc_floor")
@@ -5964,7 +5966,7 @@ check("...and the row still names WHERE the ceiling came from, which is the "
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
-_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
+_rows38 = [r for r in _c38.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "
diff --git a/goodwe_controller/test_pv.py b/goodwe_controller/test_pv.py
index 089ee06..d6d5233 100644
--- a/goodwe_controller/test_pv.py
+++ b/goodwe_controller/test_pv.py
@@ -69,7 +69,7 @@ import asyncio
import sys
from pathlib import Path
-from app import mqtt, pv
+from app import messages, mqtt, pv
from named_absence import field, nth, present
from app.main import Controller
@@ -499,6 +499,11 @@ def rows_of(c):
return [r["text"] for r in c.checks()]
+def pv_rows(c):
+ """The solar rows of the status page (UX-08: selected by id, not wording)."""
+ return [r for r in c.checks() if r.get("id") == "pv"]
+
+
def page_rows(c):
return c.status_payload()["rows"]
@@ -510,38 +515,39 @@ check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution
"is unset, because an unset forecast leaves a money guard without an "
"input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids",
- not [t for t in _off_rows if "PV" in t]
+ not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "PV"])
check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1)
-_pv_row = [t for t in _on_rows if t.startswith("PV production")]
+_pv_row = [r["text"] for r in pv_rows(c_on)]
print(f" {_pv_row}")
-# habit 14: the ordered pair - value, entity and unit each in its OWN slot in
-# the literal sentence, so a transposition is visible rather than merely
-# present.
-check("a good read says the watts, the entity it came from and the unit it was "
- "reported in, each in its own slot",
- _pv_row == ["PV production: 3210 W from sensor.pv_power "
- "(reported in W)"]
+# UX-08: the row says the watts, and no entity id (STYLE.md). The unit it was
+# read in is DEBUG detail; a refused unit still gets its own caution below.
+check("a good read says the watts, as a green row, and names no entity id",
+ _pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
+ and "sensor." not in _pv_row[0]
+ and pv_rows(c_on)[0]["ok"] is True
and ("PV", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
-_bad_row = [r for r in c_bad.checks() if r["text"].startswith("PV production")]
+_bad_row = pv_rows(c_bad)
print(f" {[r['text'] for r in _bad_row]}")
-check("a refused read says WHICH refusal, and names the entity - 'no reading' "
- "on its own sends an operator to look at a working sensor",
+check("a refused read says WHICH refusal - 'no reading' on its own sends an "
+ "operator to look at a working sensor",
len(_bad_row) == 1
- and _bad_row[0]["text"] == ("PV production: no reading from "
- "sensor.pv_power - unit '%' is not one of "
- "mW, W, kW, MW, GW, TW - it cannot be read "
- "in watts"))
+ and _bad_row[0]["text"] == ("Solar production: no reading, the sensor's "
+ "unit is not a power unit"))
+check("...and every refusal pv.watts() can give has its own plain words",
+ len({messages.pv_problem(pv.watts(st)[1]) for st in (
+ None, state("unavailable", "W"), state("abc", "W"),
+ state("12", ""), state("12", "%"))}) == 5)
check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds "
"NOT READY out of rows that are neither ok nor warn, and a telemetry "
"channel that could put a battery site into NOT READY would be "
"observability with a foot in the control path",
len(_bad_row) == 1
- and _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]}
- and not c_bad.status_payload()["banner"].startswith("NOT READY")
+ and _bad_row[0]["ok"] is False and _bad_row[0]["warn"] is True
+ and not c_bad.status_payload()["banner"].startswith(messages.NOT_READY)
and c_bad.status_payload()["level"] != "bad"
and c_bad.status_payload()["banner"]
== c_off.status_payload()["banner"])
@@ -923,41 +929,32 @@ print()
print("10. the operator surfaces at a roster of three - and 0.11.0 at one")
# ==============================================================================
_c_many, _, _ = run(cycles=1, by_entity=FULL, **PV_MANY)
-_many_row = [t for t in rows_of(_c_many) if t.startswith("PV production")]
+_many_row = [r["text"] for r in pv_rows(_c_many)]
print(f" {_many_row}")
-# habit 14 at a roster: the total in its own slot and EVERY contributor in its
-# own slot with its own watts and its own unit, so a sum that disagrees with the
-# addends printed beside it is visible in the sentence.
-check("a healthy roster says the SITE total, how many entities it came from, "
- "and then every entity with its own watts and its own unit - so a total "
- "that disagrees with the numbers printed beside it is visible rather than "
- "merely present",
- _many_row == ["PV production: 3750 W from 3 entities "
- f"({E1} 1200 W in W, {E2} 2100 W in kW, "
- f"{E3} 450 W in W)"]
+# UX-08: the row carries the site total and the number of sources; the
+# per-source figures are DOCS/DEBUG detail (DECISIONS/UX-08-short-notifications).
+check("a healthy roster says the SITE total and how many sources it came from",
+ _many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
+ f"sources"]
and ("PV", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
-_gap_row = [r for r in _c_gap.checks() if r["text"].startswith("PV production")]
+_gap_row = pv_rows(_c_gap)
print(f" {[r['text'] for r in _gap_row]}")
-check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the "
- "entity that is missing and why - AND names what the others reported. The "
- "partial information is not thrown away, it is put where a HUMAN reading "
+check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, and names "
+ "the missing source by its place in the list - put where a HUMAN reading "
"the page live will see it, and kept out of the number a recorder hands "
"back cold a week later",
len(_gap_row) == 1
- and _gap_row[0]["text"] == (
- "PV production: NO SITE TOTAL - only 2 of 3 entities are reporting "
- "and a partial sum would understate production: "
- f"{E2} - no state (entity missing, or the read failed). "
- f"Reporting: {E1} 1200 W, {E3} 450 W"))
+ and _gap_row[0]["text"] == ("Solar production: no total, source 2 of 3 "
+ "has no reading"))
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "
"one entity: `status_payload()` builds NOT READY out of rows that are "
"neither ok nor warn, and a telemetry channel that could put a battery "
"site into NOT READY would be observability with a foot in the control "
"path",
len(_gap_row) == 1
- and _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
+ and _gap_row[0]["ok"] is False and _gap_row[0]["warn"] is True
and _c_gap.status_payload()["level"] != "bad"
and _c_gap.status_payload()["banner"]
== c_off.status_payload()["banner"])
@@ -974,27 +971,24 @@ _c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
pv_entities=[E1])
print(f" one entity via pv_entities : "
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
-check("⭐ ONE ENTITY IS 0.11.0's PATH, however it was declared: the status "
- "sentence, the page row and the published value are the same shape "
- "through `pv_entity` and through a one-member `pv_entities`, and the "
- "sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`",
- [t.replace(E1, "sensor.pv_power")
- for t in rows_of(_c_one_new) if "PV" in t]
- == [t for t in rows_of(c_on) if "PV" in t]
- == ["PV production: 3210 W from sensor.pv_power (reported in W)"]
+check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
+ "sentence, the page row and the published value are the same through "
+ "`pv_entity` and through a one-member `pv_entities` - no `from 1 sources`",
+ [r["text"] for r in pv_rows(_c_one_new)]
+ == [r["text"] for r in pv_rows(c_on)]
+ == [f"Solar production: {messages.signed_watts(3210)}"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W"))
-check("...and a refused read at a roster of one is 0.11.0's refusal sentence "
- "too, not the roster one - `no reading from - `",
- [r["text"] for r in run(cycles=1, pv_entities=[E1],
- by_entity={E1: [state("1234", "%")]})[0].checks()
- if r["text"].startswith("PV production")]
- == [f"PV production: no reading from {E1} - unit '%' is not one of "
- "mW, W, kW, MW, GW, TW - it cannot be read in watts"])
+check("...and a refused read at a roster of one is the one-sensor refusal "
+ "too, not the roster one - `no reading, `",
+ [r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
+ by_entity={E1: [state("1234", "%")]})[0])]
+ == [r["text"] for r in _bad_row])
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
-_ign = [r for r in _c_both.checks() if "IGNORED" in r["text"]]
+_IGN = messages.row_pv_single_ignored()
+_ign = [r for r in _c_both.checks() if r["text"] == _IGN]
print(f" {[r['text'] for r in _ign]}")
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"entities are summed instead. `entities()` REPLACES rather than unions - "
@@ -1002,20 +996,17 @@ check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"key still names - but a replacement nobody is told about is the 'one of "
"which silently wins' this config refuses elsewhere",
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
- and _ign[0]["text"] == ("PV production: `pv_entities` is set, so "
- "`pv_entity` is IGNORED - the 3 entities in the "
- "list are what is summed. Clear `pv_entity` to "
- "remove this caution")
+ and "clear the single sensor setting" in _ign[0]["text"]
and _c_both.pv_w == 3750.0)
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
"configuration, not on a list-only one, and not on an install with "
"neither",
- not [r for r in c_on.checks() if "IGNORED" in r["text"]]
- and not [r for r in _c_many.checks() if "IGNORED" in r["text"]]
- and not [r for r in c_off.checks() if "IGNORED" in r["text"]])
+ not [r for r in c_on.checks() if r["text"] == _IGN]
+ and not [r for r in _c_many.checks() if r["text"] == _IGN]
+ and not [r for r in c_off.checks() if r["text"] == _IGN])
check("...and an install with NO PV roster still adds no row of any kind - the "
"whole of TEL-12 is invisible at the shipped default",
- not [t for t in rows_of(c_off) if "PV" in t]
+ not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "PV"]
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == []
@@ -1109,19 +1100,18 @@ check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
"carries the corrected site total",
_c_mix.pv_w == MIX_TOTAL
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
-_mix_row = [r["text"] for r in _row(_c_mix, "W from 3 entities")]
+_mix_row = [r["text"] for r in _row(_c_mix, "from 3 sources")]
print(f" {_mix_row}")
-check("the status row prints each entity's watts AFTER the sign and marks "
- "ONLY the flipped one",
- _mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, "
- f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
+check("the status row prints the total AFTER the sign",
+ _mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
+ f"from 3 sources"])
check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
-check("a holed roster's 'Reporting:' list marks the flipped entity too",
- [r["text"] for r in _row(_c_hole_f, "Reporting:")]
- and f"{E2} 2100 W [sign inverted]" in
- (_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", ""))
+check("a holed roster with a flipped member names the missing source, not "
+ "the flipped one",
+ [r["text"] for r in _row(_c_hole_f, "no total")]
+ == ["Solar production: no total, source 1 of 3 has no reading"])
# The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power"
@@ -1134,13 +1124,11 @@ check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is
"negative caution, because -13 W is standby and not a reversed sign - "
"and NO stray caution, because a lone `pv_entity` is in the roster",
_c_own.pv_w == -13.0
- and [r["text"] for r in _row(_c_own, "PV production: -13 W")]
- == [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"]
- and _row(_c_own, "probably reversed") == []
- and _row(_c_own, "flips nothing") == [])
-check("...and WITHOUT the option it is 0.12.0's row, byte for byte",
- [r["text"] for r in _row(_c_own0, "PV production:")]
- == [f"PV production: 13 W from {_OWN} (reported in W)"])
+ and [r["text"] for r in pv_rows(_c_own)]
+ == ["Solar production: -13 W, sign reversed"]
+ and _row(_c_own, "reversed-sign list") == [])
+check("...and WITHOUT the option it is the plain row",
+ [r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 W"])
# Whitespace: the roster entry and the invert entry differ only by padding. Both
# sides must normalise the same way, or the read path leaves the sign unflipped
@@ -1152,9 +1140,7 @@ check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
"`pv_invert_entities` are the same entity - flipped on the read path, "
"marked on the page, and no stray caution",
_c_pad.pv_w == MIX_TOTAL
- and f"{E2} 2100 W in kW [sign inverted]"
- in (_at(_row(_c_pad, "W from 3 entities"), 0) or {}).get("text", "")
- and _row(_c_pad, "flips nothing") == [])
+ and _row(_c_pad, "reversed-sign list") == [])
# --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()):
@@ -1162,20 +1148,20 @@ def _neg(watts_by_entity, flip=()):
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
for e, w in watts_by_entity.items()},
**opts)
- return _row(c, "probably reversed")
+ return _row(c, "reads ")
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
print(f" {[r['text'] for r in _n_add + _n_rm]}")
-check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it, its "
- "watts, and says to ADD it to `pv_invert_entities`",
+check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
+ "its place in the list, its watts, and says to ADD it to the "
+ "reversed-sign list (`pv_invert_entities`)",
[r["text"] for r in _n_add]
- == [f"PV production: {E2} reads -1500 W - far below zero, so its sign "
- "is probably reversed. If it is producing now, ADD it to "
- "`pv_invert_entities` in the Configuration tab"])
+ == ["Solar production: source 2 reads -1 500 W, so add it to the "
+ "reversed-sign list"])
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
- len(_n_rm) == 1 and "REMOVE it from `pv_invert_entities`"
+ len(_n_rm) == 1 and "remove it from the reversed-sign list"
in (_at(_n_rm, 0) or {}).get("text", ""))
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
"put a battery site into NOT READY",
@@ -1198,11 +1184,10 @@ _c_typo, _, _ = run(cycles=1, by_entity=FULL,
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
"caution (a typo would otherwise leave the sign silently unflipped)",
[(r["text"], r.get("ok"), r.get("warn"))
- for r in _row(_c_typo, "flips nothing")]
- == [("PV production: `pv_invert_entities` names sensor.pv_wset, which "
- "is not a PV entity in use, so it flips nothing - check the "
- "spelling against `pv_entities`", False, True)]
- and _row(_c_mix, "flips nothing") == [])
+ for r in _row(_c_typo, "not a solar sensor in use")]
+ == [("Solar production: entry 1 of the reversed-sign list is not a "
+ "solar sensor in use", False, True)]
+ and _row(_c_mix, "not a solar sensor in use") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
diff --git a/goodwe_controller/test_tariff.py b/goodwe_controller/test_tariff.py
index 4f40ea0..981412f 100644
--- a/goodwe_controller/test_tariff.py
+++ b/goodwe_controller/test_tariff.py
@@ -1008,7 +1008,7 @@ check("the row's coverage figure is the LIVE month's tally - graded against a "
"row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it",
bool(peak_rows)
- and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
+ and f", {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {given(SHIPPED_CAP_W):.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py
index 77ca082..648370c 100644
--- a/goodwe_controller/test_watchdog.py
+++ b/goodwe_controller/test_watchdog.py
@@ -369,10 +369,12 @@ check("0 W actually reached the device", 0.0 in hass.writes)
check("and it got there through the failsafe claim, not by accident",
"failsafe" in ctrl.reason)
print(f" status line: {ctrl.reason!r}")
-check("the status page says so in words",
- any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
+check("the status page says so in words: a red row, at 0 W, cleared by start",
+ any(c.get("id") == "direction" and not c["ok"] and not c["warn"]
+ and "set to 0 W" in c["text"] and "start control" in c["text"]
+ for c in ctrl.checks()))
check("the banner leads with it",
- "FAILSAFE" in ctrl.status_payload()["banner"])
+ ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
@@ -479,7 +481,7 @@ check("...commands 0 W from its very first cycle", post.target == 0.0)
check("...and never commanded anything else after the restart",
hass2.writes != [] and set(hass2.writes) == {0.0})
check("the banner still leads with the failsafe",
- "FAILSAFE" in post.status_payload()["banner"])
+ post.status_payload()["banner"] == messages.banner_direction(False))
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
# tripped, so the first press stops control; only the transition back to True
# clears. That is the documented behaviour and it is worth an assertion, because
@@ -952,12 +954,12 @@ check("...and it got there through the `circulation` claim, by name, rather "
"circulation" in _c.reason)
print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it",
- any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
- for ck in _c.checks()))
+ any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
+ and "meter cannot see" in ck["text"] for ck in _c.checks()))
check("the banner leads with it - above the sign failsafe, because a sign "
"disagreement is visible on every dashboard in the house and this is "
"visible nowhere at all",
- "CIRCULATING POWER" in _c.status_payload()["banner"])
+ _c.status_payload()["banner"] == messages.banner_circulating())
_pub = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
@@ -1028,7 +1030,8 @@ check("...no `circulation` claim is ever filed, so the arbitration is "
check("...and the status page says n/a rather than claiming to watch something "
"that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing",
- any("circulating power: n/a at one inverter" in ck["text"]
+ any(ck.get("id") == "circulating" and ck["ok"]
+ and "not needed with one inverter" in ck["text"]
for ck in _c1.checks()))
_pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True,
@@ -1911,9 +1914,11 @@ check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
check("the status page carries a SITE row of its own, and the banner says the "
"site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box",
- any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
+ any(ck.get("id") == "site_direction" and not ck["ok"]
+ and not ck["warn"] and "every inverter" in ck["text"]
for ck in _c31.checks())
- and "total command" in _c31.status_payload()["banner"])
+ and "Total command" in _c31.status_payload()["banner"]
+ and "inverter-" not in _c31.status_payload()["banner"])
_pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v),
@@ -2142,16 +2147,16 @@ check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and n
_n1_site_calls == []
and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None)
-check("...the banner is the sentence that shipped",
- _c1s.status_payload()["banner"]
- == "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
+check("...the banner is the one-inverter sentence, naming the meter",
+ _c1s.status_payload()["banner"] == messages.banner_direction(False)
+ and "meter" in messages.banner_direction(False))
check("...the status page carries ONE sign row, with no unit tag and no site "
"row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one",
- len([ck for ck in _c1s.checks()
- if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
- ]) == 1
- and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
+ len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
+ and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
+ and not any("inverter-" in ck["text"] for ck in _c1s.checks()
+ if ck.get("id") == "direction"))
check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
@@ -2323,12 +2328,12 @@ check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED,
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
- and _bn_b["banner"].startswith("FAILSAFE")
- and "its own measured power" in _bn_b["banner"])
+ and _bn_b["banner"] == messages.banner_direction(True)
+ and "own power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
- not _bn_n["banner"].startswith("FAILSAFE")
+ _bn_n["banner"] != messages.banner_direction(True)
and _bn_n["banner"] != _bn_b["banner"])
diff --git a/goodwe_controller/ux08_allowlist.txt b/goodwe_controller/ux08_allowlist.txt
index 94ea509..4ea0bfa 100644
--- a/goodwe_controller/ux08_allowlist.txt
+++ b/goodwe_controller/ux08_allowlist.txt
@@ -1,16 +1,10 @@
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
# does not follow STYLE.md yet: " # where :: start".
-# These are waiting for phase 2b (row, banner), 2c (refusal, option-name,
-# option-desc) and 2d (log). Delete the line when you rewrite the string, because
-# a stale line fails the test. Never add a line for a new string: write the new
+# These are waiting for phase 2c (refusal, option-name, option-desc) and 2d
+# (log); phase 2b emptied the row and banner kinds. Delete the line when you
+# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new
# string to STYLE.md instead.
-banner c185022e391f # main.py banned: SAFETY-25; option key: peak_soc_floor; capitals: THE PEAK GUARD ONLY :: '{…} - and peak_soc_floor bounds THE PEAK GUARD ONLY (SAFETY-25, see be'
-banner 4a246fc26def # main.py over 12 words (14); capitals: CIRCULATING POWER :: 'CIRCULATING POWER — the inverters were pushing against each other. Sit'
-banner 750ccd4f2126 # main.py over 12 words (14) :: "FAILSAFE — the site's total command opposed the meter. Every inverter "
-banner 637f334f130a # main.py over 12 words (26) :: 'FAILSAFE — the command opposed the meter. Inverter held at 0 W. / FAIL'
-banner 0a3d2d9c4232 # main.py capitals: NOT READY :: "NOT READY — {bad[0].get('banner') or bad[0]['text']}"
-banner dbcc227a9589 # main.py over 12 words (26); capitals: NOT DELIVERING :: 'NOT DELIVERING — {_nd} is standing at a share it is not producing. The'
log a5e99679fd80 # main.py banned: SAFETY-02, latch :: 'SAFETY-02: the per-inverter store migration did not verify (%s). The l'
log 128b18b32d86 # main.py over 25 words (41); banned: LATCHED, SAFETY-44, latched :: 'SAFETY-44: this store carries a LATCHED sign-disagreement failsafe und'
log f60446541ae5 # main.py over 25 words (83); banned: LATCH, LATCHED, SAFETY-02, latch; capitals: LATCH GUARD :: 'SAFETY-02 LATCH GUARD: this store carries a LATCHED sign-disagreement '
@@ -179,29 +173,3 @@ refusal c4a1d0371e31 # validate.py over 30 words (42); capitals: THE DEVICE WIN
refusal a15624d1aa10 # validate.py over 30 words (33); capitals: THE DEVICE WINS :: 'max_w is {max_w} W, so this controller may command {-max_w} W, but {op'
refusal 26d0602c32d7 # validate.py over 30 words (79); capitals: THE FIRMWARE WINS :: 'meter_max_age_s is {max_age_s} s but the ESP32 firmware vetoes any P1 '
refusal 2ade09aaa579 # validate.py over 30 words (73); banned: SAFETY-02, arbiter, watchdog :: 'sign_disagree_w is {sign_w} W but max_w{where} is {weakest} W, so the '
-row c24ab8bd9cb3 # main.py over 15 words (18); banned: heartbeat, layer 1; capitals: NO PASS FOR :: 'control loop: NO PASS FOR {_pass_age} s (layer 1 zeroes the inverter a'
-row c771d4bc1e0a # main.py over 15 words (17); banned: telegrams :: "P1 meter ({o.get('meter_source')}): {self.p1.net_w} W, {age} s old, {s"
-row f6195fee8575 # main.py over 15 words (33); capitals: METER MAY BE FROZEN :: 'METER MAY BE FROZEN - {…}. The loop is STILL FOLLOWING it and is still'
-row db05be7cd99c # main.py over 15 words (16); option key: meter_source; capitals: NOT COVERED :: "freeze detector: NOT COVERED on meter_source {o.get('meter_source')} -"
-row 9a21960bbbe8 # main.py capitals: NOT COVERED :: 'freeze detector: NOT COVERED - {…}'
-row c38b746a6585 # main.py banned: SATURATED; capitals: PEAK GUARD SATURATED :: 'PEAK GUARD SATURATED - {…}'
-row dd0629834473 # main.py over 15 words (36); banned: SAFETY-25; option key: peak_soc_floor; capitals: THE PEAK GUARD ONLY :: '{…}. NOTE: peak_soc_floor bounds THE PEAK GUARD ONLY - it is not a pac'
-row 4132e246e675 # main.py over 15 words (26); banned: telegrams; option key: meter_source; capitals: NOT COVERED :: "peak guard: NOT COVERED on meter_source {o.get('meter_source')} - the "
-row 7b34b16d8bb2 # main.py over 15 words (61) :: '{label}: no usable value this cycle from {entity} - and this row canno'
-row 04e0381f16c2 # main.py banned: CLAMP; capitals: OUTPUT CLAMP ENGAGED :: 'OUTPUT CLAMP ENGAGED - {…} Engagements: {self.clamp_wd.engagements}.'
-row 9f8b8adcbc6c # main.py banned: INTEGRATOR; capitals: LOOP AT INTEGRATOR BOUND :: 'LOOP AT INTEGRATOR BOUND - {…} Engagements: {self.clamp_wd.engagements'
-row f00cc9688eca # main.py over 15 words (20); banned: clamp :: 'output clamp: not engaged now, but it has engaged {self.clamp_wd.engag'
-row a44333431331 # main.py over 15 words (31); capitals: SIGN-DISAGREEMENT FAILSAFEX :: 'SIGN-DISAGREEMENT FAILSAFE{tag} - {…}. The inverter is held at 0 W. Fi'
-row 3ef4e60f2328 # main.py over 15 words (17) :: 'sign watchdog{tag}: armed{vs} (trips after {inv.sign_wd.cfg.hold_s} s '
-row 9f879b339282 # main.py over 15 words (50); banned: integrator; capitals: SITE SIGN-DISAGREEMENT FAILSAFE :: 'SITE SIGN-DISAGREEMENT FAILSAFE - {…}. EVERY inverter is held at 0 W. '
-row 96e93e34bdbf # main.py over 15 words (24); banned: watchdog :: 'site sign watchdog: armed against the sum of the commands (trips after'
-row 9ee8d293fea5 # main.py over 15 words (38); capitals: CIRCULATING POWER :: 'CIRCULATING POWER - {…}. EVERY inverter is held at 0 W. The meter cann'
-row 55eb950acd5f # main.py over 15 words (17); banned: watchdog :: 'circulation watchdog: armed (trips after {self.circ_wd.cfg.hold_s} s o'
-row 1817192a7f1c # main.py over 15 words (42); capitals: NOT DELIVERINGX :: 'NOT DELIVERING{tag} - {…}. Nothing has been commanded differently and '
-row 685c1416364b # main.py over 15 words (26) :: 'delivery watchdog{tag}: armed (reports after {inv.delivery_wd.cfg.hold'
-row d3cd52827dd0 # main.py over 15 words (29); banned: watchdog :: 'delivery watchdog: n/a at one inverter - a unit short of its share is '
-row d6981c8d7b56 # main.py banned: latched :: 'no e-stop fitted: if this host dies the battery stays latched at its l'
-row 685c8f02c97a # main.py capitals: NO SITE TOTAL :: 'PV production: NO SITE TOTAL - {…}. Reporting: {…}'
-row abef339bf192 # main.py over 15 words (25); option key: pv_entities :: 'PV production: `pv_entities` is set, so `pv_entity` is IGNORED - the {'
-row 514511bd0a93 # main.py over 15 words (32); option key: pv_invert_entities :: 'PV production: {ent} reads {watt} W - far below zero, so its sign is p'
-row 328abd6171ce # main.py over 15 words (22); option key: pv_invert_entities :: 'PV production: `pv_invert_entities` names {ent}, which is not a PV ent'