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