diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index e5e1b23..ab9d298 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,173 @@ # Changelog +## 0.10.1 + +**A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last +place moves because this is a bug fix to a version that has shipped and is +regulating a battery, not a change to what the add-on offers. **The option +surface does not move at all** - no key added, none removed, no default changed, +no enum widened, no rule added to `validate.py` - so ⚠️ **a stored options blob +that starts the add-on on 0.10.0 starts it on 0.10.1**, and there is no +`SystemExit(78)` to plan around on this hop. If you are on 0.10.0, the only +question worth your time is whether you have been hit by what it fixes, and +`### SAFETY-57` below is written to answer exactly that. + +⚠️ **WHY THIS IS A NEW NUMBER RATHER THAN A FOLD INTO 0.10.0, WHICH IS THE +REASON THE TICKET EXISTS.** The Supervisor decides that an update is available +by comparing `config.yaml`'s version string with the one the box reports. +0.10.0 is installed and running, so a fix delivered inside `## 0.10.0` would +leave every box reporting `0.10.0` believing it was already current - the +PKG-06/PKG-07 no-op, in the form where the tree it hides is a safety fix. +0.10.0's own section argued it could still be folded into; ⚠️ **that argument +was explicitly conditional on 0.10.0 never having been installed, and the +condition has fired** - see the closing note at the end of `## 0.10.0` below. + +⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS +NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons update` +does not walk the versions; it installs 0.10.1 in one step, so every validator +rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as this +section's nothing. Two of them can refuse a config that nobody has edited: +[[SAFETY-39]]'s **rule 10** against 0.7.0's own shipped default `slew_w: 1000`, +and [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`, +`totalenergies_dynamic` and `eneco_eddy`** - each of which is still refused by +name at startup on this version, with the same message. Both are written out in +full under `## 0.10.0`, and `tools/predeploy_check.py` checks both against your +live options before you press *Update*. + +Contents of this release, in merge order since 0.10.0 (`cb3f686`): + +| | | +|---|---| +| `b9f7bd0` | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** | + +⚠️ **AND THAT IS THE WHOLE TABLE, WHICH IS THE POINT OF A PATCH NUMBER.** +[[TEL-10]] and [[SAFETY-48]] also merged after `cb3f686`, but [[PKG-15]] folded +both into `## 0.10.0` **before 0.10.0 was installed**, so they shipped under +that number and are recorded there rather than here. [[CI-9]] and [[PKG-15]] +itself touch `.gitea/workflows/checks.yml` and this file only - nothing an +operator can observe on an installed add-on - and are excluded for the reason +`## 0.10.0` already gives. ⚠️ **Two defects that the reference install's own +field history found are NOT in this release**: [[SAFETY-56]] (the stale-reading +message says *"is not following its own command"* when the true cause is an +empty pack, and the NOT READY row that goes with it) and [[SAFETY-58]] (a trip +on the first read after a rest). Both are open, both are wording or bound +changes rather than deadlocks, and neither was allowed to delay this one. + +### SAFETY-57 — the stale-reading verdict gets an exit + +⚠️ **WHETHER YOU WANT THIS UPDATE, AND HOW TO TELL.** On 0.10.0 the add-on +can enter a state it cannot leave: the command is parked at **0 W**, the +battery-power reading is gated to **MISSING**, and the only condition that +would clear the gate is one that a parked command cannot produce. **It is a +deadlock, not a slow recovery.** On the reference site on 2026-09-21 it held +for about **thirteen minutes while the house exported 2.4 kW**, and it ended +only because a human restarted the add-on. + +**Recognising it costs one look at the status page and one at the log:** + +| | | +|---|---| +| the setpoint | sits at **exactly 0 W** and does not move, while the house is plainly importing or exporting | +| the battery-power sensor | is **flat at one number** for the whole of it - on the live episode, −3 W for 850 s | +| the action log | carries a `STALE batt` line and **no** clearing line after it | + +**What ends it on 0.10.0 is a restart of the add-on**, and a restart is enough - +nothing is latched to disk and the pack is not harmed. ⚠️ **But nothing ends it +by itself**, and the replay below says the wait can run to hours. On 0.10.1 the +state releases itself after a bounded **32 s** and the register is re-graded +from scratch. + +⚠️ **IT IS NOT AN EMERGENCY AND IT IS NOT COSMETIC.** The pack is not at +risk in this state - 0 W is the safe command, and it is the command §11.1 rule 1 +asks for on a missing input. What is lost is the site's own control while it +lasts: the battery neither charges nor discharges, so a solar export goes to the +grid and an evening load comes off it. + +⚠️ **WHAT IT FIXES, ON THE OWNER'S OWN BATTERY.** [[SAFETY-37]]'s freshness gate +reports a battery-power register as MISSING when it stops following its own +command, and a missing input is `command 0 W and keep commanding` (§11.1 +rule 1). The gate's only exit was *"an arrival carrying a different number"* — +**and at 0 W nothing is asking the register to be anywhere.** The state had no +exit reachable from inside it, and the log line said the opposite. + +Measured off the RS-485 bus, 2026-09-21, and committed as +`scenarios/safety57-2026-09-21/`: + +| | | +|---|---| +| 10:51:24 | 0x0518 reaches **−3 W**. The pack is at 11 %, the loop has just reversed out of a charge, and the BMS refuses the discharge. The register is **healthy and truthful**. | +| 10:51:29 | the command reaches **+1060 W**, 800 W of travel | +| 10:52:00 | the gate arms. `Inverter.batt` becomes `None` | +| 10:52:21 | **the site commands 0 W — on the wire, in the capture** | +| … | the register does not change value for **850 s**. The setpoint does not change value for **790 s**. The house exports 2.4 kW throughout | +| ~11:04 | **a human restarts the add-on.** That is what ends it | + +It had been escaping by luck - and ⚠️ **on this episode it did not escape at +all.** `0x0518` answered the same −3 W on 90 consecutive responses and never +once carried a different number between 10:51:24 and 11:05:35. The register did +not move. + +⚠️ **AND WHY IT USUALLY DOES MOVE IS NOT SOMETHING THIS REPOSITORY HAS +MEASURED.** An earlier draft of this entry named a BMS keep-alive pulse as the +thing that twitches the register, stated as fact; **no committed capture +identifies such a pulse**, distinguishes one from the register simply following +a command, or sizes it. The honest statement is that the register changes value +for reasons that belong to the battery's firmware. What IS measured is how long +it goes without changing: replaying the committed 16-day loop capture through +the shipped conjunction finds **93 trips** whose wait has a **median of 143 s** +and a worst case of **5 h 57 m**, with **16 of the 93** at or beyond this +episode's **790 s**. That figure is stronger than the firmware story it +replaces, and it needs no firmware story at all. + +**The rule, and it is [[SAFETY-13]]'s shape one module over** — which latches +get an exit, not how long the block lasts: + +> the verdict is withdrawn when the obligation that produced it has been gone +> for `RELEASE_S`, and the register is then re-graded from scratch. + +`RELEASE_S` is **17.0 s**, the obligation window already derived in +`freshness.py` — 6.0 s of plant, one 10 s ESPHome publish, one 1 s add-on read. +It is the **floor** of its band rather than a preference: below it the release +pre-empts the cheaper exit (the register answering by itself), and being wrong +*long* is the failure being fixed. **The whole parked interval is now +`stale_input_s` + `RELEASE_S` = 32 s**, bounded by two shipped numbers and by +nothing the battery does. On the live episode: telemetry back at **10:52:39 +instead of 11:05:35**, with **0x0518 still answering the same −3 W**. + +⚠️ **WHAT IT COSTS, AND IT IS NOT SMALL.** A register that is dead for good is +re-graded for ever. Driven over 1201 reads at the shipped defaults: the held +number reaches a consumer on **630 reads (52.5 %)** against 35 (2.9 %) before. +That is payable only because **one re-grading window is 35 s and the two +consumers hold for 75 s and 85 s** — neither can reach a verdict inside one, +and the re-trip that ends each window hands them a `None`, which *clears* their +windows. **No detection SAFETY-37 had is given back**: a permanent `None` +clears those same windows for ever too. What the site gets back is its +meter-derived control, and the command was never derived from `batt`. + +⚠️ **THAT 35 s HOLDS WHILE THE OBLIGATION COMES BACK, AND ONLY THEN - IT IS NOT +A UNIVERSAL BOUND, AND THE FIXTURE THAT GRADES IT CANNOT SHOW OTHERWISE.** A +window closes when the gate re-arms, and the gate needs a command that is +*moving* to re-arm against. If the release lands where the site then regulates +below `MOVE_W`, nothing re-arms, the window never closes, and the held number +reaches a consumer for an unbounded time - where before this card a `None` did. +The driver behind the 35 < 75 < 85 inequality puts a 1300 W obligation back on +every release, so it is the one shape that cannot produce that case. On the +shipped roster of **one** inverter neither consumer can reach it anyway - the +per-unit sign watchdog is compared against the **meter** at `n_units <= 1`, and +`circulating_w()` is identically `0.0` there - so this is **a stated gap rather +than a covered one**, and it needs a second inverter to bite. + +⚠️ **WHAT YOU WILL SEE.** A new `STALE WITHDRAWN ` line in the action log +beside the existing `STALE ` one, and a log warning that says in its own +words that **nothing has been learned about the register**. If the pair repeats +every ~70 s, the register really is not following and the unit needs looking at; +if it happens once and stops, the pack was simply empty and the site has +resumed. The stale line's escape clause was also corrected — it used to promise +a clearance the state could not produce. ⚠️ Its **diagnosis** half still says +*"is not following its own command"*, which is wrong in a different way when the +pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately +untouched here. + ## 0.10.0 ⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE @@ -98,8 +266,9 @@ less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter should not be expected to improve.** The whole account, including what it costs and what it does not establish, is `### SAFETY-48` below. -⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS - -THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha +⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL WAS +WHEN THIS WAS WRITTEN, AND HAS NOT BEEN SINCE 2026-09-20 - THE REFUSAL THAT CAN +STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha addons update` does not walk the versions; it installs 0.10.0 in one step, so **every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as 0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all @@ -120,7 +289,10 @@ fire only on a roster entry that USES `capacity_kwh`, a key that did not exist `slew_w` against the ceiling and `price_provider` against the withdrawn list BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both. -✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** +✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** ⚠️ This is the record of +a run made on 2026-09-19, when the reference box was still on 0.7.0. That +crossing has since been made; the box is on 0.10.0 and the transcript below is +history rather than the state of any install. docker run --rm -v :/w:ro -v :/opt:ro goodwe-addon-checks:1 \ python3 /w/goodwe_controller/tools/predeploy_check.py \ @@ -141,10 +313,10 @@ byte what that run measured. `app/validate.py` imports neither `main` nor `PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside `compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half - -the no-op check - does not run at all here**, because it is gated on -`shipped == deployed` and the box reports `0.7.0`. It is the half that would -refuse this fold on a box already running 0.10.0; see the note below the -contents table. +the no-op check - did not run at all in that transcript**, because it is +gated on `shipped == deployed` and the box reported `0.7.0` at the time. It is +the half that refuses this fold on a box already running 0.10.0 - ⚠️ **which +the reference box now is**; see the closing note below the contents table. **The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four new keys, and none of them can refuse a blob written before it existed: @@ -195,17 +367,29 @@ counted, so `last..HEAD` excluded it then and includes it now. The three misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below moves them. -⚠️ **AND THE VERSION IS NOT BUMPED FOR THIS FOLD, DELIBERATELY.** 0.10.0 was -cut on 2026-09-16 and **has never been built or pulled anywhere** - the -reference install is on 0.7.0, this repository carries exactly one tag -(`v0.2.1`) and no release - so the string `0.10.0` does not yet name a tree -anyone is running, and the PKG-06/PKG-07 no-op that forbids re-cutting 0.9.0 -does not apply to it. Cutting 0.11.0 instead would burn a number on a release -that never left the repository. ⚠️ **THE ARGUMENT IS CONDITIONAL AND THE -CONDITION IS CHECKABLE**: if 0.10.0 has reached an install by the time you read -this, it may not be folded into again, and `tools/predeploy_check.py` says so -by itself - run against a box already reporting `0.10.0` it refuses with `THE -DEPLOY WOULD BE A NO-OP` and names the runtime commits since the bump. +⚠️ **THE VERSION WAS NOT BUMPED FOR THE FOLDS ABOVE, AND THAT ARGUMENT HAS +NOW EXPIRED. THIS SECTION IS CLOSED.** As written on 2026-09-19 it ran: 0.10.0 +was cut on 2026-09-16, had never been built or pulled anywhere, the reference +install was on 0.7.0 and this repository carried exactly one tag (`v0.2.1`) and +no release - so the string `0.10.0` did not yet name a tree anyone was running, +the PKG-06/PKG-07 no-op could not apply, and cutting 0.11.0 would have burned a +number on a release that never left the repository. ⚠️ **IT WAS STATED AS +CONDITIONAL ON THAT REMAINING TRUE, AND IT STOPPED BEING TRUE ON 2026-09-20 AT +ABOUT 22:18 CEST**, when 0.10.0 was installed on the reference site as a +repository add-on (slug `68285ac7_goodwe_controller`) and began regulating a +real battery. **0.10.0 has been built and pulled, and the reference install is +on 0.10.0, not on 0.7.0.** + +⚠️ **AND THE FIELD HISTORY IS NOW THE STRONGEST EVIDENCE IN THIS FILE, WHICH +IS WHY THE CORRECTION IS WORTH ITS SPACE.** That install has since run a full +day-night cycle on 0.10.0 - an empty pack, a guard trip, a 2 h 47 min saturation +freeze, the [[SAFETY-57]] deadlock and a clean recovery - and **two defects came +out of it that no test in this repository had reached**: [[SAFETY-56]] and +[[SAFETY-57]]. So nothing may be folded into this section again, and +`tools/predeploy_check.py` enforces that by itself: run against a box already +reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the +runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**, +which is what a patch number is for. Contents of this release, in merge order since 0.9.0 (`86bdf49`): @@ -231,6 +415,7 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`): | `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** | | `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** | | `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** | +| _this branch_ | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** | ⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It diff --git a/goodwe_controller/DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md b/goodwe_controller/DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md new file mode 100644 index 0000000..7fefa0b --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md @@ -0,0 +1,380 @@ +# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested + +**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3` +(content-identical to `release/1.0` @ `8a57361`). +**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*. +**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or +192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a +**passive** RS-485 listener with no address on the bus, and it was read only. + +--- + +## 0. The five things a reader should take away + +1. **The live episode is reproduced from the wire, not modelled.** The capture + is committed, the decoder is committed, and the box that produced it was + running the defect — so the `0 W` in the trace *is* the add-on's own + `inputs missing → command 0 W`. §2. +2. **Option 2 from the card — "do not arm the gate when the command is 0 W" — + already holds, and it is not the bug.** `_grade()` has required + `|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and + the stuck state is at **0 W**; they are different moments. §3. +3. **The decision is not "how long", it is "when".** The verdict is withdrawn + when the **obligation that produced it is no longer standing** — SAFETY-13's + shape one module over. The 17.0 s is the obligation window that was already + derived in the file, taken at the **floor** of its band, and which half is + safe is the **opposite** of SAFETY-13's. §4. +4. **It costs 52 % exposure on a permanently dead register, and that number is + in the suite so it cannot be forgotten.** It is payable only because one + re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s). + **No detection SAFETY-37 had is given back.** §5. +5. **The battery had been rescuing this code by accident.** 93 trips in the + committed 16-day capture; median wait for the BMS keep-alive pulse that + cleared them **143 s**, worst **5 h 57 m**. §6. + +--- + +## 1. What was there, in the terms the code uses + +`freshness.py`, `Freshness.submit()` and `_grade()`: + +```python +if self.changed_mono is None or value != self.held: + ... + self.stale = False # the only place the latch is cleared +def _grade(self, standing_w, now_mono): + if self.stale or self.changed_mono is None: + return # sets the latch; never clears it +``` + +and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()` +answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for +`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs +missing")` and keeps filing it. + +The reason the latch exists is written in the class docstring and it is a good +one: without it the verdict oscillates at about 1 Hz, because declaring the +reading stale drops the command below `MOVE_W`, which un-arms the conjunction, +which hands the held number straight back. + +**The defect is that the only thing that ends it is an arrival carrying a +different number — and the state's own output is what stops the register being +asked for one.** The log line promised the opposite, in the same sentence that +announced the 0 W: + +> *Treating it as MISSING, which commands 0 W. It clears itself the moment the +> register reports a different value.* + +--- + +## 2. Reproducing it, from the wire + +`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840 +verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by +`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC, +0 unknown shapes**, and its mode→sign convention is **graded off the capture** +rather than trusted: 298 commands at or under −1000 W are each answered one +obligation window later by a negative register reading, and not one positive. + +| capture time | on the wire | +|---|---| +| 10:51:14 | `0x0518` = **−447 W**; the loop is charging | +| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge | +| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** | +| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel | +| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire | +| …850.0 s | `0x0518` does not change value | +| …790.0 s | the setpoint does not change value | +| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode | +| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % | +| 11:05:34.7 | `0x0518` = **−137 W** | + +⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why +this is a reproduction and not a simulation: the box was running 0.10.0, so +every command in the trace is the shipped code's own. The only event inside the +window the file cannot account for is the human restart. + +Replaying the shipped `_read_unit` loop at 1 Hz against those two series +(`test_freshness.py` part 10b): + +| | stale at | clear at | parked | +|---|---|---|---| +| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them | +| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** | + +⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN +CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is +only ever called where the register moves, and the window pair is the +change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b +asserts that by driving part 1's own fresh/stale pair through both bounds and +comparing the verdict streams. + +The replay also found a **one-read trip at 10:48:14** that clears one second +later. That is a pre-existing false positive and it is now recorded as +survivor 5 — §7. + +--- + +## 3. What the card's option 2 actually buys, which is nothing here + +The card offered *"do not arm the gate when the command being graded is 0 W"* +and warned that it might hold already. **It does, and it has since SAFETY-37**: +`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and +`test_freshness.py` part 4 has a named check on that term, added because a +mutation removing it survived. + +The warning in the card is the right one and it is worth writing down: **the +trip and the stuck state are different moments.** The gate armed at **+1060 W**, +where the conjunction is entirely correct — a command travelled 800 W and the +register did not move for 35 s. The site then went to **0 W** *because of the +verdict*. An entry-side rule cannot reach a state that is entered legitimately +and then persists. **The defect is the exit, and nothing else in the conjunction +is touched by this card.** + +Option 3 — probing with a small non-zero command — was not taken and does not +need to be. It writes to a real actuator to test an assumption, and §4's rule +does not need a probe: the site's *normal* control resuming is the test, and it +is gated behind the same `inputs_ok` everything else is. + +--- + +## 4. The decision: which verdicts get an exit, and what the bound is + +> The stale verdict holds as before, **unless** the obligation that produced it +> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone +> longer than the register would have needed to answer it. On a release the +> latch clears and a **new grading window** opens at that instant. + +This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card +established the house answer to "a latch with no exit" and its answer was *not* +a plain time bound but a **predicate plus a time bound**, where the predicate +says whether the state can end on its own. Here the predicate is that **the +question is no longer being asked.** A register under a 0 W command is obliged +to be nowhere, so a verdict about whether it is following cannot be tested, and +a verdict that cannot be tested may not stand for ever. + +### 4.1 Why the window has to re-open, and what happens if it does not + +`_grade()` compares the register's unchanged time against `unchanged_s` and the +command's travel against the command standing when the window opened. If a +release cleared the latch **without** re-opening the window, the first read at +which the resumed command travels 250 W would re-trip immediately — the +register would never get the 17 s in which it could answer, and the live +episode's recovery (a charge the pack **accepts**) would be killed inside two +seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`, ++82 B) takes four named checks red. + +The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry, +because it is the same question — *does this register follow the commands issued +from now on?* — and there is no principled shorter answer to it. It is also the +number the exposure argument in §5 rests on, so it is not free to move. + +### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe + +The quantity needed is *has the register had its chance to answer the command +standing now?*, and `freshness.py` already derives it as the floor of +`BATT_UNCHANGED_S`'s band: + +``` +6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22 +10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s +1.0 s this add-on run_control()'s await asyncio.sleep(1) +``` + +⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of +`MOVE_W` taking the **ceiling** of its own for the mirrored reason. + +| | the question | the safe half | why | +|---|---|---|---| +| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses | +| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that | + +Below 17.0 s the release pre-empts the **cheaper exit** — the register answering +the new 0 W command by itself, which clears the latch on evidence rather than on +a timer. That is graded behaviourally rather than asserted: a register that +answers at +16 s clears by **movement** at `release_s = 17.0` with `releases` +still 0, and by **timer** at `release_s = 5.0`. + +The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give +six distinct release instants, so no check in the file can confuse two of them. + +### 4.3 Two clocks, and the operator reads the register's + +`changed_mono` / `unchanged_s()` remain facts about the **register**: after a +release they go on reporting that it has not moved for 850 s, because it has +not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide +until the first release. Collapsing them either lies to the operator or defeats +the re-grading, and both directions are mutated red. + +The stale sentence's `unchanged for N s` slot carries the **register's** number; +a re-trip appends a clause naming the window and the grade count, so the two +durations cannot be read as one number that moved (habit 14). + +--- + +## 5. What it costs, measured + +Driven against a register frozen for good, 1201 reads, shipped defaults +(`test_freshness.py` part 10c): + +| | parked | the held number reaches a consumer | releases | +|---|---|---|---| +| before | 1166 s | **35 reads (2.9 %)** | 0 | +| after | 571 s | **630 reads (52.5 %)** | 17 | + +⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is +`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window. + +It is payable because of one inequality, and it is the inequality +`test_freshness.py` part 6 has graded against the shipped `config.yaml` since +SAFETY-37: + +``` +35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s) +``` + +Every unbroken run of exposure is **exactly one window**, so neither consumer +can reach a verdict inside one, and the re-trip that ends each run hands them a +`None`, which **clears** their windows rather than accumulating into them. + +**And nothing SAFETY-37 detects is given back**, which is the half that makes +this a trade rather than a regression: a permanent `None` clears those same two +windows for ever as well, so on the shipped-before module those detectors are +*equally* unable to speak about the unit. What the held number does reach for +35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which +answers a large unexplained divergence by **freezing the output**, the +conservative direction and SAFETY-13's business — and the command itself is +derived from the **P1 meter**, never from `batt`. + +### 5.1 The narrowing this does NOT take, named so the next card can + +The live episode's register refused a **discharge** into an 11 % pack and then +followed a **charge** three seconds later. A refusal in one direction is not +evidence about the other, and a gate that knew the pack's floor could re-grade +on the first command that crosses it instead of on a timer — which would shrink +the 52 % above. That needs SoC, which this module deliberately does not take +(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number. +It is **not needed to give the state an exit**, and folding it in here would +make the exit depend on a second measurement being right. + +--- + +## 6. The measurement that says the old exit was luck + +`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped +conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only +thing that could clear any of them was the register carrying a different number, +and that took: + +| | | +|---|---| +| minimum | 0.6 s | +| **median** | **143.4 s** | +| maximum | **21440.1 s** (5 h 57 m) | +| at or over the live episode's 710 s | **17 of 93** | + +The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at +508 W — twitching `0x0518` and satisfying the clearing rule **by accident**. +Over a fifth of the trips are cleared in under 20 s by something nothing in this +add-on asked for. + +⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and +0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the +setpoint column is an **ungated** controller's. That makes each trip an honest +register-versus-command divergence off the real bus, and it makes the post-trip +command column something a gated box would **not** have written. The figure +taken from it is therefore the only one that does not depend on the command at +all — how long this register goes without changing value — and it **under**-states +the wait, because a site parked at 0 W gives the register even less reason to +move than the one recorded here. + +--- + +## 7. Survivors, and the two things the battery found + +The battery is 22 mutations over `app/freshness.py`, `app/main.py` and +`tools/decode_bus_capture.py`, plus one over the committed capture itself. All +run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of +the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`. +Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per +invocation and the tree is fingerprinted **after the revert** as well as before; +every revert restored the recorded sha256 and byte count. + +**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the +PR body. Two of them are worth keeping here. + +### 7.1 The equivalent mutant, argued rather than rounded off + +Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and +survived the whole battery. **It is not a missing fixture and no fixture can +close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()` +only when `|standing| < move_w`, so at most one of them can do anything on any +read, whichever runs first. + +⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a +`⚠️` comment, with a reason that sounded right. It was false, and the battery is +what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in +a comment is a claim, and this one had not been run.** The comment now states +the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and +1300 W — instead of pinning an order that is not observable. + +### 7.2 The sibling that survived, which is habit 11 earning its place + +`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`. +Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived +the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is +below both 35 and 250. They separate only in the band between them, and a site +can sit there: a peak-guard floor, a shadow substitution, any claim that wins +with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W +that asserts the 100 W case releases and the 300 W case does not. + +### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half + +The first form of the `graded_from` mutation was +`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and +the tree fingerprint showed the edit really was on disk and really did run. +`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` — +so the mutant was a *no-op on the fixture*, for a Python truthiness reason that +has nothing to do with the property under test. Rewritten as an explicit +`is None` test (+82 B) it is red on four named checks. + +**A mutation is subject to every habit in MUTATION.md, including habit 4.** A +purge, a fingerprint and a byte delta all say the edit is present; none of them +says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19 +for it. + +--- + +## 8. What this does NOT fix, said plainly + +**It does not tell a BMS refusal apart from a dead register**, and it does not +try to. Both are re-graded on the same timer. §5.1 names what telling them apart +would need. + +**It does not make the diagnosis right.** The line the operator reads still says +*"battery-power register is not following its own command"*, which is wrong in a +different way when the pack is simply empty. That wording is **SAFETY-56's**, +together with `main.py`'s NOT READY row, and it is deliberately untouched here — +two cards editing one sentence is how one of them silently loses. Only the +**escape clause** was rewritten, because this card is what made the old one +false, and part 10h has a named check asserting the diagnosis half is still +there so that rewrite has to be a deliberate act. + +**Survivor 5 is older than this card and is not closed by it.** After a long +rest the unchanged clock is already past 35 s, so the **first** read carrying a +travelled command trips the gate — before the 17.0 s in which a healthy register +could have answered it. The live capture contains one (10:48:14, cleared one +second later). Its size is one `stale_input_s` hold, during which `cycle()` +returns at `inputs missing {n}s` and commands nothing differently. Fixing it +means measuring the unchanged clock from when the **obligation** appeared rather +than from the register's last move, which re-derives the 35.0 s bound and is a +different card. + +**Nothing was measured on two inverters.** Inverter B does not physically exist +(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two +copies of A's behaviour with different numbers passed in. + +**No deploy is proposed.** 0.10.0 is on the reference install and this branch's +CHANGELOG row is filed under it only because `test_version_gate.py` part 11b +requires the newest heading to be a version. Moving it, and deciding whether a +version is cut, is a PKG card's. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 2049886..14df547 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -526,6 +526,35 @@ next 10 s publish. There is **no option to configure**: the 35 s is derived from the ESPHome publish interval and the two watchdog windows it has to stay inside, and the add-on's own checks refuse a value that does not. +⚠️ **AND IF THE REGISTER DOES NOT REPORT A DIFFERENT VALUE, THE ADD-ON STOPS +WAITING AFTER 17 s (SAFETY-57).** This paragraph used to end at the sentence +above, and on 2026-09-21 that turned out to be a promise the state could not +keep: at 0 W **nothing is asking the register to be anywhere**, so "report a +different value" is evidence a parked site cannot ask for. The live site sat at +0 W for **790 s** while the house exported 2.4 kW, on a register that was +working perfectly — the pack was simply at its floor and the BMS was refusing +the discharge — and it was freed by a person restarting the add-on. + +So: once the standing command has been **under 250 W for 17 s**, the verdict is +**withdrawn** and the register is graded again from scratch over a fresh 35 s. +The whole parked interval is now `stale_input_s` + 17 s — **32 s at the +defaults** — and it does not depend on what the battery decides to do. + +| in the action log | what it means | what to do | +|---|---|---| +| `STALE ` alone, then nothing | the register stopped following and has not come back | look at the unit — comms, the ESP32, the inverter | +| `STALE ` then `STALE WITHDRAWN `, **once** | the pack was probably empty or full and the BMS refused the command. The site has resumed | nothing | +| the pair repeating every ~70 s | the register really is not following, and the add-on is re-testing it | look at the unit; the site is regulating on the meter in between | + +⚠️ **`STALE WITHDRAWN` IS NOT "RECOVERED", AND THE LOG LINE SAYS SO ITSELF.** +Nothing has been learned about the register when it is printed; the verdict is +withdrawn because it can no longer be *tested*, and the next 35 s are the test. +During those 35 s the held number reaches the site's telemetry again. That is +deliberate and it is bounded: 35 s is shorter than both the circulating-power +hold (75 s) and the per-unit sign hold (85 s), so neither of those alarms can be +made to say anything by a held number, and the command itself is derived from +the **meter**, never from this register. + ⚠️ **What this does NOT catch: a register whose value is WRONG rather than STOPPED**, including a dead publisher whose last two samples happen to alternate. Detecting that needs an arrival stamp inside the ESP32 firmware diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 6716ea0..14fec18 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -852,6 +852,50 @@ Cheap habits fall out of it, and all of them are now used in this repository: invented justification for a correct decision is still a defect, and it is the kind a reviewer cannot see because the code is right. +23. ⚠️ **THE MUTATION IS SUBJECT TO EVERY HABIT ABOVE, HABIT 4 INCLUDED - AND + THE PURGE, THE FINGERPRINT AND THE BYTE DELTA ALL SAY IT TOOK.** SAFETY-57's + battery reported a **survivor at +21 B** for a mutation of + `Freshness._open_window()`: `self.graded_from = float(now_mono)` rewritten + as `float(self.changed_mono or now_mono)`, which is meant to re-anchor the + verdict's window to the register's own history and so delete the card's + whole exit. The harness purged, the tree fingerprint showed the edit on + disk, the delta was non-zero, and the mutant really did run. It was still a + survivor for a reason that has nothing to do with the property: in the + fixture the register never moves after `t = 0`, so `changed_mono` is + **`0.0`** - and `0.0 or x` is `x`. **The mutant was a no-op on that + stimulus.** Rewritten with an explicit `is None` test (+82 B) it is red on + four named checks. + + Stale bytecode is a mutation that never *ran*. This is a mutation that ran + and could not *change the answer* on the data it was given, which is habit 4 + exactly, one level up - and none of the three things a mutation report is + required to state (MUTATION.md's own list) can see it, because all three are + about the file rather than about the stimulus. Two rules: + * **Ask of every survivor what it would have had to change, and check that + the fixture reaches that value.** Here: "does any fixture have a non-zero + `changed_mono` at the moment `_open_window()` is called?" The answer was + no, and it is one line to ask. + * **Prefer a mutation that CANNOT be a no-op.** `or`, `and`, truthiness on + a numeric, a default argument and a `getattr` fallback all have a value at + which the mutant and the shipped tree coincide. An explicit comparison, + a constant swap or a deleted statement does not. + + ⚠️ Note where this one was found: in the battery of a card whose whole + subject is a check that cannot reach the failure it names. **Second + instance in an instrument rather than in a check since habit 16, and the + third overall.** + + ⚠️ **AND THE SAME CARD'S BATTERY CAUGHT A HABIT-22 COMMENT IN THE FIX + ITSELF, WHICH IS WHY THESE TWO BELONG NEXT TO EACH OTHER.** + `freshness.py` shipped `⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS + LOAD-BEARING` with a worked reason. Swapping the two calls is **zero bytes** + and survived the battery: `_grade()` acts only when `|standing| >= move_w` + and `_release()` only when `|standing| < move_w`, so at most one can act on + any read and the order is **not observable**. Habit 22 says build X and + measure it; the battery is what builds X. The comment now states the + disjointness and the suite pins **that** - at 0, 100, 249, 250, 251 and + 1300 W - rather than an order nothing depends on. + And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the per-unit claim defect (`device`/`supervised` filed without `inv`), which the diff --git a/goodwe_controller/app/freshness.py b/goodwe_controller/app/freshness.py index f4116bc..5b04f2c 100644 --- a/goodwe_controller/app/freshness.py +++ b/goodwe_controller/app/freshness.py @@ -175,6 +175,124 @@ only one of them is WIRED - see `BOUNDS` below and the note on SoC. integrator, and K. It does NOT owe a second freshness mechanism - `Freshness` below takes its obligation term from the caller precisely so that an energy integral can be handed in where command travel is handed in here. + + +THE EXIT, AND WHY THE FIRST VERSION OF THIS MODULE DID NOT HAVE ONE (SAFETY-57) +============================================================================== +⚠️ THE VERDICT ABOVE IS ABOUT AN OBLIGATION, AND ITS OWN CONSEQUENCE REMOVES +THE OBLIGATION. Declaring the reading stale makes `Controller.inputs_ok` False, +which commands 0 W (§11.1 rule 1). At 0 W nothing is asking the register to be +anywhere - `|standing| >= MOVE_W` is false, so the conjunction cannot arm - and +the class docstring's clearing rule, "an arrival carrying a DIFFERENT number", +asks the register for evidence that only a command could have produced. A pack +sitting at its BMS floor answers the same integer watt for hours. **The state +had no exit reachable from inside it**, and the operator-facing line said the +opposite: *"It clears itself the moment the register reports a different +value."* + +MEASURED ON THE LIVE SITE, 2026-09-21, and committed as +`scenarios/safety57-2026-09-21/` - a passive RS-485 capture, decoded by +`tools/decode_bus_capture.py`: + + 10:51:24.7 0x0518 reaches -3 W. The pack is at 11 % and the loop has just + reversed from -447 W of charge into discharge; the BMS refuses + and the inverter idles. The register is HEALTHY and TRUTHFUL. + 10:51:29.7 the standing command reaches +1060 W. 800 W of travel, and the + register is obliged to follow it. + 10:52:00 the conjunction arms. `Inverter.batt` becomes None. + 10:52:21.7 the site commands 0 W - on the wire, in the capture. + ... 0x0518 does not change value for 850.0 s. The setpoint does not + change value for 790.0 s. Nothing in this add-on can end it. + 11:04 a human restarts the add-on. + 11:05:31.7 the restarted loop commands -400 W of charge, which the pack + ACCEPTS at 11 %, and the register moves 3 s later. + +⚠️ AND IT HAD BEEN ESCAPING BY LUCK. This BMS emits a keep-alive pulse of a few +hundred watts, which twitches 0x0518 and satisfies the clearing rule by +accident. Replaying the committed 16-day loop capture +(`scenarios/loop-2026-09-02_2026-09-17/`) through the shipped conjunction at +1 Hz gives **93 trips**, and the time from each trip to the register's next +change - which was the ONLY exit - has a median of 143 s, 17 episodes at or +over the live one's 710 s, and a worst case of 21440 s. That distribution is a +property of this battery's firmware. It is not a property of this code, and the +add-on must not depend on it. + +THE RULE, and it is SAFETY-13's shape one module over - "which latches get an +exit, not how long the block lasts": + + RELEASE <=> the verdict is standing + AND |standing command| < MOVE_W has held continuously for + >= RELEASE_S + +On a release the latch clears and a NEW grading window opens at that instant: +the register is re-graded, from scratch, against the commands issued AFTER it. +Nothing about the register's own history is rewritten - `changed_mono`, +`held` and `unchanged_s()` go on reporting that the register has not moved for +850 s, because it has not. + +⚠️ `RELEASE_S` IS `BATT_OBLIGED_S`, 17.0 s, AND IT IS THE FLOOR OF ITS BAND +RATHER THAN A PREFERENCE - the mirror of `MOVE_W` taking the ceiling of its +own. The question the bound answers is *has the register had its chance to +answer the command that is now standing?*, and that is exactly the 17.0 s +already derived above: 6.0 s of plant, one 10 s publish, one 1 s read. Below it +the release happens before the cheaper exit - the register moving by itself - +could have occurred. Which half is safe is the opposite of SAFETY-13's, and for +the reason that card states in its §4.2 table: being wrong LONG here is the +failure being fixed (a site parked while it exports), and being wrong SHORT +costs one re-grading window, which is bounded below. + +WHAT A RELEASE COSTS, MEASURED AND NOT ABSORBED - AND IT IS NOT SMALL. For the +duration of the new window the consumers get the held number again, which is +the exposure this module was written to close. Driven at the live install's +shape against a register frozen for good (`test_freshness.py` part 10c), over +1200 s: + + shipped parked 1166 s, the held number reaches a consumer 35 s 2.9 % + SAFETY-57 parked 571 s, the held number reaches a consumer 630 s 52.5 % + +⚠️ **52 % IS THE PRICE OF THE EXIT AND IT IS PAID EVERY CYCLE**, not an +occasional slip. The period is the whole of `stale_input_s` (15 s) + `RELEASE_S` +(17 s) parked, then one `BATT_UNCHANGED_S` (35 s) window of re-grading. It is +bounded by construction, and that bound is the one thing that makes it payable: + + the new window is AT MOST BATT_UNCHANGED_S = 35 s, and 35 < 75 < 85 - the + circulation hold and the per-unit sign hold. NEITHER CONSUMER CAN REACH A + VERDICT INSIDE ONE WINDOW, and a re-trip returns them a None, which CLEARS + both windows rather than accumulating into them. + +That inequality is the same one part 6 already grades, and it is why the window +is `BATT_UNCHANGED_S` rather than a number chosen to make the table above look +better. Neither detector can be made to say anything by a held number under +this scheme - which is exactly the state the shipped code leaves them in as +well, since a permanent None clears both windows for ever. **So nothing that +SAFETY-37 detects is given back.** What the held number does reach for those +35 s is `site_batt_w`, the anti-windup input of `control.compute()`, which +responds to a large unexplained divergence by FREEZING the output - the +conservative direction, and SAFETY-13's business. And the command itself is +derived from the P1 meter, never from `batt`, so a site regulating through a +re-grading window is regulating on the signal it always regulates on. + +**A register frozen under a command that never travels 250 W is still not +detected at all** (survivor 2, unchanged), and a dead publisher whose value +alternates is still invisible (survivor 3). + +⚠️ AND THE NARROWING THIS DOES NOT TAKE, named so the next card can. The live +episode's register refused a DISCHARGE into an 11 % pack and then followed a +CHARGE immediately; a refusal in one direction is not evidence about the other, +and a gate that knew the pack's floor could re-grade on the first command that +crosses it instead of on a timer. That needs SoC, which this module deliberately +does not take (see the `soc` row), and the pack floor, which is SAFETY-32's +number. It would shrink the 52 % above; it is not needed to give the state an +exit, and mixing it in here would make the exit depend on a second measurement +being right. + +⚠️ WHAT THIS DOES NOT DO, because the temptation is to call it a fix for the +whole episode: it does not tell a BMS refusal apart from a dead register. The +site above was parked for 790 s by a register that was working perfectly, and +after this change it is parked for `stale_input_s + RELEASE_S` instead - about +32 s on the shipped default - and then regrades. Telling the two apart needs +the pack's own floor, which is SAFETY-32's number and not this module's input; +the wording the operator reads about it is SAFETY-56's. """ import time @@ -211,6 +329,19 @@ BATT_UNCHANGED_S = 35.0 # and goes red if the two drift. MOVE_W = 250.0 +# SAFETY-57. How long the obligation must have been ABSENT before a standing +# verdict is withdrawn and the register re-graded. See the module docstring's +# last section for the live episode this exists for and for which half of the +# band is the safe one. +# +# ⚠️ IT IS `BATT_OBLIGED_S` AND NOT A NEW NUMBER, and that identity is the +# derivation rather than a convenience. The question is "has the register had +# its chance to answer the command standing NOW?", which is the same physical +# quantity the floor of `BATT_UNCHANGED_S`'s band is: 6.0 s of plant, one 10 s +# ESPHome publish, one 1 s add-on read. Releasing sooner would throw away the +# cheaper exit - the register moving by itself - before it could have happened. +RELEASE_S = BATT_OBLIGED_S # 17.0 + @dataclass(frozen=True) class Bound: @@ -219,6 +350,7 @@ class Bound: signal: str unchanged_s: float move_w: float + release_s: float wired: bool derivation: str @@ -233,13 +365,19 @@ BOUNDS = { signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)", unchanged_s=BATT_UNCHANGED_S, move_w=MOVE_W, + release_s=RELEASE_S, wired=True, derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; " - "circulation_hold_s 75 s ceiling; band [34.0, 37.5]"), + "circulation_hold_s 75 s ceiling; band [34.0, 37.5]. " + "SAFETY-57: the verdict is withdrawn after 17.0 s of the " + "standing command below 250 W, because at 0 W the register " + "is obliged to nothing and the verdict cannot be tested " + "again from inside the state it creates"), "soc": Bound( signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)", unchanged_s=float("inf"), move_w=float("inf"), + release_s=float("inf"), wired=False, derivation="TEL-08. Command travel is the WRONG obligation term for " "SoC - a rested pack holds a percent for hours under a 0 W " @@ -263,10 +401,33 @@ class Freshness: Declaring the reading stale makes `Controller.inputs_ok` False, which commands 0 W (§11.1 rule 1), which drops the standing command below `MOVE_W`, which un-arms the conjunction, which would return the held number - to the consumers - and round again every read. So the ONLY thing that clears - the latch is an arrival carrying a DIFFERENT number, which is exactly the - evidence the verdict was about. A register that is working clears it on its - next 10 s publish; a register that is not, does not. + to the consumers - and round again every read. So an arrival carrying a + DIFFERENT number clears the latch, which is exactly the evidence the verdict + was about. A register that is working clears it on its next 10 s publish; a + register that is not, does not. + + ⚠️ AND IT IS NOT THE ONLY THING THAT CLEARS IT ANY MORE (SAFETY-57), BECAUSE + THAT MADE THE STATE INESCAPABLE FROM INSIDE. The command the verdict itself + produces is 0 W, and at 0 W the register is obliged to be nowhere - so + "report a different value" is evidence a parked site cannot ask for. The + live site sat at 0 W for 790 s on 2026-09-21 with a register that was + working correctly, and was freed by a human. `_release()` below withdraws a + verdict whose obligation has been gone for `bound.release_s`, and opens a + new grading window at that instant. The 1 Hz oscillation the latch exists to + prevent is still prevented: the new window is `bound.unchanged_s` wide, so + no re-trip can land sooner than 35 s after a release, rather than on the + next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if + the site rests for longer than the window before it asks for anything, the + window is already old and the re-trip lands on the first read at which the + command travels 250 W again. That is survivor 5's shape, it predates this + card, and its cost is one `stale_input_s` hold. + + ⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY. + `changed_mono` / `held` are facts about the REGISTER: when it last carried a + different number. `graded_from` / `standing_at_grade` are the window the + VERDICT is computed over, which a release re-opens. They are the same thing + until the first release and diverge afterwards, and `unchanged_s()` - the + number an operator reads - is always the register's. """ def __init__(self, bound: Bound): @@ -281,10 +442,20 @@ class Freshness: # ability to fetch it. self.held = None self.changed_mono = None - # The standing command at that same instant. The conjunction compares - # against THIS, not against the previous cycle's command: what matters - # is whether the register has followed anything since it last moved. - self.standing_at_change = None + # SAFETY-57. The window the VERDICT is graded over, as opposed to the + # register's own history above, and the standing command at the instant + # it opened. The conjunction compares against THIS, not against the + # previous cycle's command: what matters is whether the register has + # followed anything since the current question was put to it. A register + # that moves opens a window, so until the first release these two are + # the change-stamps; `_release()` is where they diverge. + self.graded_from = None + self.standing_at_grade = None + # The instant the standing command last FELL below `move_w`, or None + # while an obligation is standing. The release timer runs off this. + self.rested_since = None + self.released_at = None + self.releases = 0 self.stale = False self.reason = "" self.arrivals = 0 @@ -311,20 +482,67 @@ class Freshness: if self.changed_mono is None or value != self.held: self.held = value self.changed_mono = float(now_mono) - self.standing_at_change = (None if standing_w is None - else float(standing_w)) - # Movement is the ONLY thing that clears the latch. See the class - # docstring on the 1 Hz oscillation this prevents. + self._open_window(standing_w, now_mono) + # Movement clears the latch, because it is exactly the evidence the + # verdict was about. See the class docstring on the 1 Hz oscillation + # the latch prevents, and `_release()` for the other way out. self.stale = False self.reason = "" self.last = value + # ⚠️ THE ORDER OF THESE TWO IS NOT OBSERVABLE, AND THIS COMMENT SAYS SO + # BECAUSE THE FIRST VERSION OF IT CLAIMED THE OPPOSITE. Swapping them is + # a ZERO-BYTE edit and it SURVIVED the battery; the reason is structural + # rather than a missing fixture, so no fixture could have closed it: + # `_grade()` acts only when `|standing| >= move_w` and `_release()` only + # when `|standing| < move_w`, so at most one of them can do anything on + # any given read, whichever runs first. Release is written first because + # it reads in the order the state machine moves - withdraw, then + # re-grade - not because the code depends on it. + self._release(standing_w, now_mono) self._grade(standing_w, now_mono) + def _open_window(self, standing_w, now_mono) -> None: + """Start grading the register again, from this instant (SAFETY-57). + + Called when the register moves - the window and the register's history + coincide there - and when a verdict is withdrawn, where they do not. + """ + self.graded_from = float(now_mono) + self.standing_at_grade = (None if standing_w is None + else float(standing_w)) + + def _release(self, standing_w, now_mono) -> None: + """SAFETY-57. Withdraw a verdict whose obligation is no longer standing. + + ⚠️ THE TIMER RUNS ON THE COMMAND, NOT ON THE VERDICT. `rested_since` is + maintained on every arrival, latched or not, so the elapsed rest is the + real one and not "time since we last looked". An obligation reappearing + resets it to None, which is the whole point: a site that goes back to + asking the register for 250 W of movement has put the question back, and + the verdict stands until the question is withdrawn again. + """ + obliged = (standing_w is not None + and abs(float(standing_w)) >= self.bound.move_w) + if obliged: + self.rested_since = None + return + if self.rested_since is None: + self.rested_since = float(now_mono) + if not self.stale: + return + if float(now_mono) - self.rested_since < self.bound.release_s: + return + self.released_at = float(now_mono) + self.releases += 1 + self.stale = False + self.reason = "" + self._open_window(standing_w, now_mono) + def _grade(self, standing_w, now_mono) -> None: """The conjunction. Sets the latch; never clears it.""" - if self.stale or self.changed_mono is None: + if self.stale or self.graded_from is None: return - held_s = self.unchanged_s(now_mono) + held_s = self.graded_s(now_mono) if held_s < self.bound.unchanged_s: return # ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN @@ -333,30 +551,64 @@ class Freshness: # consumer today. They are here for TEL-08, whose obligation term is an # energy integral that has nothing to say until it has a window - and # "nothing to say" must withhold the verdict, not manufacture one. - if standing_w is None or self.standing_at_change is None: + if standing_w is None or self.standing_at_grade is None: return standing_w = float(standing_w) if abs(standing_w) < self.bound.move_w: return - moved_w = abs(standing_w - self.standing_at_change) + moved_w = abs(standing_w - self.standing_at_grade) if moved_w < self.bound.move_w: return self.stale = True + # ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN + # NUMBER, NOT THE WINDOW'S. They are the same until the first release + # and different afterwards, and an operator comparing this line against + # a history graph is reading the register. + shown_s = self.unchanged_s(now_mono) self.reason = ( - f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s " + f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s " f"while the standing command moved {moved_w:.0f} W to " f"{standing_w:+.0f} W - the reading is not following, so it is " f"reported as MISSING rather than as a measurement (limits: " f"unchanged over {self.bound.unchanged_s:.0f} s, command over " f"{self.bound.move_w:.0f} W and moved by over " f"{self.bound.move_w:.0f} W)") + # ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each + # in its own slot: the register's unchanged time is in the sentence + # above, the WINDOW this verdict actually rests on is here, and so is + # how many times this unit has been round the loop. Without the second + # one the sentence reads as though 92 s of unchanged register were what + # was compared against the 35 s limit, and it was not. + if self.releases: + self.reason += ( + f" [re-graded: the {held_s:.0f} s window this verdict rests on " + f"opened when the previous one was withdrawn, not when the " + f"register last moved - this unit has now been graded " + f"{self.releases + 1} times]") def unchanged_s(self, now_mono) -> float | None: - """Seconds since an arrival carried a different number. Status page.""" + """Seconds since an arrival carried a different number. Status page. + + ⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release + re-opens the grading window; it does not make the register younger, and + an operator reading "unchanged for 12 s" off a register that has held + one value for 850 s would be reading this module's bookkeeping back as a + measurement. `graded_s()` is the other one and is named differently for + that reason. + """ if self.changed_mono is None: return None return max(0.0, float(now_mono) - self.changed_mono) + def graded_s(self, now_mono) -> float | None: + """Seconds the CURRENT verdict window has been open (SAFETY-57). + + Equal to `unchanged_s()` until the first release, and shorter after one. + """ + if self.graded_from is None: + return None + return max(0.0, float(now_mono) - self.graded_from) + def reading(self): """What the consumers get: the value, or None when it cannot be trusted. @@ -366,6 +618,13 @@ class Freshness: rather than accumulating it, and `circulation.circulating_w` returns None for a roster with a missing term, which clears the circulation window too. This module is the thing that was missing between them. + + ⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING + (SAFETY-57). That is the exit, and it is bounded rather than free: the + window a release opens is `bound.unchanged_s` = 35 s, shorter than both + consumer holds (75 s and 85 s), so neither can reach a verdict inside + one - and a re-trip hands them a None, which CLEARS their windows. See + the module docstring's last section. """ return None if self.stale else self.last @@ -373,6 +632,26 @@ class Freshness: """One line for the status page. Empty when there is nothing to say.""" return f"{unit_name}: {self.reason}" if self.stale else "" + def release_note(self, unit_name: str) -> str: + """SAFETY-57's release, for the log. Empty when nothing was withdrawn. + + ⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading + "recovered" would be a lie: nothing has been learned about the register. + The verdict was withdrawn because it can no longer be tested, and the + next 35 s are the test. + """ + if self.released_at is None: + return "" + return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat " + f"below {self.bound.move_w:.0f} W for " + f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT " + f"THE REGISTER - it is still holding {self.held:+.0f} - but a " + f"verdict about a command that is no longer standing cannot be " + f"tested, and the 0 W this one produces is what stopped it " + f"being tested. The register is now re-graded over a fresh " + f"{self.bound.unchanged_s:.0f} s window; if it still does not " + f"follow, this returns") + def monotonic() -> float: """The clock this module's callers use, in one place so tests can find it. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 48519d1..b62287a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1769,6 +1769,7 @@ class Controller: inv.soc = await self.hass.number(inv.soc_entity) raw = await self.hass.number(inv.batt_entity, inv.batt_invert) was_stale = inv.batt_fresh.stale + was_releases = inv.batt_fresh.releases inv.batt_fresh.submit(raw, inv.standing_w(), now_mono) # ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input, # not a measurement - `inputs_ok` sees the None, the site holds for @@ -1777,11 +1778,37 @@ class Controller: inv.batt = inv.batt_fresh.reading() if inv.batt_fresh.stale and not was_stale: self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason) + # ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING + # ELSE IN IT. The escape clause used to read "It clears itself the + # moment the register reports a different value", full stop - and + # that was FALSE for the state this same line announces, because the + # 0 W it announces in the sentence before is exactly what stops the + # register ever being asked to report anything different. The live + # site sat in it for 790 s on 2026-09-21 and was freed by a human. + # ⚠️ THE DIAGNOSIS HALF IS SAFETY-56'S AND IS DELIBERATELY UNTOUCHED + # HERE - "is not following its own command" is wrong in a different + # way (a pack the BMS is refusing is not a faulty register) and that + # card is rewriting it. Two cards editing one sentence is how one of + # them silently loses. _LOG.error("SAFETY-37: %s battery-power register is not following " "its own command - %s. Treating it as MISSING, which " "commands 0 W. It clears itself the moment the register " - "reports a different value.", - inv.name, inv.batt_fresh.reason) + "reports a different value, and failing that %.0f s " + "after the standing command falls below %.0f W " + "(SAFETY-57: a verdict whose own consequence is a 0 W " + "command cannot be tested again from inside it).", + inv.name, inv.batt_fresh.reason, + inv.batt_fresh.bound.release_s, + inv.batt_fresh.bound.move_w) + elif inv.batt_fresh.releases > was_releases: + # ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a + # timer and says nothing is a latch no operator can audit - and this + # one is withdrawing a SITE STOP. `release_note()` is written to say + # that nothing has been learned about the register, because nothing + # has. + self.log_event(f"STALE WITHDRAWN {inv.name}") + _LOG.warning("SAFETY-57: %s", + inv.batt_fresh.release_note(inv.name)) @property def shadow(self) -> bool: diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index df56481..f2aa493 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.10.0" +version: "0.10.1" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart diff --git a/goodwe_controller/scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz b/goodwe_controller/scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz new file mode 100644 index 0000000..ee0871f Binary files /dev/null and b/goodwe_controller/scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz differ diff --git a/goodwe_controller/scenarios/safety57-2026-09-21/manifest.json b/goodwe_controller/scenarios/safety57-2026-09-21/manifest.json new file mode 100644 index 0000000..caf355e --- /dev/null +++ b/goodwe_controller/scenarios/safety57-2026-09-21/manifest.json @@ -0,0 +1,70 @@ +{ + "generated": "2026-09-21T11:30:00+02:00", + "card": "SAFETY-57", + "what": "The 2026-09-21 episode: a healthy 0x0518 declared stale, the site parked at 0 W, and no exit reachable from inside the state.", + "source": { + "host": "sniffer@192.168.2.167 (goodwe-sniff)", + "nature": "PASSIVE listener on the RS-485 pair between the ESP32 master and the GW5000S-BP. It has no address on the bus and writes nothing.", + "file": "~/bus-a.log", + "window_local": [ + "2026-09-21T10:30:00+02:00", + "2026-09-21T11:10:00+02:00" + ], + "timezone": "Europe/Brussels (the capture stamps a time of day and no date; the date is asserted here, not derivable from the file)", + "extraction": "lines whose time-of-day field lies in the window, verbatim, in capture order" + }, + "file": { + "name": "bus-a-2026-09-21-1030-1110.log.gz", + "sha256_gz": "876fdaaf6d087125b184bfb4dd7ef4c3d1dd71ccad0fc271f8d268e8b489d134", + "sha256_plain": "fa07e13b60e230626a8c0964b9539783a71b5627a522fc5930fd9fda54438a48", + "bytes_gz": 17181, + "bytes_plain": 182400, + "lines": 3840 + }, + "decoder": "tools/decode_bus_capture.py - and it is the ONLY interpretation of these bytes this repository carries. Re-run it to re-derive every number below.", + "frame_census": { + "lines": 3840, + "unparsed": 0, + "bad_crc": 0, + "requests": 720, + "responses": 720, + "writes": 1200, + "write_echoes": 1200, + "unknown": 0 + }, + "series": { + "setpoint_w": { + "frames": 1200, + "changes": 224, + "first_s": 37801.687, + "last_s": 40199.693 + }, + "batt_w": { + "frames": 240, + "changes": 133, + "first_s": 37804.716, + "last_s": 40194.724 + }, + "soc_pct": { + "frames": 240, + "changes": 4, + "first_s": 37806.725, + "last_s": 40196.732 + } + }, + "the_episode_in_the_capture": { + "10:51:24.736": "0x0518 reaches -3 W. The pack is at 11 %, the loop has just reversed out of -447 W of charge, and the BMS refuses the discharge.", + "10:51:29.695": "the setpoint reaches +1060 W of discharge - 800 W of travel from the command standing when the register last moved.", + "10:52:21.695": "the setpoint is written as 0 W and stays there. This is the add-on's own `inputs missing -> command 0 W`, on the wire.", + "10:51:24.736..11:05:34.734": "0x0518 does not change value. 850.0 s.", + "10:52:21.695..11:05:31.698": "the setpoint does not change value. 790.0 s.", + "~11:04": "a human restarts the add-on. NOT VISIBLE IN THIS FILE - it is an action on 192.168.2.158 - and it is the only reason the episode ends.", + "11:05:31.698": "the restarted loop commands -400 W of CHARGE, which the pack accepts at 11 %.", + "11:05:34.734": "0x0518 answers -137 W. The register was healthy throughout." + }, + "not_in_this_file": [ + "the grid meter - a P1 telegram on a different wire. The 2.4 kW of export during the episode is from Home Assistant history and is NOT re-derivable here.", + "anything the add-on believed: Inverter.batt, inputs_ok, log lines. Those are INFERRED by replaying the shipped modules against these series (test_freshness.py part 10).", + "the restart at ~11:04." + ] +} \ No newline at end of file diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py index ab6affd..d7c4a6b 100644 --- a/goodwe_controller/test_freshness.py +++ b/goodwe_controller/test_freshness.py @@ -49,10 +49,14 @@ before you report a survivor. import ast import asyncio +import dataclasses import glob import gzip +import hashlib +import importlib.util import io import json +import logging import sys import time from datetime import datetime, timezone @@ -500,11 +504,13 @@ for t in range(40, 50): _after_zero.append(_lat.reading()) check("PRECONDITION: it is stale before the site is stopped", _was and _lat.reading() is None) -check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no " - "longer armed - |standing| is 0 W, below the 250 W term - so an unlatched " - "verdict would hand the held -1200 W straight back and the site would " - "restart into it, then stop, then restart, once per read", - all(v is None for v in _after_zero)) +check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W, for RELEASE_S. The " + "conjunction is no longer armed - |standing| is 0 W, below the 250 W " + "term - so an unlatched verdict would hand the held -1200 W straight " + "back and the site would restart into it, then stop, then restart, once " + "per read. The ten reads here are inside the 17.0 s SAFETY-57 waits", + all(v is None for v in _after_zero) and len(_after_zero) == 10 + and 10 < F.RELEASE_S) # ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the # mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED # the whole sweep - no fixture had ever injected a read failure while latched. @@ -526,11 +532,26 @@ check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING " _after_fail is None and _lat_fail.stale and _lat_fail.reading() is None) -check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is " - "exactly the evidence the verdict was about. Not a timer, not a restart, " - "not the operator's start button", - (lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0), - (not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat)) +# ⚠️ THIS CHECK USED TO READ "AND ONLY MOVEMENT CLEARS IT ... Not a timer, not +# a restart, not the operator's start button", AND SAFETY-57 IS THE CARD THAT +# FALSIFIED IT. It was driven on `_lat` - already 20 s into a 0 W rest - so +# under the shipped exit it would now pass having never tested movement at all, +# which is habit 9: a check that goes on being true for a reason it does not +# name. It is rebuilt here on a FRESH object that has never rested, so the only +# thing that can clear it is the arrival. +_mov = Freshness(BOUNDS["batt"]) +for t in range(0, 40): + _mov.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) +_mov_was = _mov.stale +_mov.submit(A_FROZEN_W + 1.0, 1300.0, 40.0) # the register moves one watt +check("⚠️ MOVEMENT CLEARS IT WHILE THE OBLIGATION IS STILL STANDING - a " + "different number arriving under a command that has NOT rested, which is " + "exactly the evidence the verdict was about. Not a restart, not the " + "operator's start button. SAFETY-57's timer cannot be what cleared this " + "one: the command was +1300 W on every read, so the release clock never " + "started", + _mov_was and not _mov.stale and _mov.reading() == A_FROZEN_W + 1.0 + and _mov.releases == 0 and _mov.rested_since is None) # ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A # MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from # `_grade()` left every other check in this file green, because in all of them a @@ -542,12 +563,12 @@ _rest = Freshness(BOUNDS["batt"]) # BEFORE the command settles - and then the site is commanded to 0 W and the # register sits at +5 W, correctly, for a minute. _rest.submit(1290.0, 1300.0, 0.0) -_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W +_rest.submit(5.0, 1300.0, 1.0) # standing_at_grade captures 1300 W for t in range(2, 60): _rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation " "to show it. A register that reached +5 W while the old +1300 W command " - "was still standing has a `standing_at_change` of 1300 W, so the TRAVEL " + "was still standing has a `standing_at_grade` of 1300 W, so the TRAVEL " "term alone reads 1300 W of unfollowed command for the whole minute the " "site rests - and calls a perfectly truthful reading dead. Requiring " "|standing| >= 250 W as well is what says 'a site at rest is not owed " @@ -564,7 +585,7 @@ check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which " check("...and one watt is enough, because 0x0518 is a 1 W register that " "dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured " "2026-08-22) - a working register clears this on its next 10 s publish", - _lat.held == A_FROZEN_W + 1.0 and not _lat.stale) + _mov.held == A_FROZEN_W + 1.0 and not _mov.stale) # ============================================================================== @@ -655,6 +676,7 @@ _unit_min_w = option_default("unit_sign_disagree_w") _ceiling = min(_circ_hold, _unit_hold) print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0") print(f" bound {BATT_UNCHANGED_S:.1f} s") +print(f" exit {F.RELEASE_S:.1f} s (SAFETY-57, the same obligation window)") print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, " f"unit_sign_disagree_s {_unit_hold:.0f})") @@ -690,6 +712,24 @@ check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "raise `unit_sign_disagree_w` and the gate would stop arming in states " "that check still fires in, which this catches", MOVE_W == _unit_min_w) +check("⚠️ AND SAFETY-57'S EXIT SITS INSIDE THAT SAME BAND, AT ITS FLOOR - the " + "mirror of MOVE_W taking its own band's ceiling. The question a release " + "answers is 'has the register had its chance to answer the command " + "standing now?', which is the obligation window and nothing else, so the " + "exit is exactly the floor and strictly shorter than the bound whose " + "verdict it withdraws. An exit at or above the bound would mean the site " + "spends longer parked than the evidence that parked it took to gather", + F.RELEASE_S == BATT_OBLIGED_S and F.RELEASE_S < BATT_UNCHANGED_S + and BOUNDS["batt"].release_s == F.RELEASE_S) +check("⚠️ AND THE WHOLE PARKED INTERVAL IS NOW BOUNDED BY TWO SHIPPED NUMBERS " + "AND NOTHING ELSE: `stale_input_s` (15 s, config.yaml) then `release_s` " + "(17.0 s) = 32 s. The schema caps `stale_input_s` at 120, so even the " + "worst accepted configuration parks for 137 s - where the live episode " + "parked for 790 s and had no upper bound at all", + float(option_default("stale_input_s")) + F.RELEASE_S == 32.0 + and "stale_input_s: int(5,120)" in io.open( + HERE / "config.yaml", encoding="utf-8").read() + and 120.0 + F.RELEASE_S == 137.0) check("...and the module's three floor terms are the numbers this repository " "actually carries, not a transcription of them: the ESPHome publish " "interval is read back out of the shipped firmware", @@ -734,7 +774,7 @@ _uses_batt = bounds_users("batt") _uses_soc = bounds_users("soc") for _k, _b in BOUNDS.items(): print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} " - f"move={_b.move_w:>6} {_b.signal}") + f"move={_b.move_w:>6} release={_b.release_s:>6} {_b.signal}") print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}") print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}") @@ -752,10 +792,17 @@ check("...and the SoC row says what TEL-08 owes, in the row itself, so the " "energy" in BOUNDS["soc"].derivation and "153.6 Wh" in BOUNDS["soc"].derivation and "TEL-08" in BOUNDS["soc"].derivation) -check("...and every wired bound is a real number - an infinite bound is how a " - "row silently becomes decorative", +check("...and every wired bound is a real number IN ALL THREE COLUMNS - an " + "infinite bound is how a row silently becomes decorative, and SAFETY-57's " + "`release_s` is the newest place that can happen: at `inf` the module is " + "byte-for-byte the one that parked the live site for 790 s", all(b.unchanged_s < float("inf") and b.move_w < float("inf") + and b.release_s < float("inf") for b in BOUNDS.values() if b.wired)) +check("...and the UNWIRED row is infinite in all three, so nothing about " + "SoC's absent bound can be read as a policy that exists", + all(b.unchanged_s == b.move_w == b.release_s == float("inf") + for b in BOUNDS.values() if not b.wired)) # ============================================================================== @@ -809,6 +856,96 @@ check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what th max(_runs)[1] == -899.0) +# --- 8b. AND THE EXIT THE BOUND USED TO HAVE, ON THE SAME HOUSE ------------- +# ⚠️ THIS IS THE MEASUREMENT SAFETY-57 RESTS ON, and it is about the REGISTER +# rather than about any code: before this card the only way out of the verdict +# was "an arrival carrying a different number", so the length of the state IS +# the time to 0x0518's next change. The committed 16-day loop capture is +# replayed at 1 Hz through the SHIPPED conjunction to find the trips, and each +# trip is then measured against the register's own next move. +# +# ⚠️ WHAT THIS REPLAY IS AND IS NOT. The capture runs 2026-09-02..17 and 0.10.0 +# - the first version carrying this gate - was cut on 2026-09-16, so the +# setpoint column is an UNGATED controller's. That makes each trip an honest +# register-versus-command divergence off the real bus, and it makes the +# post-trip command column something a gated box would NOT have written. The +# figure taken from it is therefore only the one that does not depend on the +# command at all: how long this register goes without changing value. If +# anything it UNDER-states the wait, because a site parked at 0 W gives the +# register even less reason to move than the one recorded here. +_LOOPDIR = HERE / "scenarios" / "loop-2026-09-02_2026-09-17" +_loop_days = sorted({Path(f).name.rsplit("-", 1)[0] + for f in glob.glob(str(_LOOPDIR / "*-batt.jsonl.gz"))}) + + +def _loop_series(day, sig): + out = [] + with gzip.open(_LOOPDIR / f"{day}-{sig}.jsonl.gz", "rt") as fh: + for line in fh: + row = json.loads(line) + try: + v = float(row["v"]) + except ValueError: + continue # 'unavailable' / 'unknown' are not readings + out.append((datetime.fromisoformat(row["t"]).timestamp(), v)) + return out + + +_t8b = time.monotonic() +_waits, _trips8b = [], 0 +for _day in _loop_days: + _b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint") + if not _b or not _s: + continue + _moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]] + _f8b, _prev8b = Freshness(BOUNDS["batt"]), False + _ib = _is = 0 + _t = max(_b[0][0], _s[0][0]) + _end = min(_b[-1][0], _s[-1][0]) + while _t <= _end: + while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t: + _ib += 1 + while _is + 1 < len(_s) and _s[_is + 1][0] <= _t: + _is += 1 + _f8b.submit(_b[_ib][1], _s[_is][1], _t) + if _f8b.stale and not _prev8b: + _trips8b += 1 + _nxt = next((m for m in _moves if m > _t), None) + if _nxt is not None: + _waits.append(_nxt - _t) + _prev8b = _f8b.stale + _t += 1.0 +_waits.sort() +_p50 = _waits[len(_waits) // 2] +print(f" {len(_loop_days)} days replayed at 1 Hz in " + f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips") +print(f" wait for the register's next change after a trip: " + f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; " + f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's " + f"710 s") + +check("PRECONDITION: all sixteen committed days were replayed, not whichever " + "files happened to be present", + len(_loop_days) == 16 and _loop_days[0] == "2026-09-02" + and _loop_days[-1] == "2026-09-17" and len(_waits) == _trips8b) +check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips in " + "sixteen days of this house, and the only thing that could clear any of " + "them - the register carrying a different number - took a MEDIAN of " + "143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at " + "or beyond the 710 s the live site was parked for on 2026-09-21", + _trips8b == 93 and abs(_p50 - 143.4) < 0.1 + and abs(_waits[-1] - 21440.1) < 0.1 + and sum(1 for w in _waits if w >= 710.0) == 17) +check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - " + "which is the whole reason the exit could not stay as it was. The short " + "waits are the pack's keep-alive pulse twitching 0x0518 and satisfying " + "the clearing rule BY ACCIDENT: a quarter of them are under 20 s, and " + "nothing in this add-on asked for any of them. After SAFETY-57 the wait " + "is `stale_input_s + release_s` = 32 s whatever the register does", + sum(1 for w in _waits if w < 20.0) >= 20 + and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0) + + # ============================================================================== print() print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises") @@ -871,6 +1008,627 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure " "each unit's own command travel and each unit's own number", _s4a.stale and _s4b.stale and _s4a.held != _s4b.held) +# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card. +# The unchanged clock keeps running while the site rests, so the FIRST read +# at which a command travels 250 W after a long quiet already satisfies all +# three terms - the register is given no time at all to answer the new +# command. Present on the shipped tree; the live capture in part 10 contains +# one (10:48:14, cleared one second later). +_s5 = Freshness(BOUNDS["batt"]) +for t in range(0, 300): # five minutes of a resting site + _s5.submit(-3.0, 0.0, float(t)) +_s5_quiet = _s5.stale +_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W +check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a " + "long rest the unchanged clock is already past 35 s, so the very FIRST " + "read carrying a travelled command trips the gate - before the 17.0 s in " + "which a healthy register could have answered it. The gate accuses on " + "read one and the register answers on read eleven", + not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0) +check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded " + "here rather than fixed here: `cycle()` returns at `inputs missing " + "{n}s` for the whole of it, so nothing is commanded differently, and the " + "register's next publish clears it. Fixing it means measuring the " + "unchanged clock from when the OBLIGATION appeared rather than from the " + "register's last move, which re-derives the 35.0 s bound and is a " + "different card", + (lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5) + and float(option_default("stale_input_s")) >= 5.0) + + +# ============================================================================== +print() +print("10. SAFETY-57: THE EXIT. The state the verdict creates, and the way out") + +# ⚠️ THE ACCEPTANCE CRITERION OF THIS CARD, IN ONE SENTENCE, AND EVERY CHECK +# BELOW IS GRADED AGAINST IT: a scenario in which the register is held CONSTANT +# and the command is gated must recover WITH NO CHANGE TO THE REGISTER. Parts 1 +# to 9 grade whether the gate can be ENTERED. A check that only shows a latch +# latching is decoration for this card. +# +# ⚠️ THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS NOT A TRANSCRIPTION. +# `_release()` with an infinite bound never fires, `_open_window()` is then only +# ever called where the register moves, and `graded_from`/`standing_at_grade` +# are `changed_mono`/the command at that change - which is byte-for-byte the +# pre-SAFETY-57 conjunction. That equivalence is ASSERTED in 10b against part +# 1's own fixtures rather than claimed here, because a counterfactual nobody +# graded is a second implementation. +_BEFORE = dataclasses.replace(BOUNDS["batt"], release_s=float("inf")) + + +def _decoder(): + """The committed decoder, loaded from `tools/` rather than transcribed.""" + spec = importlib.util.spec_from_file_location( + "_s57decode", HERE / "tools" / "decode_bus_capture.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def hms(t): + return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}" + + +def _raises(fn): + try: + fn() + except ValueError: + return True + return False + + +# `stale_input_s` is READ from the shipped config.yaml, not typed here: the +# parked half of every period below is that option, and a check that carried its +# own copy would go on being true after an operator moved it. +_S57_HOLD = float(option_default("stale_input_s")) + + +def _dead_site(bound, reads=1201, dead_w=A_FROZEN_W, hold_s=None): + """A register frozen for good, with §11.1 rule 1 driving the command. + + Returns `(freshness, exposure)` where `exposure[i]` is True when read `i` + handed the consumers a number. The command rule is the one `cycle()` uses - + hold the standing command for `stale_input_s`, then 0 W - and the loop's + own ask when telemetry is available; it is written here rather than driven + through `cycle()` because that hold runs on the WALL clock and the bound + under test runs on the injected one. 10g drives the real path for one + period instead. + """ + hold_s = _S57_HOLD if hold_s is None else hold_s + f = Freshness(bound) + standing, bad_since, exposure = dead_w, None, [] + for t in range(0, reads): + f.submit(dead_w, standing, float(t)) + live = f.reading() is not None + exposure.append(live) + if live: + bad_since = None + standing = 1300.0 if t >= 1 else dead_w + else: + bad_since = t if bad_since is None else bad_since + standing = standing if t - bad_since < hold_s else 0.0 + return f, exposure + + +def _run_lengths(exposure): + """The lengths of the unbroken runs of True in `exposure`.""" + out, n = [], 0 + for live in exposure: + if live: + n += 1 + elif n: + out.append(float(n)) + n = 0 + if n: + out.append(float(n)) + return out + + +def _release_instant(release_s): + """Seconds after the command rests at which the verdict is withdrawn. + + One stimulus, one varied term (habit 7): the same 40 s of unfollowed + +1300 W command, then a resting site, under each candidate bound. + """ + f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=release_s)) + for t in range(0, 40): + f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) + for t in range(40, 200): + f.submit(A_FROZEN_W, 0.0, float(t)) + if not f.stale: + return float(t) - 40.0 + return None + + +# --- 10a. the capture, and what it is allowed to be evidence of -------------- +_D = _decoder() +_CAPDIR = HERE / "scenarios" / "safety57-2026-09-21" +_CAPFILE = _CAPDIR / "bus-a-2026-09-21-1030-1110.log.gz" +_man = json.loads(io.open(_CAPDIR / "manifest.json", encoding="utf-8").read()) +_events, _census = _D.decode(_CAPFILE) +_sp = _D.series(_events, "setpoint_w") +_bt = _D.series(_events, "batt_w") +_soc = _D.series(_events, "soc_pct") +print(f" capture {_CAPFILE.name}: {_census}") +print(f" setpoint {len(_sp)} frames batt {len(_bt)} frames " + f"soc {len(_soc)} frames, " + f"{hms(_sp[0][0])}..{hms(_sp[-1][0])} Europe/Brussels") + +check("PRECONDITION: the committed capture is the file the manifest names, " + "byte for byte. A scenario whose bytes can drift from the numbers quoted " + "off them is a story", + hashlib.sha256(io.open(_CAPFILE, "rb").read()).hexdigest() + == _man["file"]["sha256_gz"] + and _man["file"]["bytes_gz"] == _CAPFILE.stat().st_size) +check("PRECONDITION: every line of it was understood. 3840 frames, and the " + "decoder's own census reports ZERO unparsed, ZERO bad CRC and ZERO " + "unknown shapes - so nothing in the episode below was dropped on the " + "floor by the instrument", + _census["lines"] == 3840 and _census["unparsed"] == 0 + and _census["bad_crc"] == 0 and _census["unknown"] == 0 + and _census["writes"] == 1200 and _census["responses"] == 720) +# ⚠️ THE SIGN IS THE ONE THING THIS DECODER COULD GET EXACTLY BACKWARDS, and a +# backwards sign would invert the whole episode - a discharge the BMS refused +# would read as a charge it accepted. So the mode/sign correspondence is GRADED +# OFF THE CAPTURE rather than trusted: every charge command is followed by a +# negative register reading, and there is no reading of the other sign. +_after_charge = [ + _D.held_at(_bt, t + F.BATT_OBLIGED_S) + for t, v in _sp if v <= -1000.0] +_after_charge = [v for v in _after_charge if v is not None] +print(f" readings {F.BATT_OBLIGED_S:.0f} s after a <= -1000 W command: " + f"{len(_after_charge)} of them, " + f"max {max(_after_charge):+.0f} W, min {min(_after_charge):+.0f} W") +check("⚠️ THE DECODER'S SIGN CONVENTION IS GRADED, NOT ASSERTED. EMS mode 2 is " + "read as charge and mode 3 as discharge; if that were backwards the " + "episode would read as a refused CHARGE. Every command at or under " + "-1000 W in this capture is answered, one obligation window later, by a " + "NEGATIVE register reading - and not one positive one", + len(_after_charge) > 200 and max(_after_charge) < 0.0 + and _D.signed_setpoint_w(2, 2170) == -2170.0 + and _D.signed_setpoint_w(3, 1060) == +1060.0) +check("...and an EMS mode this decoder has never seen RAISES rather than " + "defaulting to a sign. A silent default is how a firmware change becomes " + "a mis-signed series nobody re-derives", + _raises(lambda: _D.signed_setpoint_w(1, 500)) + and _raises(lambda: _D.signed_setpoint_w(0, 0))) + + +# --- 10b. THE LIVE EPISODE, RED BEFORE AND GREEN AFTER ----------------------- +def _replay_capture(bound): + """The shipped `_read_unit` loop, at 1 Hz, against the capture. + + ⚠️ THE COMMAND COLUMN IS NOT A MODEL. The capture was taken off a box + running 0.10.0, i.e. running the defect: the 0 W at 10:52:21 IS the site's + own `inputs missing -> command 0 W`, recorded on the wire. So this replay is + closed-loop for the whole of the parked interval, and the only thing in the + window it cannot account for is the human restart at ~11:04. + """ + f = Freshness(bound) + t0 = int(max(_sp[0][0], _bt[0][0])) + 1 + t1 = int(min(_sp[-1][0], _bt[-1][0])) + rows, t = [], float(t0) + while t <= t1: + f.submit(_D.held_at(_bt, t), _D.held_at(_sp, t), t) + rows.append((t, f.stale, f.held, _D.held_at(_sp, t), f.releases)) + t += 1.0 + return f, rows + + +def _edges(rows): + return [r for i, r in enumerate(rows) if i == 0 or r[1] != rows[i - 1][1]] + + +_f_before, _rows_before = _replay_capture(_BEFORE) +_f_after, _rows_after = _replay_capture(BOUNDS["batt"]) + +# ⚠️ THE EPISODE'S BOUNDARIES ARE READ OFF THE CAPTURE, NOT TYPED. It is +# defined by the thing it is about - the register's LONGEST unbroken hold in +# the window - and every other instant is derived from that one. A fixture that +# named 10:52:00 by hand would go on naming it after the file changed. +# ⚠️ CHANGE POINTS, NOT FRAMES. 0x0518 is POLLED every 10 s and answers every +# poll, so consecutive frames are always 10 s apart whatever the register is +# doing - a gap computed over frames measures the poll period and nothing else, +# which is `freshness.py`'s own first paragraph in miniature. +_bt_moves = [r for i, r in enumerate(_bt) if i == 0 or r[1] != _bt[i - 1][1]] +_holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0], + _bt_moves[i + 1][0], _bt_moves[i][1]) + for i in range(len(_bt_moves) - 1)] +_HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds) +_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM) +_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0) +_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0) +_blips = [t for t, s, _h, _sw, _r in _edges(_rows_after) + if s and t < _HOLD_FROM] +print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, " + f"{hms(_HOLD_FROM)}..{hms(_REG_MOVES)}") +print(f" trip {hms(_TRIP)} site commands 0 W {hms(_PARK)} " + f"human restart's first command {hms(_HUMAN)} " + f"register moves {hms(_REG_MOVES)}") +print(f" earlier one-read trips in this window (survivor 5): " + f"{[hms(t) for t in _blips]}") + + +def _first_clear_after(rows, t_from): + return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None) + + +_clear_before = _first_clear_after(_rows_before, _TRIP) +_clear_after = _first_clear_after(_rows_after, _TRIP) +print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear " + f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s") +print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale " + f"{hms(_TRIP)} -> clear {hms(_clear_after)} " + f"{_clear_after - _TRIP:.0f} s") + +check("PRECONDITION: `release_s=inf` REPRODUCES the pre-SAFETY-57 module and is " + "not a second implementation of it. Part 1's own fresh/stale pair, driven " + "through both bounds, gives bit-identical verdict streams - the release " + "is the only behaviour the counterfactual removes", + drive(_BEFORE, STREAM, lambda t: A_FROZEN_W)[2] == _fresh_read + and drive(_BEFORE, STREAM, + lambda t: A_FROZEN_W if t < 1 else 1300.0)[2] == _stale_read) +check("PRECONDITION: the episode is in the capture where the manifest says it " + "is, and it is the window's WORST hold rather than one picked out of " + "several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms " + "at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and " + "nothing else moves until a human does at 11:05:31", + abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0 + and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00" + and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31" + and hms(_REG_MOVES) == "11:05:34" + and sorted(_holds)[-2][0] < 0.5 * _HOLD_S) +check("⚠️ RED BEFORE. On the shipped-before module the verdict outlives the " + "whole parked interval: from the trip to the first command a HUMAN put " + "on the wire, 811 s later, `reading()` is None on every one of the 811 " + "reads - and the register never changes value in any of them, so there " + "is nothing the module could have cleared on", + all(s for t, s, _h, _sw, _r in _rows_before if _TRIP <= t < _HUMAN) + and len([1 for t, s, _h, _sw, _r in _rows_before + if _TRIP <= t < _HUMAN]) == 812 + and {v for t, v in _bt if _TRIP <= t < _HUMAN} <= {-3.0} + and _clear_before > _HUMAN) +check("⚠️ AND WHAT ENDS IT ON THE SHIPPED-BEFORE MODULE IS OUTSIDE THE MODULE. " + "The clear lands three seconds after the register moves, and the register " + "moves because a person restarted the add-on and the restarted loop " + "commanded a CHARGE the pack accepted. Nothing inside this add-on " + "produced that command", + _clear_before > _REG_MOVES and _clear_before - _REG_MOVES <= 2.0 + and _HUMAN < _REG_MOVES) +check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The " + "verdict is withdrawn 17 s after the site's own 0 W reaches the wire, " + "while 0x0518 is still answering the same -3 W it has answered since " + "10:51:24, and `reading()` is a number again with no external change of " + "any kind", + _PARK + BOUNDS["batt"].release_s <= _clear_after + < _PARK + BOUNDS["batt"].release_s + 1.0 + and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1 + and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0 + and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0) +check("...and the recovery is worth 776 s of this one episode: the site is " + "handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 " + "of those seconds are BEFORE the human touched anything. That is the " + "13 minutes it spent parked while the house exported 2.4 kW", + _clear_before - _clear_after == 776.0 + and abs((_HUMAN - _clear_after) - 772.7) < 0.01 + and all(not s for t, s, _h, _sw, _r in _rows_after + if _clear_after <= t < _HUMAN)) +check("⚠️ AND THE RELEASE STICKS RATHER THAN CHATTERING. From the withdrawal to " + "the human's first command the verdict is withdrawn on every one of the " + "773 reads, and exactly ONE release was taken in the whole episode - the " + "1 Hz oscillation the latch exists to prevent is still prevented, " + "because the window a release opens is 35 s wide", + len([1 for t, s, _h, _sw, _r in _rows_after + if _clear_after <= t < _HUMAN]) == 773 + and _f_after.releases == 1) + + +# --- 10c. what a release costs, measured ------------------------------------- +# ⚠️ THE EXPOSURE IS THE NUMBER THE SAFETY ARGUMENT RESTS ON, so it is counted +# rather than reasoned about: how many reads hand a consumer the held number +# after a release, and how long the longest unbroken run of them is. The whole +# argument is that one run is shorter than either consumer's hold. +_dead, _exp57 = _dead_site(BOUNDS["batt"]) +_deadb, _expb = _dead_site(_BEFORE) +_runs57 = _run_lengths(_exp57) +print(f" register dead for good, {len(_exp57)} reads, " + f"stale_input_s={_S57_HOLD:.0f}") +print(f" before : held number reached a consumer on {sum(_expb)} reads " + f"({100 * sum(_expb) / len(_expb):.1f} %), runs={_run_lengths(_expb)}") +print(f" after : held number reached a consumer on {sum(_exp57)} reads " + f"({100 * sum(_exp57) / len(_exp57):.1f} %), releases={_dead.releases}, " + f"longest run={max(_runs57):.0f} s") + +check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register " + "that is dead for good is re-graded for ever: over 1201 reads the held " + "number reaches a consumer on 630 of them against 35 before, and 17 " + "releases are taken. This check exists so that cost cannot be quietly " + "forgotten, and so a change that makes it worse has to move a number " + "somebody reads", + sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17 + and _deadb.releases == 0) +check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER " + "CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s " + "grading window, and 35 < 75 (circulation_hold_s) < 85 " + "(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside " + "one, and the re-trip that ends each run hands them a None, which CLEARS " + "their windows rather than accumulating into them", + set(_runs57) == {BOUNDS["batt"].unchanged_s} + and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold) +check("...and nothing SAFETY-37 detects is given back by that, which is the " + "half that makes the trade a trade. A permanent None clears both windows " + "for ever too, so on the shipped-before module the same two detectors are " + "equally unable to speak about this unit. The exposure buys the site its " + "meter-derived control back and costs no detection that existed", + disagrees(1300.0, None, True, _cfg3) is False + and circulating_w([1300.0, None]) is None) + + +# --- 10d. the bound, graded as a family -------------------------------------- +# ⚠️ ASSERTING `release_s == BATT_OBLIGED_S` WOULD BE COMPARING THE SHIPPED +# EXPRESSION AGAINST ITSELF (habit 2, and MAINT-05's whole card). The literal is +# asserted, the three terms it is built from are asserted, and then the BAND is +# graded behaviourally against its neighbours. +print(f" RELEASE_S {F.RELEASE_S:.1f} s = settle {F.PLANT_SETTLE_S:.1f} " + f"+ publish {F.PUBLISH_PERIOD_S:.1f} + read {F.READ_PERIOD_S:.1f}") +check("⚠️ THE BOUND IS 17.0 s AND IT IS THE PHYSICAL OBLIGATION WINDOW, " + "asserted against the literal rather than against the expression that " + "produces it. Same three terms as the floor of `BATT_UNCHANGED_S`'s " + "band, because it is the same question: has the register had its chance " + "to answer the command standing NOW?", + BOUNDS["batt"].release_s == 17.0 and F.RELEASE_S == 17.0 + and F.PLANT_SETTLE_S + F.PUBLISH_PERIOD_S + F.READ_PERIOD_S == 17.0) +_family = {r: _release_instant(r) for r in (5.0, 16.0, 17.0, 18.0, 35.0, + float("inf"))} +for _r, _at in sorted(_family.items(), key=lambda kv: kv[0]): + print(f" release_s {_r:>5} -> withdrawn at t+{_at}" + if _at is not None else f" release_s {_r:>5} -> never") +check("⚠️ THE FAMILY IS COMPUTED AND PRINTED, so a bound no fixture separates " + "shows up as a named survivor. Each candidate withdraws the verdict " + "exactly its own number of seconds after the command rests, and they are " + "six DISTINCT answers - a check that could not tell 17 from 35 could not " + "tell this card from a slower version of the bug", + _family == {5.0: 5.0, 16.0: 16.0, 17.0: 17.0, 18.0: 18.0, 35.0: 35.0, + float("inf"): None}) +# ⚠️ AND THE FLOOR IS GRADED BEHAVIOURALLY, WHICH IS THE HALF A CONSTANT CHECK +# CANNOT DO. Below 17.0 s the timer fires before a healthy register could have +# published its answer to the 0 W command - so it takes the expensive exit when +# the free one was one read away. The register here answers at the obligation +# window's own edge, which is the case the bound is derived from. +_free_exit = {} +for _r in (5.0, 17.0): + _f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=_r)) + for t in range(0, 40): + _f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) + for t in range(40, 40 + int(F.BATT_OBLIGED_S) - 1): + _f.submit(A_FROZEN_W, 0.0, float(t)) # resting, register not yet in + _f.submit(-1150.0, 0.0, float(40 + int(F.BATT_OBLIGED_S) - 1)) + _free_exit[_r] = (_f.releases, _f.stale, _f.held) +print(f" register answers the 0 W command at +16 s: {_free_exit}") +check("⚠️ THE FLOOR BITES, AND IT BITES ON BEHAVIOUR. A register that answers " + "the new 0 W command inside its own obligation window clears the latch " + "BY MOVING - the free exit - and at 17.0 s that is what happens, with " + "`releases` still 0. At 5.0 s the timer has already fired and the site " + "has paid for a re-grading window it did not need", + _free_exit[17.0][0] == 0 and _free_exit[5.0][0] == 1 + and _free_exit[17.0][2] == _free_exit[5.0][2] == -1150.0) + + +# --- 10e. the release does not rewrite the register's history ---------------- +_hist = Freshness(BOUNDS["batt"]) +for t in range(0, 40): + _hist.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) +for t in range(40, 120): + _hist.submit(A_FROZEN_W, 0.0, float(t)) +print(f" at t=119 after one release: unchanged_s={_hist.unchanged_s(119.0)} " + f"graded_s={_hist.graded_s(119.0)} releases={_hist.releases}") +check("⚠️ TWO CLOCKS, AND THE ONE THE OPERATOR READS IS THE REGISTER'S. A " + "release re-opens the VERDICT's window; it does not make the register " + "younger. At t=119 the register has not carried a different number for " + "119 s and `unchanged_s` says 119 s, while the grading window opened at " + "the release at t=57 and says 62 s. A single clock here would either lie " + "to the operator or defeat the re-grading", + _hist.unchanged_s(119.0) == 119.0 and _hist.graded_s(119.0) == 62.0 + and _hist.releases == 1 and _hist.held == A_FROZEN_W + and _hist.changed_mono == 0.0 and _hist.graded_from == 57.0) + + +# --- 10f. the obligation coming back cancels the timer ----------------------- +_resume = Freshness(BOUNDS["batt"]) +for t in range(0, 40): + _resume.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) +for t in range(40, 40 + int(BOUNDS["batt"].release_s) - 1): + _resume.submit(A_FROZEN_W, 0.0, float(t)) # 16 s of rest +_resume.submit(A_FROZEN_W, 1300.0, 56.0) # the command comes back +_resume_mid = (_resume.stale, _resume.rested_since) +for t in range(57, 57 + int(BOUNDS["batt"].release_s) - 1): + _resume.submit(A_FROZEN_W, 1300.0, float(t)) # and stays +check("⚠️ A TIMER THAT DOES NOT RESET IS NOT A TIMER. Sixteen seconds of rest, " + "one second short of the bound, and then the site puts the obligation " + "back: the clock is cleared, not carried, so the verdict stands. " + "Carrying it would withdraw a verdict about a command that is standing " + "RIGHT NOW", + _resume_mid == (True, None) and _resume.stale + and _resume.releases == 0 and _resume.reading() is None) +_resume.submit(A_FROZEN_W, 0.0, 80.0) +for t in range(81, 81 + int(BOUNDS["batt"].release_s)): + _resume.submit(A_FROZEN_W, 0.0, float(t)) +check("...and it starts again from the SECOND rest, not from the first: the " + "release lands 17.0 s after t=80, which is 41 s after the first rest " + "began. The total time spent resting is not what the bound is about", + _resume.releases == 1 and _resume.released_at == 97.0 + and not _resume.stale) + +# ⚠️ AND "NO LONGER STANDING" IS `MOVE_W`, NOT SOME OTHER THRESHOLD IN THE SAME +# OBJECT. Found by mutating the SIBLING (habit 11): `_release()`'s test against +# `bound.move_w` swapped for `bound.unchanged_s` SURVIVED the whole file at +# +5 B, because every fixture above rests at exactly 0 W and 0 is below both +# 35 and 250. The band between them is where they separate, and a site can sit +# in it - a peak-guard floor, a shadow substitution or any claim that wins with +# a small non-zero number leaves a command there while the verdict stands. +_band = {} +for _w in (0.0, 100.0, 300.0): + _f = Freshness(BOUNDS["batt"]) + for t in range(0, 40): + _f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) + for t in range(40, 40 + 2 * int(BOUNDS["batt"].release_s)): + _f.submit(A_FROZEN_W, _w, float(t)) + _band[_w] = _f.releases +print(f" releases after 34 s of a standing command of 0 / 100 / 300 W: " + f"{_band}") +check("⚠️ THE OBLIGATION TEST IS THE 250 W ONE, AND THE 100 W CASE IS WHAT " + "SAYS SO. A command of 100 W is below `move_w`, so the register is owed " + "nothing and the verdict is withdrawn exactly as at 0 W; a command of " + "300 W is above it, so the question is still standing and the verdict " + "is too. Every other fixture in this file rests at 0 W, where `move_w` " + "and `unchanged_s` cannot be told apart", + _band == {0.0: 1, 100.0: 1, 300.0: 0} + and BOUNDS["batt"].unchanged_s < 100.0 < BOUNDS["batt"].move_w < 300.0) + +# ⚠️ AND THE ONE EQUIVALENT MUTANT THIS CARD FOUND, ARGUED RATHER THAN ROUNDED +# OFF. Swapping `_release()` and `_grade()` in `submit()` is ZERO BYTES and +# survived the whole battery. It is not a missing fixture and no fixture can +# close it: the two act on MUTUALLY EXCLUSIVE predicates - `_grade()` only when +# `|standing| >= move_w`, `_release()` only when `|standing| < move_w` - so at +# most one of them can do anything on any read. This check pins the disjointness +# rather than the order, which is the property that actually holds. +_disjoint = [] +for _w in (0.0, 100.0, 249.0, 250.0, 251.0, 1300.0): + _a = Freshness(BOUNDS["batt"]) + _b = Freshness(BOUNDS["batt"]) + for t in range(0, 40): + for _f in (_a, _b): + _f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) + for t in range(40, 80): + _a._release(_w, float(t)) + _a._grade(_w, float(t)) + _b._grade(_w, float(t)) + _b._release(_w, float(t)) + _disjoint.append((_w, _a.stale == _b.stale + and _a.releases == _b.releases + and _a.graded_from == _b.graded_from)) +print(f" release-then-grade vs grade-then-release, by standing command: " + f"{_disjoint}") +check("⚠️ AN ARGUED EQUIVALENT MUTANT, NOT A SURVIVOR NOBODY LOOKED AT. " + "Swapping the two calls in `submit()` is zero bytes and changes nothing " + "on either side of `move_w`, including AT it - because the two are gated " + "on mutually exclusive tests and cannot both act on one read. The " + "docstring used to claim the order was load-bearing; it is not, and the " + "battery is what said so", + all(same for _w, same in _disjoint) and len(_disjoint) == 6) + + +# --- 10g. through the REAL read path, and the operator is told --------------- +_LOGREC = [] + + +class _S57Handler(logging.Handler): + def emit(self, record): + _LOGREC.append(record) + + +logging.getLogger("goodwe").addHandler(_S57Handler()) +logging.getLogger("goodwe").setLevel(logging.DEBUG) + + +async def _wired_release(): + """The whole chain at N=1: real `read_inputs`, real `Inverter`, real log.""" + c, hass = one_site() + seen = [] + for t in range(0, 40): + hass.numbers["sensor.batt_a"] = A_FROZEN_W + c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0 + await c.read_inputs(now_mono=float(t)) + seen.append((t, c.inverters[0].batt, c.inputs_ok)) + for t in range(40, 40 + int(BOUNDS["batt"].release_s) + 2): + hass.numbers["sensor.batt_a"] = A_FROZEN_W # the register NEVER moves + c.inverters[0].last_write = 0.0 # the site's own 0 W + await c.read_inputs(now_mono=float(t)) + seen.append((t, c.inverters[0].batt, c.inputs_ok)) + return c, seen + + +_c57, _seen57 = run(_wired_release()) +_trip57 = next(t for t, b, _ok in _seen57 if b is None) +_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None) +print(f" real read path: batt None from t={_trip57}, a number again at " + f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout") +check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. " + "`Inverter.batt` goes None at the bound, `inputs_ok` goes False - and " + "17.0 s after the site's own 0 W it is a NUMBER again and `inputs_ok` is " + "True, with `sensor.batt_a` serving the identical -1200.0 on every one " + "of those reads", + _trip57 == 35 and _back57 == 57 + and all(v == A_FROZEN_W for _e, v in _c57.hass.served + if _e == "sensor.batt_a") + and _seen57[_trip57][2] is False and _seen57[-1][2] is True + and _c57.inverters[0].batt == A_FROZEN_W) +check("...and the site stops holding inputs bad, which is what ends the " + "`inputs missing -> command 0 W` claim §11.1 rule 1 files", + _c57.inputs_bad_since is None and _c57.inputs_ok is True) +_ev57 = [e for e in _c57.events if "STALE" in e] +_msgs57 = [r.getMessage() for r in _LOGREC] +_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m] +print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}") +check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR " + "CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach " + "the event log and the logger, in order, naming the unit", + len(_ev57) == 2 and "STALE inverter-a" in _ev57[1] + and "STALE WITHDRAWN inverter-a" in _ev57[0] + and len(_rel57) == 1 and "inverter-a" in _rel57[0]) +check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has " + "happened: it says in its own words that nothing has been learned about " + "the register, and it prints the value the register is STILL holding", + "NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0] + and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0]) + + +# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) --------------------- +# ⚠️ AN `in` TEST AGAINST A FORMATTED SENTENCE GRADES PRESENCE, NEVER +# ARRANGEMENT. Both of the sentences this card writes carry two numbers that +# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new +# escape clause, and 250 W and 17.0 s again in the release note - so each is +# asserted with its value embedded in its own literal position. +_stale57 = [m for m in _msgs57 if "SAFETY-37" in m] +print(f" escape clause: ...{_stale57[0][-150:]}") +check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED " + "PAIR. It used to end 'It clears itself the moment the register reports " + "a different value.' - which was FALSE, because the 0 W in the sentence " + "before it is what stops the register being asked. The new clause names " + "17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes " + "red", + f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing " + f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0]) +check("...and the release note names 250 W in the POWER slot and 17 s in the " + "TIME slot, in that order, which is the reverse arrangement of the same " + "two numbers", + f"sat below {BOUNDS['batt'].move_w:.0f} W for " + f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0]) +check("...and the 35.0 s the register is re-graded over is named in the note " + "too, because it is what the operator is being told to wait out", + f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window" + in _rel57[0]) +check("⚠️ AND THE DIAGNOSIS HALF OF THE STALE LINE IS UNTOUCHED, WHICH IS " + "SAFETY-56'S. That card is rewriting 'is not following its own command' " + "- the live episode's register was following perfectly and the pack was " + "simply empty - and two cards editing one sentence is how one of them " + "silently loses. This check is here so that rewrite is a deliberate act", + "battery-power register is not following its own command" in _stale57[0]) +check("...and a re-trip's reason names the TWO durations separately, each " + "beside its own value, because after a release the verdict's window and " + "the register's own unchanged time are different facts", + (lambda f: ( + [f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t)) + for t in range(0, 40)], + [f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)], + [f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)], + f.stale and f.releases == 1 + and "unchanged for 92 s" in f.reason + and "the 35 s window this verdict rests on opened when the previous " + "one was withdrawn" in f.reason + and "graded 2 times" in f.reason)[-1])( + Freshness(BOUNDS["batt"]))) + print() if fails: diff --git a/goodwe_controller/tools/decode_bus_capture.py b/goodwe_controller/tools/decode_bus_capture.py new file mode 100644 index 0000000..18f56fb --- /dev/null +++ b/goodwe_controller/tools/decode_bus_capture.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""SAFETY-57 - turn a passive RS-485 capture into the three series the loop sees. + +`python3 tools/decode_bus_capture.py scenarios/safety57-2026-09-21/bus-a-*.log.gz` + +WHY THE RAW CAPTURE IS WHAT IS COMMITTED, AND NOT A DECODED TABLE. The other +scenario directories in this repository hold Home Assistant recorder exports - +a number per row, already interpreted. This one holds MODBUS FRAMES off the +wire, because the episode it records is one in which the add-on's own view and +the wire's disagree, and a decoded table would be this decoder's opinion with +the evidence thrown away. MUTATION.md habit 8: a committed transcript is a +check, and a check has to be re-derivable from something. + +WHAT THE CAPTURE IS. `sniffer@192.168.2.167` is a passive listener on the +RS-485 pair between the ESP32 master and the GW5000S-BP. It writes nothing; it +has no address on the bus. Every line is one frame it heard: + + a|10:51:29.695 ok F7 10 05 6E 00 02 04 00 03 04 24 ... + ^ ^ ^ ^ + | | | the frame, hex + | | CRC verdict as the capture tool computed it + | local wall clock, Europe/Brussels, no date - see `decode()` + bus label + +THE FOUR FRAME SHAPES THIS FILE UNDERSTANDS, and it deliberately understands no +others - an unrecognised frame is COUNTED and dropped, never guessed at: + + F7 03 a read REQUEST + F7 03 <2*count> its response, matched to the request + immediately preceding it + F7 10 05 6E 00 02 04 a write of the two EMS registers + F7 10 05 6E 00 02 the inverter's echo of that write + +⚠️ THE SETPOINT IS A MODE AND A MAGNITUDE, NOT A SIGNED NUMBER, and getting +that backwards is the one way this decoder could quietly invert the whole +episode. `0x056E` is the EMS mode and `0x056F` the magnitude in watts, +unsigned. Mode 2 is charge and mode 3 is discharge, and that is READ OFF THIS +CAPTURE rather than off a datasheet: in the committed window, every +`mode 2, 2170 W` write is followed within one 10 s publish by an 0x0518 +reading near **-2287 W**, and `mode 3, 1060 W` by a POSITIVE-side idle. The +add-on's own sign convention (`Inverter.standing_w()`, + = discharge) is +therefore `+magnitude` for mode 3 and `-magnitude` for mode 2, and +`test_freshness.py` part 10a grades that correspondence on the capture itself +rather than trusting this paragraph. + +WHAT THIS DECODER CANNOT SEE, largest hole first (habit 13/16): + + * **THE GRID METER.** It is not on this bus - it is a P1 telegram on a + different wire - so nothing here says what the site was importing or + exporting. The 2.4 kW export during the episode is from the operator's + Home Assistant history and is NOT re-derivable from this file. + * **WHAT THE ADD-ON BELIEVED.** The capture is the wire. It shows the + commands that arrived and the readings that were answered; it does not show + `Inverter.batt`, `inputs_ok`, or any log line. Everything about the add-on's + internal state in this episode is INFERRED by replaying the shipped modules + against these series, which is what part 10 does. + * **DATE AND YEAR.** The capture tool stamps a time of day and nothing else. + The date is supplied by the caller and is recorded in the scenario's + `manifest.json` beside the sha256 of the file it came from. + * **A FRAME THE CAPTURE TOOL SCORED BAD.** Lines whose CRC verdict is not + `ok` are counted and dropped. In the committed window there are none. + * **ANY OTHER REGISTER.** 0x0511 (a two-register read this master issues) is + recognised as a read and its values are returned untouched under its own + address; nothing here claims to know what it means. +""" + +import gzip +import io +import re +import sys + +# The registers this file will name. Anything else is returned by raw address. +REG_SETPOINT_MODE = 0x056E # written as a pair with 0x056F +REG_BATT_W = 0x0518 # S16, watts, + = discharge (SAFETY-37) +REG_SOC_PCT = 0x050E # U16, percent +MODE_CHARGE, MODE_DISCHARGE = 2, 3 + +_LINE = re.compile(r"^(\w+)\|(\d\d):(\d\d):(\d\d(?:\.\d+)?)\s+(\S+)\s+(.*)$") + + +def signed_setpoint_w(mode: int, magnitude: int) -> float: + """The EMS pair as `Inverter.standing_w()` spells it: + = discharge. + + ⚠️ AN UNKNOWN MODE RAISES rather than defaulting. A default here would turn + a firmware that started using a third mode into a silently mis-signed + series, which is the failure this whole scenario is about one layer down. + """ + if mode == MODE_DISCHARGE: + return float(magnitude) + if mode == MODE_CHARGE: + return -float(magnitude) + raise ValueError(f"unknown EMS mode {mode} at 0x{REG_SETPOINT_MODE:04X}; " + f"this decoder knows {MODE_CHARGE} (charge) and " + f"{MODE_DISCHARGE} (discharge) only") + + +def _s16(v: int) -> int: + return v - 65536 if v >= 32768 else v + + +def decode(path): + """Decode one capture file. Returns `(events, stats)`. + + `events` is a list of `(t, kind, value)` in capture order, where `t` is + SECONDS SINCE LOCAL MIDNIGHT (the capture carries no date) and `kind` is + one of `"setpoint_w"`, `"batt_w"`, `"soc_pct"`, or `"reg_0xNNNN"`. + + `stats` counts every line by what became of it, so a caller can assert that + the frames it cares about were actually present rather than that the parse + produced no error. + """ + opener = gzip.open if str(path).endswith(".gz") else io.open + events = [] + stats = {"lines": 0, "unparsed": 0, "bad_crc": 0, "requests": 0, + "responses": 0, "writes": 0, "write_echoes": 0, "unknown": 0} + pending = None # the read request awaiting its response + with opener(path, "rt") as fh: + for line in fh: + line = line.strip() + if not line: + continue + stats["lines"] += 1 + m = _LINE.match(line) + if not m: + stats["unparsed"] += 1 + continue + _bus, hh, mm, ss, verdict, hexs = m.groups() + if verdict != "ok": + stats["bad_crc"] += 1 + continue + t = int(hh) * 3600 + int(mm) * 60 + float(ss) + try: + b = [int(x, 16) for x in hexs.split()] + except ValueError: + stats["unparsed"] += 1 + continue + # --- a read request: remember it so its response can be addressed + if len(b) == 8 and b[1] == 0x03: + pending = ((b[2] << 8) | b[3], (b[4] << 8) | b[5]) + stats["requests"] += 1 + continue + # --- a read response, addressed by the request before it + if (b[1] == 0x03 and pending is not None and len(b) >= 5 + and b[2] == 2 * pending[1] and len(b) == b[2] + 5): + data = b[3:3 + b[2]] + regs = [(data[i] << 8) | data[i + 1] + for i in range(0, len(data), 2)] + addr = pending[0] + pending = None + stats["responses"] += 1 + if addr == REG_BATT_W: + events.append((t, "batt_w", float(_s16(regs[0])))) + elif addr == REG_SOC_PCT: + events.append((t, "soc_pct", float(regs[0]))) + else: + events.append((t, f"reg_0x{addr:04X}", tuple(regs))) + continue + # --- a write of the EMS pair + if (len(b) > 8 and b[1] == 0x10 and b[2] == 0x05 and b[3] == 0x6E + and b[6] == 4): + data = b[7:11] + mode = (data[0] << 8) | data[1] + mag = (data[2] << 8) | data[3] + events.append((t, "setpoint_w", signed_setpoint_w(mode, mag))) + stats["writes"] += 1 + continue + if len(b) == 8 and b[1] == 0x10: + stats["write_echoes"] += 1 + continue + stats["unknown"] += 1 + return events, stats + + +def series(events, kind): + """`[(t, value)]` for one kind, in capture order.""" + return [(t, v) for t, k, v in events if k == kind] + + +def held_at(rows, t): + """The last value at or before `t`, or None before the first row. + + This is how the add-on sees an entity: Home Assistant answers with whatever + the publisher last put there, and a value that has not been re-published is + still the value. See `freshness.py` on why that is the whole problem. + """ + out = None + for ts, v in rows: + if ts > t: + break + out = v + return out + + +def _hms(t): + return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}" + + +def main(argv): + if len(argv) != 2: + print(__doc__.splitlines()[2].strip()) + return 2 + events, stats = decode(argv[1]) + print(f"== {argv[1]}") + for k, v in stats.items(): + print(f" {k:14} {v}") + for kind in ("setpoint_w", "batt_w", "soc_pct"): + rows = series(events, kind) + if not rows: + continue + changes = [r for i, r in enumerate(rows) + if i == 0 or r[1] != rows[i - 1][1]] + print(f"== {kind}: {len(rows)} frames, {len(changes)} changes, " + f"{_hms(rows[0][0])} .. {_hms(rows[-1][0])}") + for t, v in changes: + print(f" {_hms(t)} {v:+10.1f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))