From eb8864689987040e19866d13e6cf2c889fe8669e Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Tue, 22 Sep 2026 15:22:03 +0200 Subject: [PATCH] PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0 The Supervisor decides an update exists by comparing config.yaml's version string with the one the box reports. master and release/1.0 both said 0.10.0, so SAFETY-57 - a deadlock that parked the reference battery for ~13 min on 2026-09-21 while the house exported 2.4 kW - had no way of reaching the box that has it. A patch bump is the delivery mechanism, not bookkeeping. - config.yaml: 0.10.0 -> 0.10.1. Nothing else in the option surface moves: no key added or removed, no default changed, no enum widened, no rule added to validate.py, so a blob that starts on 0.10.0 starts on 0.10.1. - CHANGELOG.md: a new `## 0.10.1` section, holding SAFETY-57's entry moved out of `## 0.10.0`. The entry opened by saying it was misfiled and that whoever cut the next version owned moving it; this is that cut, so that paragraph is replaced by what an operator actually needs in order to decide - what the deadlock does (command parked at 0 W, batt reading gated to MISSING, escape condition unreachable from inside the state), how to recognise it (setpoint stuck at exactly 0 W with the battery register flat, a STALE line and no clearing line) and that a restart of the add-on clears it. - PKG-16 proper: `## 0.10.0`'s preamble still claimed 0.10.0 "has never been built or pulled anywhere" and that the reference install was on 0.7.0. Both stopped being true on 2026-09-20 ~22:18 CEST, when 0.10.0 was installed as a repository add-on (slug 68285ac7_goodwe_controller) and began regulating a real battery. The section's own bump argument was explicitly conditional on that; the condition has fired, so the argument is retold as history and the section is closed. This text is what the Supervisor shows beside Update. Also fixes, in this file only, the three non-blocking documentation defects raised on PR #104 - issuecomments 1445, 1446 and 1447: - 1446: the live episode is 790 s, not 710 s. Re-derived rather than rescaled: at >= 790 s the committed 16-day replay gives 16 of 93, not 17. - 1447: the BMS keep-alive pulse was an inference stated as a measurement. No committed capture identifies one, and on this episode 0x0518 never moved at all. The measured claim replaces the firmware story. - 1445: "neither consumer can reach a verdict inside one window" is universal as written and graded on a fixture that puts the obligation back on every release. It now carries the qualifier the reviewer asked for, and names the unbounded case and why the shipped one-inverter roster cannot reach it. The same three defects also sit in app/freshness.py, test_freshness.py, DOCS.md and the DECISIONS note. They are deliberately NOT touched here: this PR is config.yaml and CHANGELOG.md only, and none of them is worth delaying a live safety fix. SAFETY-56 and SAFETY-58 are likewise untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 296 ++++++++++++++++++++++----------- goodwe_controller/config.yaml | 2 +- 2 files changed, 203 insertions(+), 95 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 57702f9..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`): @@ -249,82 +433,6 @@ clean up after - and it is still not an operator-facing change. If a future CI ticket changes what the shipped image contains, it belongs here; this one does not. -### SAFETY-57 — the stale-reading verdict gets an exit - -⚠️ **THIS ENTRY IS FILED UNDER 0.10.0 AND 0.10.0 IS NO LONGER UNSHIPPED, WHICH -THIS SECTION'S OWN PREAMBLE SAYS IS THE MOMENT TO STOP FOLDING INTO IT.** The -preamble above, written 2026-09-19, argues that `0.10.0` "does not yet name a -tree anyone is running" and makes the argument **conditional on that remaining -true**. It is no longer true: 0.10.0 is on the reference install, and this card -exists because of a defect **measured on that install on 2026-09-21**. The row -is here only because `test_version_gate.py` part 11b requires the newest `## ` -heading to be a version rather than `Unreleased`, so a feature branch has -nowhere else to put one. **Whoever cuts the next version owns moving it**, and -`tools/predeploy_check.py` will refuse a deploy that folds into a shipped -0.10.0 by itself. - -⚠️ **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: the BMS emits a keep-alive pulse that twitches -the register and satisfies the clearing rule by accident. Replaying the -committed 16-day loop capture through the shipped conjunction finds **93 trips** -whose wait for that accident has a **median of 143 s** and a worst case of -**5 h 57 m**, with **17 of the 93** at or beyond this episode's 710 s. That -distribution belongs to the battery's firmware, not to this add-on. - -**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`. - -⚠️ **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. - ### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed ⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in 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