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