UX-08 phase 2b: short, plain status rows and page banners
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s

Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
2026-09-27 02:12:10 +02:00
co-authored by Claude Opus 5.5
parent 48ef06ac1f
commit d017fc67bf
19 changed files with 989 additions and 578 deletions
+1 -1
View File
@@ -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. - **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. - **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 "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body. - **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" 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. - **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. - **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.
@@ -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. 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. 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/<slug>/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. 7. **The Details link** opens the add-on's documentation page (`/hassio/addon/<slug>/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.
+45 -20
View File
@@ -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`, 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 `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 **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 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 here would be a silent factor of 1000 in the one channel that exists to be
believed a week later. 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 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 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 | | all three gone | `unknown` | as above |
**It does not publish the sum of the ones that answered.** Two inverters out of **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. 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 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 status page names the missing source by its place in the `pv_entities` list
others reported. That page is read live by a human who wants to fix something; (*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. 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 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; 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 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 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, 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. 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 the exact operating point the controller aims for. Freeze detection is a
separate problem and needs the low-power case solved first. 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, You read it yourself instead: the add-on's status page shows it on the Meter
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real 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 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. 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 point **opposite ways** — charging the battery while the house imports, or
discharging it while the house exports — with both over `sign_disagree_w`, for 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 `sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
there. The banner reads `FAILSAFE`, the commissioning list says which two there. The banner reads *Command and meter point opposite ways*, the add-on
numbers disagreed and for how long, and the `Controller status` entity reports log says which two numbers disagreed and for how long, and the `Controller
`failsafe`. status` entity reports `failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30) #### ⚠️ 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 the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
other latches. other latches.
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure, The banner reads **`<unit> 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 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 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 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 | | 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 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 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 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. 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 ⚠️ **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 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** `off` there is no signal to threshold and the status page says *Stuck-meter
rather than pretending the guard is armed. check: off* rather than pretending the guard is armed.
The same row appears for a second reason: a setpoint device that reports a range 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 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 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 degenerating into the bare "unchanged for N seconds" rule this design exists to
avoid. 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 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 (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 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 that still changes the bill. The projection and the watts are in the add-on
and on the status page. See *The capacity-peak guard*. log. See *The capacity-peak guard*.
### Battery stopped limiting the peak ### 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 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 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 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 window are in the add-on log; the *Circulating* line on the status page shows the
alarm*. live watts. See *The circulating-power alarm*.
### An inverter is not delivering ### 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 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 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. 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 frozen-meter alarm*.
## The Web UI ## 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 and can restart the add-on; the set of commands that reach the hardware is
exactly the three buttons it always had. 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 ## Status entities
If an MQTT broker is available the add-on publishes setpoint, grid power, If an MQTT broker is available the add-on publishes setpoint, grid power,
+8
View File
@@ -358,6 +358,10 @@ class ClampWatchdog:
def __init__(self): def __init__(self):
self.active = False self.active = False
self.engagements = 0 # every engagement, never deduplicated 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.episodes = 0 # rising edges
self.since: datetime | None = None self.since: datetime | None = None
self.last_alarm: datetime | None = None self.last_alarm: datetime | None = None
@@ -399,6 +403,10 @@ class ClampWatchdog:
""" """
if engaged: if engaged:
self.engagements += 1 self.engagements += 1
if bug:
self.over_rating += 1
else:
self.bound += 1
self.clean_cycles = 0 self.clean_cycles = 0
self.run_len += 1 self.run_len += 1
rising = not self.active rising = not self.active
+183 -300
View File
@@ -4551,27 +4551,24 @@ class Controller:
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is # 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. # sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None: if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "controller",
"text": "control loop: starting"}) "text": messages.row_controller_starting()})
else: else:
_pass_age = time.monotonic() - self.loop_pass_mono _pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S: if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "controller",
"text": f"control loop: NO PASS FOR {_pass_age:.0f} s" "text": messages.row_controller_stalled(
f" (layer 1 zeroes the inverter after " _pass_age, FIRMWARE_INGEST_VETO_S)})
f"{FIRMWARE_INGEST_VETO_S:.0f} s without a "
f"heartbeat)"})
else: else:
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "controller",
"text": f"control loop: last pass {_pass_age:.0f} s ago"}) "text": messages.row_controller_ran(_pass_age)})
if self.p1_enabled: if self.p1_enabled:
age = self.p1.published_age_s age = self.p1.published_age_s
if self.p1.stale: if self.p1.stale:
out.append({"ok": False, "warn": False, # The last error is on the add-on log's WARNING line (p1.py).
"text": f"P1 meter ({o.get('meter_source')}): no reading for " out.append({"ok": False, "warn": False, "id": "meter",
f"{age:.0f} s (limit {self.p1.max_age_s:.0f} s)" "text": messages.row_meter_stale(
+ (f" - last error: {self.p1.last_error}" age, self.p1.max_age_s)})
if self.p1.last_error else "")})
else: else:
# ⚠️ unchanged_s is REPORTED, never thresholded and never folded # ⚠️ unchanged_s is REPORTED, never thresholded and never folded
# into the age - see HomeWizardLocalSource.unchanged_s for why # 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 # only homewizard_local has one, and p1_source is None until
# amain() builds the transport. # amain() builds the transport.
unchanged = getattr(self.p1_source, "unchanged_s", None) unchanged = getattr(self.p1_source, "unchanged_s", None)
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "meter",
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, " "text": messages.row_meter(
f"{age:.0f} s old, {self.p1.samples} telegrams, " self.p1.net_w, age, self.p1.parse_errors,
f"{self.p1.parse_errors} rejected" unchanged)})
+ (f", measurement unchanged for {unchanged:.0f} s"
if unchanged is not None else "")})
# SAFETY-18. Reported in both states, for the reason SAFETY-02 is: # SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
# a detector nobody can see is armed is a detector nobody trusts # 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 # 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 # is an alarm, and the banner is the loudest surface we own. It
# changes NOTHING about control; see check_meter_frozen(). # changes NOTHING about control; see check_meter_frozen().
if self.freeze_wd.active: if self.freeze_wd.active:
out.append({"ok": False, "warn": False, # The figures are in `freeze_wd.reason`, on the log and the
"text": "METER MAY BE FROZEN - " + self.freeze_wd.reason # event list (check_meter_frozen()).
+ ". The loop is STILL FOLLOWING it and is " out.append({"ok": False, "warn": False, "id": "meter_frozen",
"still running. Check the meter. Held here " "text": messages.row_meter_frozen()})
"for a quarter of an hour this sets the "
"month's billed capacity peak."})
elif getattr(self.p1_source, "unchanged_s", None) is None: elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible # Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the # to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run. # status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": f"freeze detector: NOT COVERED on " "text": messages.row_freeze_off("source")})
f"meter_source {o.get('meter_source')} - only "
f"homewizard_local sees whether the meter's own "
f"document changes"})
elif rail_unreachable(self.site_max_w, elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"), dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")): 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 # and an operator does not need to learn two vocabularies for
# it. What differs is only which term is missing: there, # it. What differs is only which term is missing: there,
# `unchanged_s`; here, a rail the command could ever stand at. # `unchanged_s`; here, a rail the command could ever stand at.
out.append({"ok": False, "warn": True, _no_rating = (not self.site_max_w
"text": f"freeze detector: NOT COVERED - " or not is_commandable(self.site_max_w))
+ rail_unreachable( out.append({"ok": False, "warn": True, "id": "freeze_check",
self.site_max_w, "text": messages.row_freeze_off(
dev_min=self.site_dev_bound("dev_min"), "rating" if _no_rating else "range")})
dev_max=self.site_dev_bound("dev_max"))})
else: else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE # ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included. This row used to interpolate # USES, device range included. This row used to interpolate
@@ -4643,17 +4632,12 @@ class Controller:
# reads `min` and `max` independently and returns None for # reads `min` and `max` independently and returns None for
# whichever attribute is missing or unparseable - so this # whichever attribute is missing or unparseable - so this
# renders whatever is there rather than formatting a None. # renders whatever is there rather than formatting a None.
_cap = ("" if _rail >= rail_w(self.site_max_w) # UX-08: "the inverter's own limit" says the device set it; which
else " - below max_w because " # inverter and its range are DOCS and the settings, not entity ids.
+ "; ".join( out.append({"ok": True, "warn": False, "id": "freeze_check",
f"{inv.setpoint_entity or inv.name} accepts " "text": messages.row_freeze_on(
f"only {inv.dev_min}...{inv.dev_max} W" self.freeze_wd.cfg.hold_s, _rail,
for inv in self.inverters)) own_limit=_rail < rail_w(self.site_max_w))})
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})"})
# TARIFF-01. The month's billed peak, and - the load-bearing half - # 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 # a DIFFERENT row when the store could not answer. "no peak yet" is
@@ -4673,8 +4657,8 @@ class Controller:
"text": _pk.explain() "text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the " + f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit" f"{self.peak.cap_w:.0f} W soft limit"
+ f"; {self.peak.recorded} quarter(s) banked" + f", {self.peak.recorded} quarter(s) banked"
+ f"; on start: {self.peak.restore_state}"}) + f", on start: {self.peak.restore_state}"})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see" # SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way # are opposite states and a page that spelled them the same way
@@ -4684,12 +4668,12 @@ class Controller:
# decision that has not been taken. # decision that has not been taken.
_pg = self.peak_guard _pg = self.peak_guard
if _pg is None: 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 " "text": "peak guard: no decision yet - the first "
"control cycle has not run"}) "control cycle has not run"})
elif _pg.saturated: elif _pg.saturated:
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "peak_guard",
"text": "PEAK GUARD SATURATED - " + _pg.reason}) "text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited: elif _pg.floor_limited:
# SAFETY-24. NOT ok, like saturation: in both states the money # 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 # 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 # the whole sentence, because the row is where an operator
# reads what the floor does and does not bound. Shortening the # reads what the floor does and does not bound. Shortening the
# ROW instead would be weakening AC 3. # ROW instead would be weakening AC 3.
out.append({"ok": False, "warn": False, # UX-08: the same three branches, and the same pack naming, as
"banner": _pg.explain() # check_peak_floor_limited()'s notification. The figures stay in
+ " - and peak_soc_floor bounds THE PEAK " # `_pg.reason`, on the log and the event list.
"GUARD ONLY (SAFETY-25, see below)", _blocked = [name for name, why
"text": _pg.explain() in peakguard.pack_permissions(self.packs,
+ ". NOTE: peak_soc_floor bounds THE PEAK " _pg.soc_floor)
"GUARD ONLY - it is not a pack-wide " if why is not None]
"reserve, and ordinary self-consumption " out.append({"ok": False, "warn": False, "id": "peak_guard",
"has no SoC floor, so the battery may " "banner": messages.banner_peak_reserve(),
"keep discharging below this level down " "text": messages.row_peak_reserve(
"to the BMS cut-out (SAFETY-25)"}) _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: 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: else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged, out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
"text": _pg.explain()}) "id": "peak_guard", "text": _pg.explain()})
else: else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point. # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the # 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. # leave the one install that is unprotected showing nothing at all.
# Same answer, same reason, as the freeze detector's NOT COVERED row # Same answer, same reason, as the freeze detector's NOT COVERED row
# a few lines up. # a few lines up.
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": f"peak guard: NOT COVERED on meter_source " "text": messages.row_peak_guard_off()})
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"})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on # ⚠️ 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 # 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. # that is what an existing install reads.
rows = [] rows = []
for inv in self.inverters: 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
rows.append((f"battery SoC{tag}", inv.soc, inv.soc_entity)) rows.append((messages.LABEL_SOC, unit, inv.soc, inv.soc_entity))
rows.append((f"battery power{tag}", inv.batt, inv.batt_entity)) rows.append((messages.LABEL_BATTERY, unit, inv.batt,
inv.batt_entity))
if not self.p1_enabled: if not self.p1_enabled:
# In P1 mode the check above replaces this one; leaving both in # In P1 mode the check above replaces this one; leaving both in
# would report "no entity configured" for a meter_entity that is # would report "no entity configured" for a meter_entity that is
# correctly unused, i.e. a permanent false NOT READY. # correctly unused, i.e. a permanent false NOT READY.
rows.insert(0, ("grid power", self.grid, o.get("meter_entity"))) rows.insert(0, (messages.LABEL_GRID, None, self.grid,
for label, value, entity in rows: 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: 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: elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED # ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read # 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 # pre-empting the answer. Same reason the wording is not shared
# with `freshness._grade()`: that one is a log line about a # with `freshness._grade()`: that one is a log line about a
# BEHAVIOUR, this one is a banner about an ENTITY. # BEHAVIOUR, this one is a banner about an ENTITY.
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "sensor",
"text": f"{label}: no usable value this cycle from " "text": messages.row_sensor_unusable(label, unit)})
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"})
else: 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: for inv in self.inverters:
sp = inv.setpoint_entity 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: if not sp:
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "control",
"text": f"setpoint entity not configured{tag}"}) "text": messages.row_control_unset(unit)})
elif inv.dev_max is None: elif inv.dev_max is None:
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "control",
"text": f"setpoint entity {sp} not found"}) "text": messages.row_control_missing(unit)})
else: else:
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "control",
"text": f"setpoint {sp} (range {inv.dev_min:g}…" "text": messages.row_control(inv.dev_min,
f"{inv.dev_max:g} W)"}) inv.dev_max, unit)})
# DEV-02. Whether this controller can still put a number on each # 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 # 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: # evidence with it. Reported only when there is something to report:
# unlike the freeze detector this guard has no "not covered" state, it # unlike the freeze detector this guard has no "not covered" state, it
# runs on every write on every transport. # runs on every write on every transport.
if self.clamp_wd.active and self.clamp_wd.bug: # UX-07 item 1 / UX-08: each count is shown under ITS alarm's name -
out.append({"ok": False, "warn": False, # over-rating cuts as "Power request too high", integrator-bound cycles
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason # as "Battery is not keeping up" - never the two summed under one.
+ f" Engagements: {self.clamp_wd.engagements}."}) _cw = self.clamp_wd
elif self.clamp_wd.active: 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. # SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": "LOOP AT INTEGRATOR BOUND - " + self.clamp_wd.reason "text": messages.row_not_keeping_up(_cw.bound)})
+ f" Engagements: {self.clamp_wd.engagements}."}) else:
elif self.clamp_wd.engagements: if _cw.over_rating:
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": f"output clamp: not engaged now, but it has " "text": messages.row_before(
f"engaged {self.clamp_wd.engagements} time(s) in " messages.T_OUTPUT_CLAMP, _cw.over_rating)})
f"{self.clamp_wd.episodes} episode(s) since " if _cw.bound:
f"start - see the event log"}) 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: for inv in self.inverters:
if inv.last_write_ok is False: if inv.last_write_ok is False:
tag = "" if len(self.inverters) == 1 else f" ({inv.name})" out.append({"ok": False, "warn": False, "id": "write",
out.append({"ok": False, "warn": False, "text": messages.row_write_refused(
"text": f"last write to the inverter{tag} was " None if len(self.inverters) == 1
f"REJECTED - check the log"}) else inv.name)})
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed # SAFETY-02. Reported in both states: a watchdog nobody can see is armed
# is a watchdog nobody trusts when it fires. # 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 # inverter's own power sensor. `sign_wd.against` is the same string the
# trip sentence uses, so the two cannot drift apart. # trip sentence uses, so the two cannot drift apart.
for inv in self.inverters: 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 # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
# shipped roster of one the two sentences below are the ones that # 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 # 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- # 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 # 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. # fault that is visible only on one inverter's own power sensor.
vs = ("" if len(self.inverters) == 1 # UX-08: at N>=2 `unit` is set, and the row says "its own power".
else f" against {inv.sign_wd.against}") # The figures stay in `sign_wd.reason`, on the log and event list.
if inv.sign_wd.tripped: if inv.sign_wd.tripped:
what = ("the meter" if len(self.inverters) == 1 out.append({"ok": False, "warn": False, "id": "direction",
else inv.sign_wd.against) "text": messages.row_direction_trip(unit)})
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."})
else: else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc)) held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "direction",
"text": f"sign watchdog{tag}: armed{vs} (trips after " "text": messages.row_direction_check(
f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over " held, inv.sign_wd.cfg.hold_s, unit)})
f"{inv.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held else "")})
# --- SAFETY-30: the SITE row ----------------------------------------- # --- SAFETY-30: the SITE row -----------------------------------------
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather # ⚠️ 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. # operator learns to skim the list.
if len(self.inverters) > 1: if len(self.inverters) > 1:
if self.site_sign_wd.tripped: if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "site_direction",
"text": "SITE SIGN-DISAGREEMENT FAILSAFE - " "text": messages.row_site_direction_trip()})
+ 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."})
else: else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc)) _sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "site_direction",
"text": f"site sign watchdog: armed against the sum of " "text": messages.row_site_direction_check(
f"the commands (trips after " _sheld, self.site_sign_wd.cfg.hold_s)})
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 "")})
# --- SAFETY-31 ----------------------------------------------------- # --- SAFETY-31 -----------------------------------------------------
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says # ⚠️ 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". # here would invite the operator to clear "the other one".
_circ = self.site_circulating_w _circ = self.site_circulating_w
if self.circ_wd.tripped: if self.circ_wd.tripped:
out.append({"ok": False, "warn": False, out.append({"ok": False, "warn": False, "id": "circulating",
"text": "CIRCULATING POWER - " + self.circ_wd.reason "text": messages.row_circulating_trip()})
+ ". 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."})
elif len(self.inverters) <= 1: elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for # 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 # every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead. # watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "circulating",
"text": "circulating power: n/a at one inverter - " "text": messages.row_one_inverter(
"sum(|Bi|) - |sum(Bi)| is identically zero"}) "Circulating power check")})
else: else:
held = self.circ_wd.held_s(datetime.now(timezone.utc)) held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "circulating",
"text": "circulation watchdog: armed (trips after " "text": messages.row_circulating_check(
f"{self.circ_wd.cfg.hold_s:.0f} s over " _circ, held, self.circ_wd.cfg.hold_s)})
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 "")})
# --- SAFETY-42 ------------------------------------------------------- # --- SAFETY-42 -------------------------------------------------------
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE # ⚠️ 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. # caution is what a fault nobody is acting on actually is.
if len(self.inverters) > 1: if len(self.inverters) > 1:
for inv in self.inverters: for inv in self.inverters:
tag = f" ({inv.name})"
if inv.delivery_wd.tripped: if inv.delivery_wd.tripped:
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "delivery",
"text": f"NOT DELIVERING{tag} - " "text": messages.row_not_delivering(inv.name)})
+ 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."})
else: else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc)) _dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "delivery",
"text": f"delivery watchdog{tag}: armed (reports after " "text": messages.row_delivery_check(
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of " inv.name, _dheld,
f"its share by over " inv.delivery_wd.cfg.hold_s)})
f"{self.tuning.saturation_w:.0f} W, with a "
f"sibling that is not)"
+ (f" - short for {_dheld:.0f} s now"
if _dheld else "")})
else: else:
# AC 5, and the same honesty `circulating power: n/a` uses one block # 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 # 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 # subset to find, for EVERY input, so "armed" would be a claim to be
# watching something that cannot happen. # watching something that cannot happen.
out.append({"ok": True, "warn": False, out.append({"ok": True, "warn": False, "id": "delivery",
"text": "delivery watchdog: n/a at one inverter - a unit " "text": messages.row_one_inverter("Delivery check")})
"short of its share is indistinguishable from a "
"plant that cannot deliver until there is a "
"sibling to compare it with"})
if not o.get("estop_fitted", False): if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "estop",
"text": "no e-stop fitted: if this host dies the battery stays " "text": messages.row_no_estop()})
"latched at its last command"})
else: else:
out.append({"ok": True, "warn": False, "text": "e-stop fitted"}) 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 # control path. The reason comes from `pv.watts()` rather than being
# re-derived here, so the page cannot disagree with the reading. # 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 -- # TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08
# ⚠️ THE `len(...) == 1` BRANCH IS 0.11.0's CODE UNCHANGED, DOWN TO THE # replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so
# f-string, so an install that set `pv_entity` reads the row it already # a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows
# reads. That is an ACCEPTANCE CRITERION rather than a courtesy, and it # these rows as changed telemetry; the control documents stay equal.
# 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.
roster = self.pv_roster roster = self.pv_roster
if roster: if roster:
# TEL-14: each flipped entity is marked where its watts are printed; # UX-08: sources are numbered in the order of the settings list,
# an unflipped one gets "", so 0.12.0's sentences are unchanged. # 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) flip = pv_read.inverted(self.o)
n_src = len(roster)
def inv(ent): if self.pv_w is None and n_src == 1:
return " [sign inverted]" if ent in flip else "" out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_none(self.pv_why)})
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])})
elif self.pv_w is None: elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN # ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS. `pv.total()`'s note is the argument; this is # THOSE WORDS - and which sources are missing, by position.
# where the operator meets it. The partial information is NOT out.append({"ok": False, "warn": True, "id": "pv",
# thrown away - every entity that reported is named with its "text": messages.row_pv_partial(
# watts and every one that did not is named with its reason - [i + 1 for i, (_e, watt, _y)
# because this surface is read LIVE by a human who wants the in enumerate(self.pv_parts) if watt is None],
# diagnostic, whereas the MQTT row is read back COLD a week n_src)})
# 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")})
else: else:
# habit 14 at a roster: the total in its own slot, then EVERY out.append({"ok": True, "warn": False, "id": "pv",
# contributor in its own slot with its own watts and its own "text": messages.row_pv(
# unit. A sum that disagrees with the addends it is printed self.pv_w, n_src,
# beside is visible in the sentence. n_src == 1 and roster[0] in flip)})
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)
+ ")"})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s # ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
# right rule (its docstring says why) and is also the rule an # rule an operator upgrading from 0.11.0 is most likely to trip
# operator upgrading from 0.11.0 is most likely to trip over: they # over. A CAUTION and never a `bad` row: telemetry must not stop
# add all their inverters to the new field and the old key is left # control.
# 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.
if pv_read.entity_is_ignored(self.o): if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "pv",
"text": "PV production: `pv_entities` is set, so " "text": messages.row_pv_single_ignored()})
"`pv_entity` is IGNORED - the "
f"{len(roster)} entities in the "
"list are what is summed. Clear "
"`pv_entity` to remove this caution"})
# TEL-14: a sign that looks reversed, per entity rather than on the # 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. # 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: if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "pv",
"text": f"PV production: {ent} reads " "text": messages.row_pv_negative(
f"{watt:.0f} W - far below zero, so " i + 1, n_src, watt, ent in flip)})
f"its sign is probably reversed. If " _inv_list = [str(x or "").strip()
f"it is producing now, " for x in (self.o.get("pv_invert_entities") or [])]
+ ("REMOVE it from "
"`pv_invert_entities`"
if ent in flip else
"ADD it to `pv_invert_entities`")
+ " in the Configuration tab"})
for ent in pv_read.stray_inverts(self.o): for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True, out.append({"ok": False, "warn": True, "id": "pv",
"text": f"PV production: `pv_invert_entities` " "text": messages.row_pv_stray(
f"names {ent}, which is not a PV entity in " _inv_list.index(ent) + 1)})
f"use, so it flips nothing - check the "
f"spelling against `pv_entities`"})
return out return out
def status_payload(self) -> dict: def status_payload(self) -> dict:
@@ -5145,9 +5039,7 @@ class Controller:
# is the fault the operator has NO OTHER WAY of learning about: a # is the fault the operator has NO OTHER WAY of learning about: a
# sign disagreement is visible at the meter and on every dashboard # sign disagreement is visible at the meter and on every dashboard
# in the house, and circulating power is visible nowhere at all. # in the house, and circulating power is visible nowhere at all.
level, banner = "bad", ("CIRCULATING POWER — the inverters were " level, banner = "bad", messages.banner_circulating()
"pushing against each other. Site held at "
"0 W.")
elif self.site_sign_wd.tripped and len(self.inverters) > 1: elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below # SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument # 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 # 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 # for, not in one device - naming a unit here would send an operator
# to swap the wrong box. # to swap the wrong box.
level, banner = "bad", ("FAILSAFE — the site's total command " level, banner = "bad", messages.banner_site_direction()
"opposed the meter. Every inverter held at "
"0 W.")
elif any(i.sign_wd.tripped for i in self.inverters): elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "NOT READY": nothing is missing, something went wrong. # Outranks "Not ready": nothing is missing, something went wrong.
# ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter" # At a roster of one the unit was graded against the meter; at N>=2
# is what the unit was graded against. At N>=2 it is that unit's own # against its own power, so the banner says so - see checks() on
# power, so the banner says so - see checks() on why the noun is # why the noun is load-bearing rather than cosmetic.
# load-bearing rather than cosmetic. level, banner = "bad", messages.banner_direction(
level, banner = "bad", ( len(self.inverters) > 1)
"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.")
elif bad: elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
# need to say more to an operator reading the checks list than fits # 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 # 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 # row is the case that forced this - and a headline that is a full
# paragraph, repeated verbatim by the row underneath it, is a # paragraph, repeated verbatim by the row underneath it, is a
# headline nobody reads. Rows without the key are unaffected. # headline nobody reads. UX-08: without the key, the banner takes
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}" # the row's first sentence, so it stays a headline.
level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto: elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W" level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif any(i.delivery_wd.tripped for i in self.inverters): 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 # precisely the shape this whole card exists to remove. It sits
# BELOW "Stopped" because a stopped site is the more urgent fact and # BELOW "Stopped" because a stopped site is the more urgent fact and
# the banner carries one sentence. # the banner carries one sentence.
_nd = ", ".join(i.name for i in self.inverters level, banner = "warn", messages.banner_not_delivering(
if i.delivery_wd.tripped) [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.")
elif self.maint.phase != IDLE: elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster # ⚠️ 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 # of one this is the string it has always been, character for
+410
View File
@@ -228,6 +228,416 @@ def meter_frozen(unchanged_s) -> Notice:
f"stop control on the add-on page.") 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: def samples() -> list:
"""One rendering of every notice, with realistic values, for the test.""" """One rendering of every notice, with realistic values, for the test."""
return [ return [
+31 -8
View File
@@ -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. # 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 " 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)", "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)) for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged", 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"] "under the integrator bound's own title",
for c in ctl_r.checks())) 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 # ⚠️ 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. # 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", check("AC 2: no OUTPUT_CLAMP row in the action log",
not [e for e in ctl_s.events if REASON_CODE in e]) not [e for e in ctl_s.events if REASON_CODE in e])
check("AC 2: and no clamp row on the status page", 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") print(" ...and the two alarms do not share a notification_id")
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)} 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() and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i)) for x in log_i))
check("...and the status row is amber, not red", 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) any(c["warn"] and c.get("id") == "not_keeping_up" for c in red_i)
and not any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"] and not any(not c["warn"] and c.get("id") == "power_too_high"
for c in red_i)) for c in red_i))
# --- 9b. the genuine over-rating: integrator_max_w above max_w ---------------- # --- 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'), and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
_f(d, 'clamped_w')) for d, t, r in over) _f(d, 'clamped_w')) for d, t, r in over)
for n in clamp_notes(hass_o)) 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)) 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 ------------------------------- # --- 9c. the text, directly, and the two budgets -------------------------------
from app.clamp import loop_clamp_note # noqa: E402 from app.clamp import loop_clamp_note # noqa: E402
+10 -6
View File
@@ -337,15 +337,19 @@ def rows_for(pass_age):
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31) 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]}") 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'", 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", check("29 s after a pass it is ok",
r_29[0] == {"ok": True, "warn": False, r_29[0]["id"] == "controller" and r_29[0]["ok"] and not r_29[0]["warn"]
"text": "control loop: last pass 29 s ago"}) 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", check("31 s after a pass it is RED and first, naming the age and the veto",
r_31[0] == {"ok": False, "warn": False, r_31[0]["id"] == "controller" and not r_31[0]["ok"]
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the " and not r_31[0]["warn"]
"inverter after 30 s without a heartbeat)"}) 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(): async def one_pass():
+10 -5
View File
@@ -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 " "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 " "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", "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 "inverter-b" in _pay["banner"]
and "holding grid at zero" not in _pay["banner"]) and "holding grid at zero" not in _pay["banner"])
print(f" banner: {_pay['level']!r} / {_pay['banner']!r}") 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 " 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", "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", "is byte-identical to what it was before this card",
unit_key(_only.name, "delivery_tripped") not in _st1.data unit_key(_only.name, "delivery_tripped") not in _st1.data
and unit_key(_only.name, "delivery_reason") 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 " check("...and the status page says n/a rather than 'armed', which would be a "
"claim to be watching something that cannot happen", "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", check("...no persistent notification of this kind was raised either",
not [c for c in _h1.calls not [c for c in _h1.calls
if "not_delivering" in str(c[2].get("notification_id", ""))]) if "not_delivering" in str(c[2].get("notification_id", ""))])
+39 -31
View File
@@ -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 # install has. Flipped here rather than plumbing a whole P1 transport in - the
# row's own content is what is under test. # row's own content is what is under test.
ctl_f.p1_enabled = True 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, " 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)") f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03 # 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"]) bool(notes) and "Control is still running" in notes[0]["message"])
check("the status page carries a failed row saying so", 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"]) 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)) 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 # ⚠️ 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. # 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 # 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. # is what is under test, not the transport that feeds it.
ctl.p1_enabled = True ctl.p1_enabled = True
return ctl, [r for r in ctl.checks() return ctl, [r for r in ctl.checks() if r.get("id") == "freeze_check"]
if r["text"].startswith("freeze detector")]
_ctl_u, _rows_u = freeze_rows(None, 4000.0, meter_source="ha_dsmr") _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)'}") 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", check("an uncovered transport gets exactly one freeze-detector row",
len(_rows_u) == 1) len(_rows_u) == 1)
check("...and it says NOT COVERED", # UX-08: the row says the check is off and which source it needs, in words
bool(_rows_u) and "NOT COVERED" in _rows_u[0]["text"]) # (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 " 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", "page lying on behalf of a guard that cannot run",
bool(_rows_u) and "armed" not in _rows_u[0]["text"]) bool(_rows_u) and "alerts after" not in _rows_u[0]["text"])
check("...and it names the meter_source it is not covering, so the operator " check("...and it names HomeWizard local as the source that would be covered",
"knows which install this is", bool(_rows_u) and "HomeWizard local" in _rows_u[0]["text"])
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"])
check("...flagged as a warning rather than a silent ok", check("...flagged as a warning rather than a silent ok",
bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"]) bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"])
check("a COVERED transport gets exactly one freeze-detector row", check("a COVERED transport gets exactly one freeze-detector row",
len(_rows_c) == 1) len(_rows_c) == 1)
check("...and that one DOES say armed - without this, 'the uncovered row does " check("...and that one DOES say it is on - without this, 'the uncovered row "
"not say armed' is satisfied by deleting the armed branch", "does not say armed' is satisfied by deleting the armed branch",
bool(_rows_c) and "armed" in _rows_c[0]["text"] bool(_rows_c) and "alerts after" in _rows_c[0]["text"]
and "NOT COVERED" not 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 " check("...and it quotes the hold and the rail it is armed at, derived from the "
"config the controller was actually built with", "config the controller was actually built with",
bool(_rows_c) bool(_rows_c)
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"] and f"{messages.duration(_ctl_c.freeze_wd.cfg.hold_s)} unchanged"
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"]) 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", 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"]) 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 _c20.p1_enabled = True
check("...and the red status row is up too, so the page and the notification " check("...and the red status row is up too, so the page and the notification "
"agree", "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 " check("...and the page does NOT print the max_w rail the command can never "
"reach - THE DEFECT, in one assertion", "reach - THE DEFECT, in one assertion",
bool(_rows_s) 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", check("...it prints the rail the predicate actually uses",
bool(_rows_s) bool(_rows_s)
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W" and messages.watts(rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0,
in _rows_s[0]["text"]) dev_max=4700.0)) in _rows_s[0]["text"])
check("...and it names the device range as the reason, since the operator " check("...and it names the inverter's own limit as the reason, since the "
"configured max_w and is owed the reason the guard is armed elsewhere", "operator configured max_w and is owed the reason the guard is armed "
bool(_rows_s) and "4700" in _rows_s[0]["text"] "elsewhere",
and "max_w" in _rows_s[0]["text"]) 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. # ⚠️ 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 # 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 # 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, # this: the rail the page advertises has to be a magnitude the wire can carry,
# and the wire stops at the device rating. # 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 _m = re.search(r"above (\d[\d ]*) W", _rows_s[0]["text"]) if _rows_s else None
_page_rail = float(_m.group(1)) if _m 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 " print(f" the page advertises a rail of {_page_rail} W against a device that "
f"stops at {_ctl_s.dev_max:.0f} W") f"stops at {_ctl_s.dev_max:.0f} W")
check("...the row states a rail in watts at all, so the parse below means " 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"]) "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("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 " check("...and it says the check is off for the power range - the rail is not "
"0 W, which would be TRUE of every command including a parked one", "merely low there, it is 0 W, which would be TRUE of every command "
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"]) "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", check("...and it NEVER says armed",
bool(_rows_z) and "armed" not in _rows_z[0]["text"]) 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 " check("...flagged as a warning rather than a silent ok, like the uncovered "
"transport row it shares", "transport row it shares",
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"]) bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
+15 -10
View File
@@ -69,6 +69,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from app import freshness as F from app import freshness as F
from app import messages
from named_absence import first, given, nth, present from named_absence import first, given, nth, present
from app.circulation import circulating_w from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness 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 a red with no name attached, which reads like broken infrastructure and gets
retried rather than read. Measured: 0 named reds before, 2 after. 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: if len(hits) == 1:
return hits[0] return hits[0]
return {"ok": None, "warn": None, 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 _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_abs["ok"] is False and _row56_abs["warn"] is False
and _row56_ref["text"] == _row56_abs["text"]) and _row56_ref["text"] == _row56_abs["text"])
check("...and what it says instead is the observation plus BOTH causes, with " # UX-08: the row names the setting by its label, never the entity id
f"{_BATT_ENT} in its own slot - `label` and `entity` are two " # (STYLE.md), so there is no second interpolation left to transpose.
f"interpolations in one sentence and swapping them costs zero bytes, so " check("...and what it says instead is the observation plus BOTH causes, under "
f"the literal around each is pinned (habit 14)", "its own label, and the check to make before replacing the sensor - with "
f"battery power: no usable value this cycle from {_BATT_ENT} - and this " f"no entity id ({_BATT_ENT}) on the page row",
f"row cannot tell which of two things that is" in _row56_ref["text"] _row56_ref["text"].startswith(f"{messages.LABEL_BATTERY}: no usable "
and "the entity is missing or not numeric" in _row56_ref["text"] f"reading")
and "a number that this controller is withholding" in _row56_ref["text"]) 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 " 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()` " "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 " "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", "other caller does not need",
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"] _S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
and _S56_EXPLANATIONS[1][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) ------------- # --- 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 # * It does not show the live episode was a BMS refusal beyond doubt. 11a
+8 -6
View File
@@ -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 " "'the sign watchdog is armed' printed over a latched sibling is the page "
"lying", "lying",
len([r for r in _c3r.checks() 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() 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 " 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 " "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 " "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 " "per-unit rows would be advertising two guards where the install has "
"three", "three",
len([r for r in _c3r.checks() 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 " 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 " "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 " "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 " "look at P1 for a fault that is visible only on one inverter's own power "
"sensor", "sensor",
all("its own measured power" in r["text"] for r in _c3r.checks() len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
if r["text"].startswith(("sign watchdog [", and all("its own power" in r["text"] for r in _c3r.checks()
"SIGN-DISAGREEMENT FAILSAFE [")))) if r.get("id") == "direction"))
# The operator's clear, which is one button and has to reach every latch. # The operator's clear, which is one button and has to reach every latch.
run(_c3r.handle_action("auto_toggle")) # -> stopped run(_c3r.handle_action("auto_toggle")) # -> stopped
+67 -3
View File
@@ -7,8 +7,11 @@
2. No notification is worded anywhere else. An AST scan of `app/` fails any 2. No notification is worded anywhere else. An AST scan of `app/` fails any
`persistent_notification` call whose payload is not built by `persistent_notification` call whose payload is not built by
`app/messages.py`. `app/messages.py`.
3. THE RATCHET for the kinds not rewritten yet. Every status-row and banner 3. The status page. Every row and banner in `app/messages.py` obeys the rule
literal, every validator refusal, every WARNING+ log line and every option 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 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 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 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, # 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 # so deleting lines means lowering this number in the same change, and adding
# one means raising it where a reviewer sees it. # one means raising it where a reviewer sees it.
ALLOWLIST_MAX = 200 ALLOWLIST_MAX = 168
def read_allowlist(path): def read_allowlist(path):
@@ -326,10 +329,57 @@ if "--violations" in sys.argv:
print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}") print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}")
sys.exit(0) sys.exit(0)
STATUS = messages.status_samples()
def row_problems(colour, text):
"""STYLE.md section 1 for a status row (by colour) or a banner (None)."""
n_s, n_w = sentences(text), len(words(text))
out = []
if colour is None:
if n_w > 12:
out.append(f"banner over 12 words ({n_w})")
elif colour == messages.RED:
if n_s > 3:
out.append(f"red row over 3 sentences ({n_s})")
if n_w > 30:
out.append(f"red row over 30 words ({n_w})")
else:
if n_s > 1:
out.append(f"{colour} row over 1 sentence ({n_s})")
if n_w > 15:
out.append(f"{colour} row over 15 words ({n_w})")
if ";" in text:
out.append("semicolon: split it into sentences or drop a clause")
return out + text_problems(text, keys=False, entities=False)
for fn, colour, text in STATUS:
p = row_problems(colour, text)
check(f"{colour or 'banner':6} {text!r}{' - ' + '; '.join(p) if p else ''}",
not p)
_catalogue = {k for k, v in vars(messages).items()
if callable(v) and k.startswith(("row_", "banner_"))}
_missing = sorted(_catalogue - {fn for fn, _c, _t in STATUS})
check(f"the samples cover every row and banner in the catalogue "
f"(missing: {_missing})", not _missing)
# A red row with no banner of its own heads the page as "Not ready - <its first
# sentence>". The three trips have their own branch in status_payload(), and
# main.py gives the other two a "banner" key (both are sampled above).
_own_banner = {"row_direction_trip", "row_site_direction_trip",
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
and len(words(messages.banner_not_ready({"text": t}))) > 12]
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) allow = read_allowlist(ALLOW_FILE)
by_kind = {} by_kind = {}
for kind, *_ in found: for kind, *_ in found:
by_kind[kind] = by_kind.get(kind, 0) + 1 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})", check(f"the scan found every ratcheted kind ({by_kind})",
all(by_kind.get(k, 0) >= 5 for k in LIMITS)) all(by_kind.get(k, 0) >= 5 for k in LIMITS))
new = sorted(k for k in violating if k not in allow) 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)) heads = set(re.findall(r"^### (.+)$", section, re.M))
missing = sorted({n.docs for n in SAMPLES} - heads) missing = sorted({n.docs for n in SAMPLES} - heads)
check(f"...with a heading for every Details line (missing: {missing})", not missing) 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" _fake = " x = {'text': 'THE METER IS FROZEN AND NOTHING WORKS AT ALL'}\n"
_n = [t for node in ast.walk(ast.parse(_fake.strip())) _n = [t for node in ast.walk(ast.parse(_fake.strip()))
if isinstance(node, ast.Dict) for _, t in strings_in(node.values[0])] 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", check("a new shouting status row is seen by the scan and is not listed",
_n and problems_of("row", _n[0]) _n and problems_of("row", _n[0])
and key_of("row", _n[0]) not in allow) and key_of("row", _n[0]) not in allow)
+1 -1
View File
@@ -1432,7 +1432,7 @@ def p1_rows(ctl):
a traceback instead of reddening the check that names the rule. a traceback instead of reddening the check that names the rule.
""" """
try: 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 except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" checks() raised {type(err).__name__}: {err}") print(f" checks() raised {type(err).__name__}: {err}")
return err return err
+37 -35
View File
@@ -1038,7 +1038,7 @@ check(f"a Controller built with peak_cap_w ABSENT takes the shipped "
c.peak.cap_w == CAP_W) c.peak.cap_w == CAP_W)
check("...and before the first cycle the guard has NO decision, which the " 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) "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: " print(" status row before the first cycle: "
+ (pre[0]["text"] if pre else "(none)")) + (pre[0]["text"] if pre else "(none)"))
check("the status page says so in its own row", 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"}) {"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 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()) 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 " print(f" meter_source off: guard={c_off.peak_guard}, wrote "
f"{nth(hass_off.writes, -1):+.0f} W") f"{nth(hass_off.writes, -1):+.0f} W")
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)")) 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 " 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 "legacy path can still charge", c_off.peak_guard is None
and hass_off.writes and hass_off.writes[-1] < 0) and hass_off.writes and hass_off.writes[-1] < 0)
check("...and the status page says NOT COVERED rather than 'idle' or a green " check("...and the status page says the guard is off, and why, rather than "
"row, which is the same answer SAFETY-18 already gives on a transport " "'idle' or a green row - the same answer SAFETY-18 already gives on a "
"that cannot see it", len(off_rows) == 1 "transport that cannot see it", len(off_rows) == 1
and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"]) 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. # ⚠️ 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 # `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 "")) 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", 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)) 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)")) print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged", check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"]) 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 " 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 " "identical cards is how an operator learns to dismiss the one that "
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1) "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)")) 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 - " 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", "the money is going to be spent and a human is the only actuator left",
len(rows24) == 1 and not rows24[0]["ok"] len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
and "FLOOR-LIMITED" in rows24[0]["text"]) and "peak limit will be passed" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
for e in c24.events)) for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop # 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. # comment is a scope sentence the operator never sees.
c25, hass25, NET25, _ = wired_over_limit( c25, hass25, NET25, _ = wired_over_limit(
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0) 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)")) 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 " 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. " "guard only and that self-consumption may keep discharging below it. "
"Without that sentence the page implies a pack-wide reserve at the exact " "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", "moment the replay above says the loop is still emptying the pack",
len(rows25) == 1 len(rows25) == 1
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"] and "at its peak reserve" in rows25[0]["text"]
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper() and _row_scope(rows25[0]["text"]))
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload() # ⚠️ 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 # 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 # 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 # 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 # this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
# that is going over the ceiling. # that is going over the ceiling.
_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0] # UX-08: the banner is the notification's own title, and the row below keeps
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - " # the SAFETY-25 sentence.
"under 90 characters of it - and NOT the row's whole paragraph repeated " check("the Not ready banner is a short headline - the notification's title - "
"at the top of the page. A headline that is a paragraph is a headline " "and NOT the row repeated at the top of the page. A headline that is a "
"nobody reads, and this one is followed three lines later by the same " "paragraph is a headline nobody reads",
"text verbatim", _pay25["level"] == "bad"
present(_expl25) and _pay25["banner"] == (f"{messages.NOT_READY} — "
and _pay25["level"] == "bad" f"{messages.T_PEAK_RESERVE}")
and _pay25["banner"].startswith("NOT READY") and not _row_scope(_pay25["banner"]))
and _expl25 in _pay25["banner"] check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
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 "
"sentence is still what the checks list carries, which is the surface the " "sentence is still what the checks list carries, which is the surface the "
"operator reads to find out what the floor does not bound", "operator reads to find out what the reserve does not bound",
len(rows25) == 1 len(rows25) == 1 and _row_scope(rows25[0]["text"]))
and len(rows25[0]["text"]) > 300
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
_body25 = hass25.last_message("goodwe_peak_soc_floor") _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. # main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db") _c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE _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)")) print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read " 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. " "off the shipped `checks()` rather than off `explain()` a second time. "
+80 -95
View File
@@ -69,7 +69,7 @@ import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
from app import mqtt, pv from app import messages, mqtt, pv
from named_absence import field, nth, present from named_absence import field, nth, present
from app.main import Controller from app.main import Controller
@@ -499,6 +499,11 @@ def rows_of(c):
return [r["text"] for r in c.checks()] 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): def page_rows(c):
return c.status_payload()["rows"] 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 " "is unset, because an unset forecast leaves a money guard without an "
"input; this is instrumentation and a permanent caution on every install " "input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids", "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"]) 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 " check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1) "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}") print(f" {_pv_row}")
# habit 14: the ordered pair - value, entity and unit each in its OWN slot in # UX-08: the row says the watts, and no entity id (STYLE.md). The unit it was
# the literal sentence, so a transposition is visible rather than merely # read in is DEBUG detail; a refused unit still gets its own caution below.
# present. check("a good read says the watts, as a green row, and names no entity id",
check("a good read says the watts, the entity it came from and the unit it was " _pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
"reported in, each in its own slot", and "sensor." not in _pv_row[0]
_pv_row == ["PV production: 3210 W from sensor.pv_power " and pv_rows(c_on)[0]["ok"] is True
"(reported in W)"]
and ("PV", "3 210 W") in page_rows(c_on)) and ("PV", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_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]}") print(f" {[r['text'] for r in _bad_row]}")
check("a refused read says WHICH refusal, and names the entity - 'no reading' " check("a refused read says WHICH refusal - 'no reading' on its own sends an "
"on its own sends an operator to look at a working sensor", "operator to look at a working sensor",
len(_bad_row) == 1 len(_bad_row) == 1
and _bad_row[0]["text"] == ("PV production: no reading from " and _bad_row[0]["text"] == ("Solar production: no reading, the sensor's "
"sensor.pv_power - unit '%' is not one of " "unit is not a power unit"))
"mW, W, kW, MW, GW, TW - it cannot be read " check("...and every refusal pv.watts() can give has its own plain words",
"in watts")) 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 " 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 " "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 " "channel that could put a battery site into NOT READY would be "
"observability with a foot in the control path", "observability with a foot in the control path",
len(_bad_row) == 1 len(_bad_row) == 1
and _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]} and _bad_row[0]["ok"] is False and _bad_row[0]["warn"] is True
and not c_bad.status_payload()["banner"].startswith("NOT READY") and not c_bad.status_payload()["banner"].startswith(messages.NOT_READY)
and c_bad.status_payload()["level"] != "bad" and c_bad.status_payload()["level"] != "bad"
and c_bad.status_payload()["banner"] and c_bad.status_payload()["banner"]
== c_off.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") 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) _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}") print(f" {_many_row}")
# habit 14 at a roster: the total in its own slot and EVERY contributor in its # UX-08: the row carries the site total and the number of sources; the
# own slot with its own watts and its own unit, so a sum that disagrees with the # per-source figures are DOCS/DEBUG detail (DECISIONS/UX-08-short-notifications).
# addends printed beside it is visible in the sentence. check("a healthy roster says the SITE total and how many sources it came from",
check("a healthy roster says the SITE total, how many entities it came from, " _many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
"and then every entity with its own watts and its own unit - so a total " f"sources"]
"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)"]
and ("PV", "3 750 W") in page_rows(_c_many)) and ("PV", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_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]}") print(f" {[r['text'] for r in _gap_row]}")
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the " check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, and names "
"entity that is missing and why - AND names what the others reported. The " "the missing source by its place in the list - put where a HUMAN reading "
"partial information is not thrown away, it is put where a HUMAN reading "
"the page live will see it, and kept out of the number a recorder hands " "the page live will see it, and kept out of the number a recorder hands "
"back cold a week later", "back cold a week later",
len(_gap_row) == 1 len(_gap_row) == 1
and _gap_row[0]["text"] == ( and _gap_row[0]["text"] == ("Solar production: no total, source 2 of 3 "
"PV production: NO SITE TOTAL - only 2 of 3 entities are reporting " "has no reading"))
"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"))
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at " 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 " "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 " "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 " "site into NOT READY would be observability with a foot in the control "
"path", "path",
len(_gap_row) == 1 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()["level"] != "bad"
and _c_gap.status_payload()["banner"] and _c_gap.status_payload()["banner"]
== c_off.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]) pv_entities=[E1])
print(f" one entity via pv_entities : " print(f" one entity via pv_entities : "
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}") 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 " check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
"sentence, the page row and the published value are the same shape " "sentence, the page row and the published value are the same through "
"through `pv_entity` and through a one-member `pv_entities`, and the " "`pv_entity` and through a one-member `pv_entities` - no `from 1 sources`",
"sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`", [r["text"] for r in pv_rows(_c_one_new)]
[t.replace(E1, "sensor.pv_power") == [r["text"] for r in pv_rows(c_on)]
for t in rows_of(_c_one_new) if "PV" in t] == [f"Solar production: {messages.signed_watts(3210)}"]
== [t for t in rows_of(c_on) if "PV" in t]
== ["PV production: 3210 W from sensor.pv_power (reported in W)"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)] and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W")) .index("PV")] == ("PV", "3 210 W"))
check("...and a refused read at a roster of one is 0.11.0's refusal sentence " check("...and a refused read at a roster of one is the one-sensor refusal "
"too, not the roster one - `no reading from <entity> - <reason>`", "too, not the roster one - `no reading, <reason>`",
[r["text"] for r in run(cycles=1, pv_entities=[E1], [r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0].checks() by_entity={E1: [state("1234", "%")]})[0])]
if r["text"].startswith("PV production")] == [r["text"] for r in _bad_row])
== [f"PV production: no reading from {E1} - unit '%' is not one of "
"mW, W, kW, MW, GW, TW - it cannot be read in watts"])
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT. # ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY)) _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]}") print(f" {[r['text'] for r in _ign]}")
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many " check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"entities are summed instead. `entities()` REPLACES rather than unions - " "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 " "key still names - but a replacement nobody is told about is the 'one of "
"which silently wins' this config refuses elsewhere", "which silently wins' this config refuses elsewhere",
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True 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 " and "clear the single sensor setting" in _ign[0]["text"]
"`pv_entity` is IGNORED - the 3 entities in the "
"list are what is summed. Clear `pv_entity` to "
"remove this caution")
and _c_both.pv_w == 3750.0) and _c_both.pv_w == 3750.0)
check("...and that caution appears ONLY when both are set - not on the 0.11.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 " "configuration, not on a list-only one, and not on an install with "
"neither", "neither",
not [r for r in c_on.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 "IGNORED" in r["text"]] and not [r for r in _c_many.checks() if r["text"] == _IGN]
and not [r for r in c_off.checks() if "IGNORED" in r["text"]]) 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 " 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", "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 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 len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == [] 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", "carries the corrected site total",
_c_mix.pv_w == MIX_TOTAL _c_mix.pv_w == MIX_TOTAL
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False)) 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}") print(f" {_mix_row}")
check("the status row prints each entity's watts AFTER the sign and marks " check("the status row prints the total AFTER the sign",
"ONLY the flipped one", _mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
_mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, " f"from 3 sources"])
f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
check("...and the page's PV row is the corrected total", check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix)) ("PV", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP) _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", check("a holed roster with a flipped member names the missing source, not "
[r["text"] for r in _row(_c_hole_f, "Reporting:")] "the flipped one",
and f"{E2} 2100 W [sign inverted]" in [r["text"] for r in _row(_c_hole_f, "no total")]
(_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", "")) == ["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. # The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power" _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 - " "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", "and NO stray caution, because a lone `pv_entity` is in the roster",
_c_own.pv_w == -13.0 _c_own.pv_w == -13.0
and [r["text"] for r in _row(_c_own, "PV production: -13 W")] and [r["text"] for r in pv_rows(_c_own)]
== [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"] == ["Solar production: -13 W, sign reversed"]
and _row(_c_own, "probably reversed") == [] and _row(_c_own, "reversed-sign list") == [])
and _row(_c_own, "flips nothing") == []) check("...and WITHOUT the option it is the plain row",
check("...and WITHOUT the option it is 0.12.0's row, byte for byte", [r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 W"])
[r["text"] for r in _row(_c_own0, "PV production:")]
== [f"PV production: 13 W from {_OWN} (reported in W)"])
# Whitespace: the roster entry and the invert entry differ only by padding. Both # 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 # 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, " "`pv_invert_entities` are the same entity - flipped on the read path, "
"marked on the page, and no stray caution", "marked on the page, and no stray caution",
_c_pad.pv_w == MIX_TOTAL _c_pad.pv_w == MIX_TOTAL
and f"{E2} 2100 W in kW [sign inverted]" and _row(_c_pad, "reversed-sign list") == [])
in (_at(_row(_c_pad, "W from 3 entities"), 0) or {}).get("text", "")
and _row(_c_pad, "flips nothing") == [])
# --- the negative caution ------------------------------------------------------- # --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()): 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")] c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
for e, w in watts_by_entity.items()}, for e, w in watts_by_entity.items()},
**opts) **opts)
return _row(c, "probably reversed") return _row(c, "reads ")
_n_add = _neg({E1: 1200, E2: -1500, E3: 450}) _n_add = _neg({E1: 1200, E2: -1500, E3: 450})
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2]) _n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
print(f" {[r['text'] for r in _n_add + _n_rm]}") 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 " check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
"watts, and says to ADD it to `pv_invert_entities`", "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] [r["text"] for r in _n_add]
== [f"PV production: {E2} reads -1500 W - far below zero, so its sign " == ["Solar production: source 2 reads -1 500 W, so add it to the "
"is probably reversed. If it is producing now, ADD it to " "reversed-sign list"])
"`pv_invert_entities` in the Configuration tab"])
check("...and a FLIPPED one far below zero is told to REMOVE it instead", 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", "")) in (_at(_n_rm, 0) or {}).get("text", ""))
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot " check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
"put a battery site into NOT READY", "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 " 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)", "caution (a typo would otherwise leave the sign silently unflipped)",
[(r["text"], r.get("ok"), r.get("warn")) [(r["text"], r.get("ok"), r.get("warn"))
for r in _row(_c_typo, "flips nothing")] for r in _row(_c_typo, "not a solar sensor in use")]
== [("PV production: `pv_invert_entities` names sensor.pv_wset, which " == [("Solar production: entry 1 of the reversed-sign list is not a "
"is not a PV entity in use, so it flips nothing - check the " "solar sensor in use", False, True)]
"spelling against `pv_entities`", False, True)] and _row(_c_mix, "not a solar sensor in use") == [])
and _row(_c_mix, "flips nothing") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------ # --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY) _c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
+1 -1
View File
@@ -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 " "row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it", "rather than restating the property that produces it",
bool(peak_rows) 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 " 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", f"on a {given(SHIPPED_CAP_W):.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"]) bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
+25 -20
View File
@@ -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", check("and it got there through the failsafe claim, not by accident",
"failsafe" in ctrl.reason) "failsafe" in ctrl.reason)
print(f" status line: {ctrl.reason!r}") print(f" status line: {ctrl.reason!r}")
check("the status page says so in words", check("the status page says so in words: a red row, at 0 W, cleared by start",
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks())) 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", 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", check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1) 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", check("...and never commanded anything else after the restart",
hass2.writes != [] and set(hass2.writes) == {0.0}) hass2.writes != [] and set(hass2.writes) == {0.0})
check("the banner still leads with the failsafe", 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 # ⚠️ 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 # 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 # 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) "circulation" in _c.reason)
print(f" status line: {_c.reason!r}") print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it", 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"] any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
for ck in _c.checks())) 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 " 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 " "disagreement is visible on every dashboard in the house and this is "
"visible nowhere at all", "visible nowhere at all",
"CIRCULATING POWER" in _c.status_payload()["banner"]) _c.status_payload()["banner"] == messages.banner_circulating())
_pub = {} _pub = {}
_c.mqtt = type("P", (), {"enabled": True, _c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v), "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 " 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 " "that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing", "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())) for ck in _c1.checks()))
_pub1 = {} _pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True, _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 " 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 - " "site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box", "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()) 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 = {} _pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True, _c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v), "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 == [] _n1_site_calls == []
and not _c1s.site_sign_wd.tripped and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None) and _c1s.site_sign_wd.disagree_since is None)
check("...the banner is the sentence that shipped", check("...the banner is the one-inverter sentence, naming the meter",
_c1s.status_payload()["banner"] _c1s.status_payload()["banner"] == messages.banner_direction(False)
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.") and "meter" in messages.banner_direction(False))
check("...the status page carries ONE sign row, with no unit tag and no site " 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 " "row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one", "advertising two guards where the install has one",
len([ck for ck in _c1s.checks() len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"] and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
]) == 1 and not any("inverter-" in ck["text"] for ck in _c1s.checks()
and not any("site sign" in ck["text"].lower() 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 " check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped", "arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason) "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", "the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad" _bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"] and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"].startswith("FAILSAFE") and _bn_b["banner"] == messages.banner_direction(True)
and "its own measured power" in _bn_b["banner"]) and "own power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched " check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is " "carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test", "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"]) and _bn_n["banner"] != _bn_b["banner"])
+3 -35
View File
@@ -1,16 +1,10 @@
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that # UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start". # does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
# These are waiting for phase 2b (row, banner), 2c (refusal, option-name, # These are waiting for phase 2c (refusal, option-name, option-desc) and 2d
# option-desc) and 2d (log). Delete the line when you rewrite the string, because # (log); phase 2b emptied the row and banner kinds. Delete the line when you
# a stale line fails the test. Never add a line for a new string: write the new # 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. # 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 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 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 ' 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 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 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 ' 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'