From 7aa1bd052ed48c9e3a217e2c3a73a8a757024cf3 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 30 Aug 2026 02:39:54 +0200 Subject: [PATCH 1/2] SAFETY-18: a meter frozen at a wrong-but-fresh value now raises an alarm A meter that answers 200 OK with a stale document forever is ARRIVING on every poll, so every freshness mechanism in the stack reads it as healthy while the loop integrates a constant error and rails: 360/135/75 s at the live install's max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW unattended import sets the month's billed capaciteitstarief peak. The detector is a conjunction, not the bare unchanged_s threshold TEL-05 measured and rejected: unchanged_s >= meter_freeze_s AND |command| within 5 % of max_w Both terms already existed and were already on the status page, and they fail for independent reasons: at the converged point the command is nowhere near the rail, and at the rail against a real load the kWh registers move in seconds. At the shipped 300 s the relation is structural rather than a margin - the slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which is inside the 15 W deadband, so the command there is parked at 0 W. Notification only, deliberately: an HA persistent notification with a fixed notification_id, an ERROR log line, a UI event and a red status row. No claim, no latch, no 0 W. A false trip parks the battery; a false notification costs an alert. Only meter_source: homewizard_local is covered - the status page says NOT COVERED elsewhere rather than pretending the guard is armed. DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps maintenance charging and nothing caps the loop. That cap is SAFETY-07. test_freeze.py grades both directions and was verified by breaking what it watches (six mutations, all red): silent over the committed 40-minute converged capture, silent over an hour of TEL-05's low-power case where the rejected bare threshold is shown to fire, alarming on a genuinely frozen meter driven over real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze fault when reachable), silent on the same railed command against a meter that is still moving, and asserting on the service call that leaves Controller.cycle() rather than on the watchdog's own flag. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC --- goodwe_controller/CHANGELOG.md | 53 ++ goodwe_controller/DOCS.md | 93 ++++ goodwe_controller/app/freeze.py | 261 +++++++++ goodwe_controller/app/main.py | 87 +++ goodwe_controller/config.yaml | 31 +- .../scenarios/p1-age-2026-08-26.jsonl.gz | Bin 0 -> 26988 bytes goodwe_controller/test_freeze.py | 516 ++++++++++++++++++ 7 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 goodwe_controller/app/freeze.py create mode 100644 goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz create mode 100644 goodwe_controller/test_freeze.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index dfba0ff..553174e 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## 0.5.2 + +**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not +stopped.** (SAFETY-18) + +A meter that answers `200 OK` with a stale document forever is *arriving* on +every poll, so every freshness mechanism in the stack reads it as healthy: +`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the +heartbeat and by a sample age that never climbs, and the arbiter contributes only +±`max_w`. The loop integrates a constant error and rails — replaying the real +`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at +350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs +`max_w: 5000`**, which is both the schema maximum and the device rating. + +The new detector (`app/freeze.py`) is a **conjunction**, not the bare +`unchanged_s` threshold TEL-05 measured and rejected: + + unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w + +Both terms already existed and were already on the status page. They fail for +independent reasons: at the converged point the command is nowhere near the rail, +and at the rail against a real load the kWh registers move within seconds. At the +shipped 300 s there is also a structural relation, not just a margin — the +slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which +is inside the 15 W deadband, so the command there is parked at 0 W and the two +halves cannot both be true. + +**Notification only, deliberately.** An HA persistent notification (fixed +`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line, +a UI event and a red status banner. No claim, no latch, no 0 W: a false trip +parks the battery and the operator's next move is to switch the guard off; a +false notification costs an alert. The trip is a separate decision that needs +data from a real install. + +- New option `meter_freeze_s` (default 300, range 60–3600, no "off"). +- **Only `meter_source: homewizard_local` is covered** — it is the only transport + that sees the meter's own document. Everywhere else the status page says + **NOT COVERED** rather than "armed". +- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close: + `peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying + meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the + month's billed peak (measured: one 4998 W quarter moved a monthly peak from + 4832 → 5013 W). That cap is SAFETY-07. +- `test_freeze.py` grades it in both directions: silent over 40 minutes of the + live house's real converged capture (committed as + `scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's + low-power case where a bare threshold is shown to fire, and alarming on a + genuinely frozen meter driven over real HTTP through the real + `HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the + infra repo is reachable, an equivalent stub otherwise. Its negative twin is the + load-bearing one: the same railed command against a meter that is still moving + must stay silent at the same threshold. + ## 0.5.1 **SAFETY-02's latch now survives an add-on restart, which is the difference diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index f6b53db..4109efb 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -30,6 +30,10 @@ The inverter **holds its last command forever** — it has no meter-timeout. So: - The **optional RS485 e-stop** is the only thing that covers this machine dying. Without it, a failed host leaves the battery latched at its last command until someone intervenes. +- This add-on **raises an alarm — and nothing more —** when the meter keeps + answering with a reading that never changes while the command sits at the + rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff + exposure of the grid-following loop". **If anything looks wrong: stop the add-on.** That commands 0 W and the hardware holds it there. @@ -433,6 +437,95 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp is dropped so the battery can shave the peak instead. Money outranks the maintenance schedule. +#### Capacity-tariff exposure of the grid-following loop + +⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The +grid-following loop is bounded by `max_w` and by the device rating, and by +nothing else. That is deliberate — the loop exists to *reduce* what crosses the +meter, so under every healthy condition it lowers the peak rather than raising +it — but it means one thing is true and worth writing down: + +**If the meter lies, the loop will happily buy `max_w` from the grid for as long +as the lie lasts, and no cap in this add-on stops it.** A meter frozen at a large +export reads as "the house is exporting hard", the loop charges the battery to +correct it, and the correction is a grid import. At the schema maximum +`max_w: 5000` — which is what this house runs, and is also the device rating — +that is a 5 kW unattended import. + +The bill is not the energy. In the Belgian capaciteitstarief the month is billed +on its highest quarter-hour average, so **fifteen minutes of this sets the whole +month's peak.** A measured case: one 4998 W quarter moved a monthly peak from +4832 W to 5013 W, and every day of that month was billed at the higher figure. + +What exists today, and what does not: + +| | | +|---|---| +| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude | +| Maintenance charging capped under `peak_cap_w` | ✅ | +| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 | +| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** | +| Frozen meter *stopped* | ❌ — the alarm does not command anything | +| SoC guard on the loop | ❌ — the BMS is the only energy backstop | + +So the frozen-meter alarm below **narrows the window; it does not close it.** On +a capacity-tariff site where an unattended runaway is unacceptable, the mitigation +today is 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. + +### The frozen-meter alarm + +A meter that has died is easy — it stops answering, the sample age climbs past +`meter_max_age_s`, the reading becomes missing, and the add-on and the ESP32 both +command 0 W. A meter that keeps answering `200 OK` with a document that never +changes is the hard one: **every freshness mechanism in the stack reads it as +healthy**, because it really is arriving on every poll. The loop then integrates +a constant error and winds to the rail, in 75–360 s at `max_w: 5000`. + +The add-on alarms when **both** of these are true at once: + +- the meter's own document (power figure *and* both kWh registers) has not + changed for `meter_freeze_s` — 300 s by default; **and** +- the command standing on the wire is at the rail, within 5 % of `max_w`. + +Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips +at the exact operating point this controller aims for — at a converged −10 W a +1 Wh register legitimately takes about six minutes to move — and a bare "at the +rail" rule fires on any genuinely large load. Together they are safe in both +directions, because a real railed condition means kilowatts still crossing the +meter, and a kilowatt advances a 1 Wh register every 3.6 s. + +**It is an alarm and nothing else.** It does not command 0 W, does not latch, and +does not claim the actuator. A false trip here parks the battery; a false +notification costs an alert. Turning it into a trip is a separate decision that +needs data from a real install — and an answer to "what should the loop do +instead", given that 0 W is itself an import of whatever the house is drawing. + +When it fires you get a Home Assistant persistent notification +(`goodwe_meter_frozen`, replaced rather than stacked on a repeat), an `ERROR` in +the add-on log, an entry in the UI's event list, and a red banner on the status +page. **The loop keeps running.** If the meter really is stuck, stop control from +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. + +| option | default | meaning | +|---|---|---| +| `meter_freeze_s` | 300 | Seconds of an unchanged measurement, *while the command is at the rail*, before the alarm | + +Why 300 s: over 2399 s of this house at the converged operating point (mean grid +−14.2 W, 1194 polls, 735 of them repeating the previous reading) the largest +`unchanged_s` any sample sees is **8.0 s**, and the longest interval serving no +distinct reading at all is **15.4 s**. There is also a structural reason not to +lower it much: at 300 s the slowest a *healthy* register can tick and still look +unchanged is 12 W, which is inside the 15 W deadband — so the command there is +parked at 0 W and the two halves of the rule cannot both be true. At the schema +floor of 60 s that figure is 60 W and only the margin is left. + ### Site | option | meaning | diff --git a/goodwe_controller/app/freeze.py b/goodwe_controller/app/freeze.py new file mode 100644 index 0000000..b4096cc --- /dev/null +++ b/goodwe_controller/app/freeze.py @@ -0,0 +1,261 @@ +"""SAFETY-18 - the frozen-but-fresh meter detector. + +WHAT IT CATCHES, in one sentence: a meter that keeps answering 200 OK with a +document that never changes, which every freshness mechanism in this stack reads +as perfectly healthy while the loop winds to the rail and stays there. + +THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is +*arriving* on every poll, so: + + - `meter_max_age_s` is structurally blind. Every response resets the arrival + stamp (p1.py, `HomeWizardLocalSource`), so the age never climbs. Age catches + a meter that stops TALKING; this one stops CHANGING. + - firmware layer 1 is fed. Its watchdog keys on setpoint staleness, which the + heartbeat satisfies, and on `sensor.goodwe_p1_sample_age`, which a talking + meter satisfies. + - the arbiter contributes only the device rating and +-`max_w` - it bounds the + magnitude, which is the number this fault drives us to, not away from. + - there is no SoC guard on the grid-following loop at all. `soc_floor` belongs + to the maintenance cycle; the BMS is the sole energy backstop. + +The loop reads a constant error and integrates it. Time from freeze to the rail, +replaying the real `control.compute()` at 15 s cycles: + + meter frozen at to rail, max_w 2000 to rail, max_w 5000 + 350 W 150 s 360 s + 1000 W 60 s 135 s + >= 3000 W 30 s 75 s + +⚠️ THE LIVE INSTALL RUNS `max_w: 5000`, which is both the schema maximum and the +device rating, so the right-hand column is the one that describes this house. + +------------------------------------------------------------------------------ +THIS HAS A EURO FIGURE, AND IT IS THE REASON THE TICKET IS P1 + +`maintenance_charge_floor()` caps MAINTENANCE charging against `peak_cap_w`. The +grid-following loop is not capped by anything but `max_w`. A meter frozen +reading a large export drives a 5 kW unattended grid IMPORT, and >= 15 minutes +of that sets the month's billed capaciteitstarief peak: specs.md:583 records a +measured case where a single 4998 W quarter moved a monthly peak from 4832 W to +5013 W and billed the whole month at it. + +⚠️ THIS CUT DOES NOT CAP THAT. It notifies. See DOCS.md, "Capacity-tariff +exposure of the grid-following loop", and SAFETY-07. + +------------------------------------------------------------------------------ +WHY IT IS A CONJUNCTION AND NOT AN `unchanged_s` THRESHOLD + +`unchanged_s` (p1.py) already measures exactly the right thing: seconds since +the meter served a DIFFERENT measurement, fingerprinted over the power figure +and both energy registers. It is computed, it is on the status page, and TEL-05 +deliberately refused to threshold it - correctly: + + this controller regulates grid power toward ~0 W, and at a converged -10 W + the export register takes about six minutes to advance by its 1 Wh + resolution while the power figure legitimately repeats. + +So a bare `unchanged_s > N` false-trips at the exact operating point we aim for, +and `p1.py` says as much in a note that ends "whoever wires a freeze detector +must handle the low-power case first". This is that handling, and it is not a +smaller number: + + unchanged_s >= hold_s AND |commanded_w| is at the rail + +The second term is what TEL-05's case cannot satisfy. At the converged point the +command is nowhere near +-`max_w`; on the 40 minutes of live converged behaviour +in `sim/scenarios/p1-age-2026-08-26.jsonl.gz` the meter never once leaves ++-500 W, so the loop never even approaches a 5000 W rail. And the reverse case - +a command genuinely pinned at the rail by a real 8 kW load - cannot satisfy the +FIRST term, because a real railed condition means kilowatts still crossing the +meter, and a kilowatt advances a 1 Wh register every 3.6 s. + +The two terms fail for independent reasons. That is the whole design. + +------------------------------------------------------------------------------ +WHERE `hold_s` COMES FROM + +Measured on `sim/scenarios/p1-age-2026-08-26.jsonl.gz` - 2399 s (40 min) of the +live house at the converged operating point, mean grid -14.2 W, 1194 polls: + + polls that repeated the previous reading 735 of 1193 (61.6 %) + distinct-reading transitions 458 + median gap between distinct readings 6.0 s + p95 gap 6.0 s + LARGEST `unchanged_s` any sample observes 8.0 s + longest interval serving no distinct reading 15.4 s + samples outside +-500 W 0 + +The default 300 s is 37x the largest `unchanged_s` this house has been recorded +producing while converged, and 19.5x the longest interval in which it served no +distinct reading at all. The two figures differ because the capture has an 11.4 s +hole in its OWN polling; the second is quoted because it is the pessimistic one. +300 s is not fitted to either margin - it is picked as "clearly past any +plausible register-tick interval, and clearly inside the 900 s quarter that bills +the capacity tariff". test_freeze.py re-measures both every run, so this +paragraph cannot quietly stop being true. + +⚠️ BE PRECISE ABOUT WHAT THAT CAPTURE PROVES. It is POWER-ONLY - the shipped +fingerprint also covers both kWh registers, so a real `unchanged_s` on that data +can only be shorter, never longer, which makes it a conservative measurement of +the converged case. But it does NOT on its own refute a bare `unchanged_s` +threshold, and saying it did would be exactly the overstatement this project +keeps having to walk back: at 8 s worst, a bare 300 s threshold is silent on it +too. The case that refutes the bare threshold is the one below, which no capture +of THIS meter contains because this meter's power figure jitters. + +The floor on the other side: at the rail, the smallest residual grid flow that +still ticks a 1 Wh register inside 300 s is 12 W. Any condition that genuinely +pins this loop at +-5000 W has three orders of magnitude more than that crossing +the meter. + +⚠️ AND THOSE TWO NUMBERS DO NOT MERELY HAVE MARGIN - THEY CANNOT BOTH BE TRUE. +The worry the conjunction has to answer is "a HEALTHY meter whose register is +slower than `hold_s`". At 300 s that is |grid| < 12 W. The shipped `deadband_w` +is 15 W, so anywhere the register is that slow the control law is already inside +its deadband and the command is parked - the rail term is not merely unlikely to +be true there, it is 0 W. The two terms are mutually exclusive by construction +at the defaults, not just far apart, and the check re-derives that every run. + +That relationship is the one to protect. `hold_s` may be lowered only while +3600/hold_s stays under `deadband_w`; at the schema floor of 60 s that is 60 W +against a 15 W deadband, and the mutual exclusion is gone. A 60 s `meter_freeze_s` +is a supported configuration that gives up this argument and keeps only the +margin - do not set one without a capture that says the margin is enough. + +⚠️ WHAT `hold_s` DOES NOT BUY IS DETECTION SPEED. The alert lands at +max(hold_s, time-to-rail) after the freeze, so at the shipped default and +`max_w: 5000` that is 300-360 s. The rail is REACHED first, from 75 s. A +notification is not a brake; see the DOCS.md note. + +------------------------------------------------------------------------------ +THE RAIL TERM, PRECISELY + + abs(commanded_w) >= RAIL_FRAC * max_w + +`commanded_w` is the EMITTED setpoint - what the arbiter resolved and +`write_setpoint()` put on the wire - not the loop's raw wish, for the same +reason SAFETY-02 uses the emitted value: the wire carries the emitted number. + +RAIL_FRAC is 0.95 and is a constant rather than an option. The command is +quantised to `step_w` (max 100) and clamped to `max_w` (min 100), so at the rail +the emitted value IS `max_w` on every schema-legal combination; the 5 % is slack +for a device rating that clips `max_w` slightly in `write_setpoint()`, not a +tuning knob. Making it configurable would offer a way to widen the one term that +carries the whole no-false-positive argument. + +⚠️ NOT GATED ON `following`, and that is deliberate. A maintenance charge +commands against the meter on purpose and can sit at the rail for hours - but it +does so while pushing kilowatts through the meter, so the FIRST term is what +excludes it, and it excludes it on physics rather than on bookkeeping. Gating on +`following` as well would additionally blind the detector to a maintenance phase +running against a meter that really has frozen, which is the same fault with a +worse actuator behind it. + +------------------------------------------------------------------------------ +NOTIFY, DO NOT TRIP + +First cut is an alarm and nothing else: an HA persistent notification, an ERROR +in the log, an event on the status page, and a failed check row. It does NOT +claim the actuator, does NOT file a P_SAFETY claim and does NOT latch. + +That is a deliberate ordering of costs, not timidity. A false trip here parks +the battery - on a house that is running this loop precisely to avoid buying +power at the wrong moment - and the operator's response to a parked battery is +to switch the guard off. A false notification costs one alert. The trip is a +separate decision that has to be earned with observed data from a real install, +and it needs an answer to a question this cut does not have one for: what should +the loop do instead, given that 0 W is itself a grid import of whatever the +house is drawing. + +⚠️ TRANSPORT-CONDITIONAL. `unchanged_s` exists ONLY on `homewizard_local`, +because it is the only transport that sees the meter's own document. Under +`ha_dsmr`, `mqtt_p1`, `ha_signed` and `meter_source: off` this detector is +silent and the exposure above is uncovered. The caller passes None and gets +False; there is no half-measure available on those paths, and pretending +otherwise would be worse than the gap. +""" + +from dataclasses import dataclass +from datetime import datetime + +# See "THE RAIL TERM, PRECISELY" above. A constant, on purpose. +RAIL_FRAC = 0.95 + + +@dataclass(frozen=True) +class FreezeWatchdogConfig: + """Default, not a constant - config.yaml carries `meter_freeze_s`.""" + + hold_s: float = 300.0 + + +def rail_w(max_w) -> float: + """The magnitude at or above which a command counts as railed.""" + return abs(float(max_w)) * RAIL_FRAC + + +def looks_frozen(unchanged_s, commanded_w, max_w, + cfg: FreezeWatchdogConfig) -> bool: + """The predicate, and the ONLY place the conjunction is written down. + + Public for the same reason `watchdog.disagrees()` is: the checks that grade + it must assert on THE SHIPPED EXPRESSION. A safety predicate transcribed + into its own test is one copy plus a future disagreement about what the + check means, and the copy that drifts is always the one doing the grading. + + `unchanged_s` seconds since the meter served a different measurement, or + None on a transport that cannot know (see the module note). + `commanded_w` the EMITTED setpoint standing on the wire. + `max_w` the configured output bound the rail is measured against. + """ + if unchanged_s is None or commanded_w is None or not max_w: + return False + return (float(unchanged_s) >= cfg.hold_s + and abs(float(commanded_w)) >= rail_w(max_w)) + + +class FreezeWatchdog: + """One instance per controller, evaluated once per control cycle. + + Level-triggered state with an EDGE for the alarm: `evaluate()` returns True + on the cycle the conjunction first becomes true and False on every cycle + after, so the caller raises one notification per episode rather than one + every second. `active` is the level, for the status page. + + No latch, deliberately - see "NOTIFY, DO NOT TRIP". A latch exists so a trip + cannot erase its own evidence by working; nothing here acts, so there is + nothing to erase. When the meter starts moving again the alert clears + itself, which is the honest report of the state. + """ + + def __init__(self, cfg: FreezeWatchdogConfig | None = None): + self.cfg = cfg or FreezeWatchdogConfig() + self.active = False + self.since: datetime | None = None + self.reason = "" + self.episodes = 0 + + def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime) -> bool: + """Advance one cycle. Returns True ONLY on the rising edge. + + `now` is the caller's clock so this stays testable and replayable - the + same discipline as `SignWatchdog.evaluate()`. + """ + if not looks_frozen(unchanged_s, commanded_w, max_w, self.cfg): + self.active = False + self.since = None + self.reason = "" + return False + + self.reason = ( + f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s " + f"without its measurement changing while the command stands at " + f"{float(commanded_w):+.0f} W, at the {float(max_w):.0f} W rail " + f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over " + f"{rail_w(max_w):.0f} W)") + if self.active: + return False + self.active = True + self.since = now + self.episodes += 1 + return True diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index a44eb43..7d6f1ab 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -36,6 +36,7 @@ from .arbiter import ( P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve, ) from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk +from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w from .hass import HomeAssistant from .maintenance import IDLE, MaintConfig, Maintenance from .mqtt import MqttPublisher @@ -127,6 +128,18 @@ class Controller: "once you know why the command opposed the meter.", self.sign_wd.reason or "(reason not recorded)") + # SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why + # it is a conjunction and not an `unchanged_s` threshold, and for the + # measurements behind the default. + # + # ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the + # asymmetry is the point: this one does not latch because it does not + # act. A restart re-derives its state from the meter within one + # `meter_freeze_s` window, and until then the honest report is "not + # alarming yet" rather than an alarm nobody can clear. + self.freeze_wd = FreezeWatchdog( + FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300)))) + # P1 ingestion (TEL-01). `meter_source: off` keeps the original # single-entity meter_entity path, so an existing install is unchanged # until it opts in. @@ -393,6 +406,53 @@ class Controller: self.reason = resolution.explain() await self.write_setpoint(self.target) + # --- SAFETY-18, and it runs LAST on purpose -------------------------- + # Both terms of the conjunction describe the state that now exists: the + # command that has just gone on the wire, and how long the meter that + # chose it has been standing still. Evaluated before write_setpoint() + # it would be reasoning about the PREVIOUS command against THIS cycle's + # meter - the one combination that is true of neither moment. + await self.check_meter_frozen() + + async def check_meter_frozen(self) -> None: + """SAFETY-18. Alarm on a meter that answers forever without changing. + + Notification only - no claim, no latch, no 0 W. freeze.py carries the + reasoning and the euro figure; DOCS.md carries the exposure this does + NOT close. + + ⚠️ getattr, not an attribute access: `unchanged_s` exists only on + `homewizard_local`, and `p1_source` is None until amain() builds the + transport. Every other meter_source hands the predicate None and gets + False, which is the honest answer - see the transport note in freeze.py. + """ + unchanged = getattr(self.p1_source, "unchanged_s", None) + fired = self.freeze_wd.evaluate(unchanged, self.target, + float(self.o.get("max_w", 2000)), + datetime.now(timezone.utc)) + if not fired: + return + self.log_event("METER FROZEN? " + self.freeze_wd.reason) + _LOG.error( + "SAFETY-18 - %s. The grid-following loop is regulating against a " + "number that has stopped moving, and nothing in this stack stops " + "it: the meter is still answering, so the age is fresh, the " + "firmware watchdog is fed and the arbiter only bounds the " + "magnitude. CHECK THE METER. Sustained for a quarter of an hour " + "this sets the month's billed capacity-tariff peak. This is an " + "ALARM ONLY - the loop is still running.", self.freeze_wd.reason) + # ⚠️ A fixed notification_id, so a second episode REPLACES the first + # rather than stacking. An operator who has to dismiss eleven identical + # notifications learns to dismiss the twelfth without reading it. + await self.hass.call("persistent_notification", "create", { + "notification_id": "goodwe_meter_frozen", + "title": "GoodWe: the P1 meter may be frozen", + "message": ( + f"{self.freeze_wd.reason}.\n\nThe controller is STILL RUNNING " + f"and still following this reading. Check the meter; if it is " + f"stuck, stop control from the add-on UI."), + }) + # -- tasks --------------------------------------------------------------- async def run_control(self) -> None: entity = self.o.get("setpoint_entity", "") @@ -581,6 +641,33 @@ class Controller: f"{self.p1.parse_errors} rejected" + (f", measurement unchanged for {unchanged:.0f} s" if unchanged is not None else "")}) + # SAFETY-18. Reported in both states, for the reason SAFETY-02 is: + # 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."}) + 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"}) + else: + 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_w(self.o.get('max_w', 2000)):.0f} W)"}) rows = [("battery SoC", self.soc, o.get("soc_entity")), ("battery power", self.batt, o.get("batt_entity"))] if not self.p1_enabled: diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index d9fc3d1..aa62b8e 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.5.1" +version: "0.5.2" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart @@ -114,6 +114,26 @@ options: sign_disagree_w: 300 sign_disagree_s: 180 + # --- SAFETY-18 frozen-but-fresh meter detector ----------------------------- + # Seconds the meter may keep answering with an UNCHANGED measurement while the + # command stands at the rail before the add-on raises an alarm. Both halves + # are required: neither one alone is safe, and app/freeze.py carries why. + # + # ALARM ONLY. It does not command 0 W and does not latch. A false trip parks + # the battery; a false notification costs an alert. + # + # ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the + # only transport that reads the meter's own document. On every other source + # the status page says NOT COVERED rather than "armed". + # + # 300 s is measured, not chosen. Over 2399 s of the live house at the + # converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean + # grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the + # LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any + # sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and + # still inside the 900 s quarter that bills the capacity tariff. + meter_freeze_s: 300 + # --- maintenance ----------------------------------------------------------- maintenance_enabled: false maintenance_interval_days: 28 @@ -181,6 +201,15 @@ schema: sign_disagree_w: int(50,5000) sign_disagree_s: int(30,1800) + # No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a + # supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s + # the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged + # is 12 W, which is inside the 15 W deadband, so the two terms of the + # conjunction are mutually exclusive rather than merely far apart. At 60 s that + # figure is 60 W and the argument is gone - only the margin is left. See + # app/freeze.py. + meter_freeze_s: int(60,3600) + maintenance_enabled: bool maintenance_interval_days: int(1,90) maintenance_start_hour: int(0,23) diff --git a/goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz b/goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz new file mode 100644 index 0000000000000000000000000000000000000000..1b583f64c56bcdba1fdbfb7b02172089928c655e GIT binary patch literal 26988 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 literal 0 HcmV?d00001 diff --git a/goodwe_controller/test_freeze.py b/goodwe_controller/test_freeze.py new file mode 100644 index 0000000..492f818 --- /dev/null +++ b/goodwe_controller/test_freeze.py @@ -0,0 +1,516 @@ +"""Runnable check for SAFETY-18, the frozen-but-fresh meter detector. + + python3 test_freeze.py + +Three parts, and the last two are the ones that matter: + + 1. the predicate's boundaries and the edge-trigger, table-driven; + 2. SILENT ON REAL CONVERGED DATA. `scenarios/p1-age-2026-08-26.jsonl.gz` is + 40 minutes of this house's live meter at the operating point, committed + here from the infra repo's `sim/scenarios/`. It is replayed through the + REAL `control.compute()` at the real cycle rule, and the detector must not + make a sound. This is the acceptance criterion TEL-05's objection is + about, and it is verified rather than argued; + 3. FIRES ON A GENUINELY FROZEN METER, over real HTTP, through the REAL + `HomeWizardLocalSource` - the transport is what computes `unchanged_s`, so + a check that calls the predicate directly would not be testing the thing + that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra + repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub + otherwise. Both are exercised; the run says which one it used. + +⚠️ Part 3 runs in WALL CLOCK - `unchanged_s` is `time.monotonic()` and the +transport really polls - so it drives the detector at `hold_s=10`, not the +shipped 300. The THRESHOLD is config; the MECHANISM is what is under test, and +the mechanism cannot be hurried. Part 2 uses the shipped 300 because that data +is replayed rather than lived. + +⚠️ Part 3 also runs its NEGATIVE twin, and that is the load-bearing half: the +same railed command against a meter that is still MOVING, at the same 10 s +threshold. If that one alarms, the detector is a `unchanged_s` threshold with +extra steps and the whole reason for the conjunction is gone. +""" + +import asyncio +import gzip +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import aiohttp + +from app.control import Tuning, compute +from app.freeze import ( + RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w, +) +from app.main import Controller +from app.p1 import HomeWizardLocalSource, P1Ingest + +fails = [] +T0 = datetime(2026, 8, 26, 7, 0, 0, tzinfo=timezone.utc) +HERE = Path(__file__).resolve().parent + +# The live install's numbers, which are the ones the exposure is written at. +LIVE_MAX_W = 5000.0 + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def tuning(max_w=LIVE_MAX_W): + """The shipped defaults from config.yaml, at the live install's max_w.""" + return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15, + target_grid_w=-10, step_w=10, saturation_w=500, + saturation_cycles=3) + + +# --------------------------------------------------------------------------- # +# 1. the predicate and the edge +# --------------------------------------------------------------------------- # +print("predicate") +cfg = FreezeWatchdogConfig(hold_s=300) + +check("both terms true -> frozen", looks_frozen(300, 5000, 5000, cfg)) +check("unchanged long, command NOT railed -> silent (TEL-05's case)", + not looks_frozen(3600, -40, 5000, cfg)) +check("command railed, meter still moving -> silent (a real 8 kW load)", + not looks_frozen(4, -5000, 5000, cfg)) +check("neither -> silent", not looks_frozen(4, -40, 5000, cfg)) +check("one second under the hold is still silent", + not looks_frozen(299.9, 5000, 5000, cfg)) +check("just under the rail fraction is still silent", + not looks_frozen(3600, 5000 * RAIL_FRAC - 1, 5000, cfg)) +check("at the rail fraction exactly, it fires", + looks_frozen(3600, 5000 * RAIL_FRAC, 5000, cfg)) +check("the rail scales with max_w, not with a fixed watt figure", + looks_frozen(3600, 2000, 2000, cfg) and not looks_frozen(3600, 2000, 5000, cfg)) +check("a charge rail counts too - the runaway can be either sign", + looks_frozen(3600, -5000, 5000, cfg)) +check("no transport signal (unchanged_s None) -> silent, never assumed frozen", + not looks_frozen(None, 5000, 5000, cfg)) +check("no command yet -> silent", not looks_frozen(3600, None, 5000, cfg)) +check("rail_w tracks RAIL_FRAC", rail_w(5000) == 5000 * RAIL_FRAC) + +print("the edge") +wd = FreezeWatchdog(cfg) +edges = [wd.evaluate(u, c, 5000, T0 + timedelta(seconds=i)) + for i, (u, c) in enumerate( + [(10, 5000), (300, 5000), (305, 5000), (310, 5000), + (2, 5000), (300, 5000)])] +check("rises exactly once per episode", edges == [False, True, False, False, + False, True]) +check("two episodes counted", wd.episodes == 2) +check("clearing the condition clears the alarm state", + not FreezeWatchdog(cfg).evaluate(2, 5000, 5000, T0)) +check("the reason names both terms", + "unchanged over 300" in wd.reason and "command over 4750" in wd.reason) + + +# --------------------------------------------------------------------------- # +# 2. silent on 40 minutes of real converged behaviour +# --------------------------------------------------------------------------- # +print("converged replay - scenarios/p1-age-2026-08-26.jsonl.gz") +rows = [json.loads(line) for line in + gzip.open(HERE / "scenarios" / "p1-age-2026-08-26.jsonl.gz", "rt")] +check("the capture loaded", len(rows) > 1000) + +# ⚠️ The capture records the POWER figure only. The shipped fingerprint also +# covers both kWh registers, so a real `unchanged_s` on this data can only be +# SHORTER than what is reconstructed here - this replay is the pessimistic +# version of the converged case, not a flattering one. +def replay(hold_s, max_w, cycle_floor_s=15.0): + """Drive the real control law and the real detector over the capture.""" + wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s)) + t = tuning(max_w) + target, i_w, sat = 0.0, 0.0, 0 + prev_pwr, changed_at = None, rows[0]["t"] + last_cycle = None + peak_cmd, peak_unchanged, fired_at = 0.0, 0.0, None + for r in rows: + now, pwr = r["t"], float(r["pwr"]) + if prev_pwr is None or pwr != prev_pwr: + changed_at = now + arrival = (prev_pwr is None or pwr != prev_pwr) + prev_pwr = pwr + # main.run_control()'s rule: a cycle per changed reading, or the floor. + if arrival or last_cycle is None or now - last_cycle >= cycle_floor_s: + last_cycle = now + d = compute(prev_w=target, grid_w=pwr, actual_w=target, tuning=t, + sat_count=sat, i_w=i_w) + target, i_w, sat = d.target_w, d.i_w, d.sat_count + # ⚠️ The detector is evaluated on EVERY poll, not only on cycles, and + # that is deliberately harsher than the shipped wiring (which evaluates + # it inside cycle()). On an arrival-triggered cycle `unchanged_s` is 0 + # by construction, so grading only there would be grading the detector + # on the one sample it cannot possibly fire on. + unchanged = now - changed_at + peak_cmd = max(peak_cmd, abs(target)) + peak_unchanged = max(peak_unchanged, unchanged) + if wd.evaluate(unchanged, target, max_w, + T0 + timedelta(seconds=now - rows[0]["t"])) \ + and fired_at is None: + fired_at = now - rows[0]["t"] + return wd, peak_cmd, peak_unchanged, fired_at + + +for mw in (2000.0, LIVE_MAX_W): + wd_r, peak_cmd, peak_unchanged, fired = replay(300.0, mw) + print(f" max_w {mw:.0f}: worst |command| {peak_cmd:.0f} W (rail " + f"{rail_w(mw):.0f} W), worst unchanged {peak_unchanged:.1f} s " + f"(hold 300 s), episodes {wd_r.episodes}") + check(f"max_w {mw:.0f}: never alarms on the converged capture", fired is None) + check(f"max_w {mw:.0f}: and the rail term had margin to spare", + peak_cmd < rail_w(mw)) + +# The margin on the OTHER term, as numbers rather than as trust. Both figures +# are quoted in config.yaml and freeze.py, so they are re-measured every run and +# a capture swapped underneath them cannot leave the prose saying the old thing. +_, _, worst_unchanged, _ = replay(300.0, LIVE_MAX_W) +gaps, last, prev = [], rows[0]["t"], None +for r in rows: + if prev is None or r["pwr"] != prev: + gaps.append(r["t"] - last) + last = r["t"] + prev = r["pwr"] +print(f" longest unchanged_s any sample would observe: {worst_unchanged:.1f} s") +print(f" longest interval serving no distinct reading: {max(gaps):.1f} s " + f"(spans an 11.4 s hole in the capture's own polling)") +check("worst observed unchanged_s is the 8 s the docs quote", + 7.5 < worst_unchanged < 8.5) +check("longest distinct-reading interval is the 15.4 s the docs quote", + 15.0 < max(gaps) < 16.0) +check("300 s is at least 15x the worst of the two", 300.0 >= 15 * max(gaps)) + +# --------------------------------------------------------------------------- # +# 2b. TEL-05's case, reconstructed, because the capture cannot contain it +# --------------------------------------------------------------------------- # +# ⚠️ BE HONEST ABOUT WHAT THE CAPTURE ABOVE PROVES AND WHAT IT DOES NOT. On this +# house's meter the power figure jitters at 1 W, so the worst `unchanged_s` any +# sample sees is 8 s - which means the capture alone does NOT refute a bare +# `unchanged_s > 300` threshold, and reporting it as though it did would be the +# confident explanation nobody checked. The case that refutes the bare threshold +# is the one p1.py describes and no capture of THIS meter contains: a converged +# meter whose power figure simply repeats, where the 1 Wh export register needs +# 360 s to advance at -10 W and the fingerprint therefore stands still. +# +# So it is constructed, from the operating point the capture measures (mean grid +# -14.2 W) rather than invented: one hour of a perfectly healthy converged meter +# that happens not to move. +print("TEL-05's low-power case - a converged meter that legitimately repeats") +tel05 = FreezeWatchdog(FreezeWatchdogConfig(hold_s=300)) +t5 = tuning(LIVE_MAX_W) +tgt, iw, sc = 0.0, 0.0, 0 +bare_would_fire = False +tel05_cmd = 0.0 +for k in range(3600): + if k % 15 == 0: # cycle_floor_s, the only trigger left + d = compute(prev_w=tgt, grid_w=-14.2, actual_w=tgt, tuning=t5, + sat_count=sc, i_w=iw) + tgt, iw, sc = d.target_w, d.i_w, d.sat_count + tel05_cmd = max(tel05_cmd, abs(tgt)) + # The REJECTED design, written out so the case can be shown to be real. + bare_would_fire = bare_would_fire or float(k) >= 300 + tel05.evaluate(float(k), tgt, LIVE_MAX_W, T0 + timedelta(seconds=k)) +print(f" an hour at -14.2 W: worst |command| {tel05_cmd:.0f} W, " + f"unchanged_s reaches 3599 s, episodes {tel05.episodes}") +check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here", + bare_would_fire) +check("the conjunction does not - this is what the rail term buys", + tel05.episodes == 0) +check("...and the command stayed nowhere near the rail", + tel05_cmd < rail_w(LIVE_MAX_W)) + +# The structural version of the same point, re-derived rather than asserted in +# prose: the slowest register a HEALTHY meter can have while still being seen as +# unchanged for hold_s is 3600/hold_s watts, and at the shipped defaults that +# figure is inside the deadband, so the command there is parked at 0 W. If +# someone lowers meter_freeze_s or narrows the deadband, this is the check that +# should stop them. +DEADBAND_W = 15.0 +slowest_healthy_w = 3600.0 / 300.0 +check("a healthy meter slow enough to look unchanged is inside the deadband", + slowest_healthy_w < DEADBAND_W) +_park = compute(prev_w=0.0, grid_w=-slowest_healthy_w, actual_w=0.0, + tuning=tuning(LIVE_MAX_W), sat_count=0, i_w=0.0) +check("...and the shipped law really does park the command there", + _park.target_w == 0.0) + +# ⚠️ THE PROOF THAT THE REPLAY IS CAPABLE OF FIRING AT ALL. Without this, "the +# converged capture is silent" is satisfied by a detector that is wired to +# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay +# must alarm - so the silence above is the data's doing, not the harness's. +wd_hot, _, _, fired_hot = replay(1.0, 20.0) +check("...and the same replay DOES alarm once both terms are made reachable", + fired_hot is not None and wd_hot.episodes > 0) + + +# --------------------------------------------------------------------------- # +# 3. a genuinely frozen meter, over real HTTP, through the real transport +# --------------------------------------------------------------------------- # +def find_hwsim(): + """The rig's own fake meter, if the infra repo is reachable.""" + cands = [] + if os.environ.get("GOODWE_SIM"): + cands.append(Path(os.environ["GOODWE_SIM"]) / "hwsim.py") + cands.append(HERE.parents[1] / "goodwe" / "sim" / "hwsim.py") + for c in cands: + if c.exists(): + return c + return None + + +STUB = r''' +import http.server, json, sys, threading, time +FROZEN = {"active_power_w": 700.0, "active_power_l1_w": 700.0, + "total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123} +moving = len(sys.argv) > 2 and sys.argv[2] == "moving" +t0 = time.monotonic() +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + d = dict(FROZEN) + if moving: + # A REAL railed condition: a big steady import, so the power figure + # jitters and the 1 Wh registers advance the way physics makes them. + el = time.monotonic() - t0 + d["active_power_w"] = 7000.0 + (int(el * 10) % 7) + d["active_power_l1_w"] = d["active_power_w"] + d["total_power_import_kwh"] = round(1234.567 + 7000 * el / 3.6e6, 3) + raw = json.dumps(d).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + def log_message(self, *a): pass +http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() +''' + +HWSIM_DRIVER = r''' +import sys, threading, time +sys.path.insert(0, sys.argv[3]) +import hwsim +hwsim.serve(int(sys.argv[1])) +mode = sys.argv[2] +hwsim.set_grid(7000.0) +if mode == "freeze": + hwsim.fault = "freeze" # exactly `hwsim.py --fault freeze` +while True: + # The plant keeps moving underneath either way. Under `freeze` hwsim's own + # set_grid() returns early, which is the whole point of that fault. + hwsim.set_grid(7000.0 + (int(time.monotonic() * 10) % 7)) + time.sleep(0.2) +''' + + +async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_MAX_W): + """Poll a real HTTP meter through the real transport and drive the detector.""" + ingest = P1Ingest(phases=1, max_age_s=30) + wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s)) + t = tuning(max_w) + target, i_w, sat = 0.0, 0.0, 0 + fired_at, polls, worst_unchanged, worst_cmd = None, 0, 0.0, 0.0 + async with aiohttp.ClientSession() as session: + src = HomeWizardLocalSource(session, ingest, "127.0.0.1", port=port, + poll_s=1.0) + start = time.monotonic() + while time.monotonic() - start < seconds: + if await src.poll_once(): + polls += 1 + grid = ingest.net_w + d = compute(prev_w=target, grid_w=grid, actual_w=target, + tuning=t, sat_count=sat, i_w=i_w) + target, i_w, sat = d.target_w, d.i_w, d.sat_count + worst_unchanged = max(worst_unchanged, src.unchanged_s or 0.0) + worst_cmd = max(worst_cmd, abs(target)) + if wd.evaluate(src.unchanged_s, target, max_w, + datetime.now(timezone.utc)) and fired_at is None: + fired_at = time.monotonic() - start + await asyncio.sleep(1.0) + return fired_at, polls, worst_unchanged, worst_cmd + + +def with_server(argv, port, fn): + proc = subprocess.Popen([sys.executable, "-c"] + argv, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + import socket + with socket.create_connection(("127.0.0.1", port), 0.3): + break + except OSError: + time.sleep(0.1) + else: + raise RuntimeError("the fake meter never came up") + return fn() + finally: + proc.kill() + proc.wait() + + +hw = find_hwsim() +print(f"frozen meter over HTTP - using {'the rig hwsim at ' + str(hw) if hw else 'the local stub'}") +if hw: + def argv_for(mode): + return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode, + str(hw.parent)] +else: + def argv_for(mode): + return [STUB, "8791" if mode == "freeze" else "8792", + "frozen" if mode == "freeze" else "moving"] + +fired, polls, unch, cmd = with_server( + argv_for("freeze"), 8791, + lambda: asyncio.run(live_run(8791, "freeze"))) +print(f" freeze: {polls} polls, worst unchanged {unch:.1f} s, " + f"worst |command| {cmd:.0f} W, alarm at " + f"{'never' if fired is None else f'{fired:.1f} s'}") +check("the transport really polled a live server", polls >= 10) +check("a frozen meter raises the alarm", fired is not None) +check("...and only after the hold, not on the first railed poll", + fired is None or fired >= 10.0) + +fired2, polls2, unch2, cmd2 = with_server( + argv_for("moving"), 8792, + lambda: asyncio.run(live_run(8792, "moving"))) +print(f" moving: {polls2} polls, worst unchanged {unch2:.1f} s, " + f"worst |command| {cmd2:.0f} W, alarm at " + f"{'never' if fired2 is None else f'{fired2:.1f} s'}") +check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_MAX_W)) +check("a REAL railed condition against a live meter stays silent", fired2 is None) +check("...because the meter kept moving", unch2 < 10.0) + + +# --------------------------------------------------------------------------- # +# 4. the wiring, because a correct detector nobody calls is not a detector +# --------------------------------------------------------------------------- # +# ⚠️ EVERYTHING ABOVE PASSES WITH `app/freeze.py` IMPORTED BY NOTHING. That is +# the shape of defect this project keeps finding: the mechanism is right, the +# suite is green, and the shipped path never reaches it. So the real +# `Controller.cycle()` is driven here, against the real arbiter and the real +# control law, with a stand-in transport that reports `unchanged_s` the way +# HomeWizardLocalSource does - and the assertion is on the SERVICE CALL that +# leaves the process, not on the watchdog's own flag. +print("the wiring - Controller.cycle() actually raises the notification") + + +class _Store: + data: dict = {} + + def set(self, *a): + pass + + def get_time(self, *a): + return None + + +class _Pub: + enabled = False + + def publish(self, *a, **k): + pass + + def close(self): + pass + + +class _Hass: + def __init__(self, grid): + self.grid = grid + self.calls = [] + self.writes = [] + # The plant tracks the command instantly, as in test_loop.py. Without + # it the saturation freeze pins the command short of the rail and the + # runaway under test never happens - which would have made this a check + # that passes for the wrong reason. + self.batt = 0.0 + + async def limits(self, entity): + return (-5000.0, 5000.0, 1.0) + + async def number(self, entity, invert=False): + return {"sensor.grid": self.grid, "sensor.soc": 50.0, + "sensor.batt": self.batt}.get(entity) + + async def set_number(self, entity, value): + self.writes.append(float(value)) + self.batt = float(value) + return True + + async def call(self, domain, service, data): + self.calls.append((domain, service, data)) + return True + + +class _Source: + """Only the attribute the controller reads. Duck-typed like the real one.""" + + def __init__(self, unchanged_s): + self.unchanged_s = unchanged_s + + +def wired(unchanged_s, grid, cycles=60, **over): + async def go(): + hass = _Hass(grid) + c = Controller(dict( + {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6, + "slew_w": 1000, "deadband_w": 15, "target_grid_w": -10, + "step_w": 10, "heartbeat_s": 10, "stale_input_s": 15}, + **over), hass, _Store(), _Pub(), stop=asyncio.Event()) + c.auto = True + c.dev_min, c.dev_max = -5000.0, 5000.0 + c.p1_source = _Source(unchanged_s) + for _ in range(cycles): + await c.read_inputs() + await c.cycle() + return hass, c + return asyncio.run(go()) + + +hass_f, ctl_f = wired(3600.0, 4000.0) +notes = [d for dom, svc, d in hass_f.calls + if (dom, svc) == ("persistent_notification", "create")] +# The status row lives behind `p1_enabled`, which is what a homewizard_local +# 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 +rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]] +print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, " + f"{len(notes)} notification(s), {len(rows)} red status row(s)") +check("the shipped cycle() raises exactly one HA notification", len(notes) == 1) +check("...with a fixed notification_id so repeats replace, not stack", + notes and notes[0].get("notification_id") == "goodwe_meter_frozen") +check("...and it says the controller is still running", + bool(notes) and "STILL RUNNING" in notes[0]["message"]) +check("the status page carries a failed row saying so", + len(rows) == 1 and not rows[0]["ok"] and not rows[0]["warn"]) +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. +check("and it does NOT command 0 W - this cut is an alarm only", + abs(ctl_f.target) >= rail_w(LIVE_MAX_W)) + +hass_n, ctl_n = wired(3600.0, -14.2) +check("wired up, TEL-05's converged case still raises nothing", + not [1 for dom, svc, _ in hass_n.calls if svc == "create"]) +hass_m, ctl_m = wired(2.0, 8000.0) +check("wired up, a real railed load against a moving meter raises nothing", + not [1 for dom, svc, _ in hass_m.calls if svc == "create"] + and abs(ctl_m.target) >= rail_w(LIVE_MAX_W)) +check("an uncovered transport (no unchanged_s) raises nothing, and says so", + not [1 for dom, svc, _ in wired(None, 4000.0)[0].calls if svc == "create"]) + +print() +if fails: + print(f"{len(fails)} FAILED: {', '.join(fails)}") + sys.exit(1) +print("all checks passed") -- 2.54.0 From 0ba3a200fe62a32f39e0a7743afa9d038b1ccb4f Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 30 Aug 2026 02:49:55 +0200 Subject: [PATCH 2/2] SAFETY-18: verify on the real rig, and record what it showed AC3 says "verified on the rig". The previous commit drove hwsim in-process because `riglock status` reported UNKNOWN - which is the tool declining to observe, not an observation. riglock shells out to the local docker CLI; on a Windows host without DOCKER_HOST that resolves to a Docker Desktop npipe which is not running, and the result is indistinguishable from a genuinely absent rig. The rig was up the whole time. With DOCKER_HOST=ssh://homelab@192.168.2.147 it reads FREE. Re-run under sim/riglock.py against the real goodwe-sim compose project, with the shipped modules staged into goodwe-sim-plant so they poll that container's own hwsim while plantsim drives it. Both phases passed, lock released cleanly. The rig produced a result the in-process stub could not, and it strengthens the design rather than merely confirming it: in the NEGATIVE twin - +8000 W of house load, healthy meter, correctly railed command - `active_power_w` did not move at all, sitting at exactly 10130.0 W for 26 consecutive polls. The only thing keeping `unchanged_s` at 0.0 s was the kWh registers advancing. So the register terms in measurement_fingerprint() are not belt-and-braces for this detector, they ARE the negative case: fingerprinted on the power figure alone it would have raised a false alarm on a healthy meter under a real load, which is precisely how a safety guard gets switched off. Recorded in freeze.py so nobody simplifies the fingerprint without meeting that run first. No behaviour change - documentation and the harness note only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC --- goodwe_controller/app/freeze.py | 14 ++++++++++++++ goodwe_controller/test_freeze.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/goodwe_controller/app/freeze.py b/goodwe_controller/app/freeze.py index b4096cc..1a11544 100644 --- a/goodwe_controller/app/freeze.py +++ b/goodwe_controller/app/freeze.py @@ -71,6 +71,20 @@ meter, and a kilowatt advances a 1 Wh register every 3.6 s. The two terms fail for independent reasons. That is the whole design. +⚠️ AND THE SECOND HALF OF THAT WAS MEASURED ON THE RIG, WITH A RESULT WORTH +READING CAREFULLY. Driving the real transport against the rig's own hwsim under ++8000 W of house load - a genuinely railed command, meter perfectly healthy - the +POWER FIGURE DID NOT MOVE AT ALL: `active_power_w` sat at exactly 10130.0 W for +26 consecutive polls. What kept `unchanged_s` pinned at 0.0 s was the kWh +registers advancing, and nothing else. + +So the register terms in `measurement_fingerprint()` are not belt-and-braces +here, they are the entire negative case. A freeze detector fingerprinted on the +power figure alone would have raised a false alarm on that rig run - a healthy +meter, a real load, a correctly railed command - which is the exact failure mode +that gets a safety guard switched off. Anyone tempted to simplify the fingerprint +should read that run first. + ------------------------------------------------------------------------------ WHERE `hold_s` COMES FROM diff --git a/goodwe_controller/test_freeze.py b/goodwe_controller/test_freeze.py index 492f818..09f0f81 100644 --- a/goodwe_controller/test_freeze.py +++ b/goodwe_controller/test_freeze.py @@ -28,6 +28,29 @@ is replayed rather than lived. same railed command against a meter that is still MOVING, at the same 10 s threshold. If that one alarms, the detector is a `unchanged_s` threshold with extra steps and the whole reason for the conjunction is gone. + +------------------------------------------------------------------------------ +THE RIG RUN THIS STANDS IN FOR + +Part 3 is the portable version of a run done on the real rig - the `goodwe-sim` +compose project on 192.168.2.147, under `sim/riglock.py`, with the driver staged +into `goodwe-sim-plant` so it polls that container's own hwsim while plantsim +drives it. Both phases passed there. To repeat it: + + DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status + +⚠️ WITHOUT `DOCKER_HOST` SET, ON A WINDOWS HOST, `riglock status` REPORTS +`rig: UNKNOWN` - it shells out to the local `docker` CLI, which resolves to a +Docker Desktop npipe that is not running. That is INDISTINGUISHABLE from a +genuinely absent rig, and it is not one: the rig lives on the homelab box and was +up the whole time. `UNKNOWN` is the tool declining to observe, not an +observation. Do not read it as "no rig" (this cost this ticket a wrong caveat). + +The rig run also produced a result part 3's stub cannot: under +8000 W of house +load the meter's POWER FIGURE never moved - 10130.0 W for 26 consecutive polls - +and only the kWh registers kept `unchanged_s` at zero. See the note in +freeze.py; it is why the fingerprint's register terms are the negative case +rather than a nicety. """ import asyncio -- 2.54.0