diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index d312405..49979b9 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -81,6 +81,59 @@ a Supervisor install (every key is always supplied) and reachable only from tests/`sim`. The real fix is one source of truth for option defaults, which is bigger than this ticket and touches files another ticket is in. +## 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 39d2951..d1e61ea 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..1a11544 --- /dev/null +++ b/goodwe_controller/app/freeze.py @@ -0,0 +1,275 @@ +"""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. + +⚠️ 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 + +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 3f7918c..d7b3d71 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 @@ -196,6 +216,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 0000000..1b583f6 Binary files /dev/null and b/goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz differ diff --git a/goodwe_controller/test_freeze.py b/goodwe_controller/test_freeze.py new file mode 100644 index 0000000..09f0f81 --- /dev/null +++ b/goodwe_controller/test_freeze.py @@ -0,0 +1,539 @@ +"""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. + +------------------------------------------------------------------------------ +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 +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")