diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index ab9d298..02a046e 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,307 @@ # Changelog +## 0.11.0 + +**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT: +`pv_entity` ([[TEL-11]]).** The option surface moves - **75 keys -> 76**, +`options:` and `schema:` in step - which is the same test 0.8.0, 0.9.0 and +0.10.0 were each cut on. ⚠️ **AND UNLIKE 0.10.0, NOTHING HERE CAN REFUSE A +STORED OPTIONS BLOB.** No key was removed, no default moved, no enum widened, +and **no rule was added to `validate.py`** - the validator does not mention +`pv_entity` at all, because nothing else reads it and it cannot conflict with +anything. The new key is `str?` - **note the trailing `?`, it is OPTIONAL** - +with an `options:` default of `""`, so a blob written on 0.10.0 or 0.10.1 does +not carry it, the Supervisor's merge supplies the empty string, and the empty +string is *off*. ⚠️ **A stored options blob that starts the add-on on 0.10.1 +starts it on 0.11.0. There is no `SystemExit(78)` to plan around on this hop**, +and a site that presses *Update* and changes nothing else gets an add-on that +behaves exactly as its 0.10.1 did. + +⚠️ **WHAT YOU ARE ACTUALLY DECIDING, IN ONE LINE EACH. TWO TICKETS HERE ARE +OBSERVABLE ON AN INSTALLED ADD-ON, AND NEITHER CHANGES A WATT.** + +* [[SAFETY-56]] - **three messages stop naming your hardware as the fault** in a + state where the add-on cannot tell a dead register from a battery that is + simply empty at its BMS floor. **Text only; the behaviour is measured + byte-identical.** If you have ever opened Developer Tools because the add-on + told you a sensor had failed, and found the sensor working, this is that + defect. On the reference install the state it misdiagnoses arrives on most + nights and on every cloudy morning after a deep discharge. +* [[TEL-11]] - **one new optional key, `pv_entity`, and nothing whatever happens + until you fill it in.** Set it and the add-on records what your solar is + producing beside what the meter and the battery are doing. ⚠️ **It is not a + control input and it does not change what the loop does.** + +⚠️ **THERE IS NO CONTROL-LAW CHANGE IN THIS RELEASE, WHICH IS WORTH SAYING +BECAUSE 0.10.0 HAD ONE AND NOTHING IN ITS PREAMBLE COULD WARN YOU.** +`app/control.py` is not in this release's diff at all, and neither are +`allocator.py`, `arbiter.py`, `arbitrage.py`, `clamp.py`, `peakguard.py`, +`maintenance.py` or `watchdog.py`. The runtime files that move are +`app/freshness.py` and `app/main.py` (SAFETY-56's three sentences), and +`app/pv.py` - new - with `app/main.py`, `app/mqtt.py`, `config.yaml` and +`translations/` (TEL-11). Contrast [[SAFETY-48]] in `## 0.10.0`, which altered +what the inverter is asked to do while touching no option, no default and no +validator rule, so that nothing a config check could see would mention it. This +release has nothing of that shape in it. + +⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS +STILL NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons +update` does not walk the versions; it installs 0.11.0 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**, added in 0.8.0, against 0.7.0's own shipped +> default `slew_w: 1000` - the ceiling is 2/18 of the site rail, which is +> **555.6 W** on a 5000 W rail. +> +> [[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 0.11.0**, with the same message and for the same +> measured reasons: a 404 from a gateway with no route, a TLS handshake against +> a hostname the certificate does not cover, and a 404 with an empty body. + +⚠️ **NEITHER OF THOSE IS NEW IN 0.11.0, AND NEITHER IS NEWS TO A SITE ALREADY ON +0.10.0 OR 0.10.1 - IT HAS ALREADY MET THEM.** They are restated here only +because this is now the newest section, and a reader who opens the newest section +and stops would otherwise never meet the one class of thing that can stop the +add-on starting. **The full account of both is in `## 0.10.0` below**: what to +set instead of each withdrawn id, which tariff a site left on one of them has +really been running, why the refusal *is* the migration, and why all three ids +stay in `config.yaml`'s `price_provider` enum even though `PROVIDERS` is down to +three. **That is the section that withdrew them, it is where the evidence is, +and it is not superseded by this one.** ⚠️ The reference install is on +`price_provider: off` and at `slew_w: 400`, so neither applies to it. +`tools/predeploy_check.py` checks both against your live options blob before you +press *Update*, and on this tree it reports a real version change rather than +0.10.1's no-op. + +Contents of this release, in merge order since 0.10.1 (`b0b24e5`): + +| | | +|---|---| +| `c9aaf7f` | SAFETY-56 - the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can **⚠️ TEXT ONLY, BEHAVIOUR BYTE-IDENTICAL** | +| `94da37f` | TEL-11 - PV production stops being invisible, and stays out of the control law **⚠️ NEW OPTIONAL OPTION, UNSET ON EVERY EXISTING INSTALL** | + +⚠️ **TWO MORE MERGES LANDED IN THIS WINDOW AND ARE DELIBERATELY NOT IN THE TABLE +ABOVE: [[CI-9]]'S FOLLOW-UP (`9f6b9f1`) AND [[SEC-2]] (`239e120`). THE RULE IS +[[PKG-15]]'S - AUDIENCE, NOT IMPORTANCE - AND BOTH FAIL IT THE SAME WAY.** This +file is the **add-on's** release note: the text the Supervisor renders beside the +version number, read by somebody deciding whether to press *Update* on a battery +that is currently regulating. A row here has to be something that reader can act +on. + +* **CI-9's follow-up changes one file, `.gitea/workflows/checks.yml`**, exactly + as CI-9 itself did, and it is comment-only within that file. It is not under + `goodwe_controller/`, it is not in `predeploy_check.py`'s `RUNTIME` tuple, it + is not in the built image, and **there is no version of this add-on on which an + operator can observe it.** `## 0.10.0` excluded CI-9 on exactly that reasoning; + excluding the follow-up on any other reasoning would make the rule mean + nothing. +* **SEC-2 adds `docs/RELEASE.md` and two lines to `README.md`, and this one is + the closer call, so here is the whole of it.** SEC-2 is by some distance the + more consequential of the two: it is the ticket that made `master` refuse a + direct push, and it is *why this release reaches you as a pull request into + `release/1.0` and then into `master`, rather than as a push straight to the + branch the Supervisor clones* - which is how 0.10.1 got there. It is also + **purely a procedure for whoever cuts a release**; both files sit outside + `goodwe_controller/`, neither is in the image, and no install can tell whether + that document exists or what it says. An operator reading it learns nothing + about 0.11.0 that helps them decide whether to take it. So it is excluded, and + it is excluded under the same sentence as CI-9: ⚠️ **importance to this + repository is not the test. Observability on an installed add-on is.** + +⚠️ **AND BOTH ARE WRITTEN DOWN, JUST NOT HERE.** CI-9's follow-up is PR #102 plus +the comment block at the top of `.gitea/workflows/checks.yml`, which carries the +measurements in full; SEC-2 is PR #108 plus `docs/RELEASE.md` itself, which is +now the canonical account of the release path and is the procedure this release +followed. ⚠️ **If a later CI or packaging ticket changes what the shipped image +contains, it belongs in this table.** Neither of these does. + +### SAFETY-56 — three messages stop blaming healthy hardware + +⚠️ **NOTHING THE ADD-ON DOES CHANGES. THREE THINGS IT SAYS DO.** Same +conjunction, same limits, same `MISSING`, same 0 W command, same NOT READY row, +same [[SAFETY-57]] release at 32 s. `tools/safety56_behaviour_trace.py` is what +says so rather than a commit message: it hashes a document with no sentence +anywhere in it, built from **2390 one-second reads replayed out of the committed +2026-09-21 bus capture**, a **66-cell command × register grid stepped 120 times +each**, and **six driven controller worlds**. That document is +`6030175ed2ab9a41…` on `release/1.0` and on this tree, byte for byte, while the +sentences differ. ⚠️ **And the tool REFUSES to report "behaviour unchanged" when +the sentence halves agree**, so an identical pair is a result rather than one +tree compared with itself. + +**THE DEFECT, AND IT IS NOT A TYPO.** [[SAFETY-37]]'s freshness gate fires when a +battery-power register holds one value while the standing command moves. That +conjunction is satisfied by **two disjoint worlds**: + +1. the register has stopped reporting reality - which is what the gate was built + to catch; or +2. **reality is not moving**, because the source cannot comply with the command. + A BMS refusing a discharge at its floor holds the pack still under any + setpoint, and a register reporting that honestly publishes the same unchanged + number a dead one does. + +**Nothing the gate is handed separates them, and three sentences asserted the +first.** ⚠️ **On the one episode this repository has measured, it was the +second.** On 2026-09-21 the pack read **11 %** for the whole 850 s hold, **every +command inside that hold was a discharge**, and the register **answered the first +CHARGE command three seconds later**. All three numbers are read off the +committed capture by `test_freshness.py` part 11a rather than typed there. The +register was healthy and truthful throughout, and the add-on said it had failed. + +**THE THREE SURFACES, so you can recognise what moved:** + +| where you read it | what 0.10.1 said | what 0.11.0 says | +|---|---|---| +| the **action log** and the **status page** — both quote `freshness.py`'s `STALE_READING:` line verbatim, through `_read_unit()` and `stale_note()` | `… the reading is not following, so it is reported as MISSING` | `… TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART: the register may have stopped reporting reality, or the source may be unable to comply with the command …` | +| the **add-on log**, at ERROR, from `main.py` | `SAFETY-37: battery-power register is not following its own command` | `SAFETY-37: battery-power reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER` | +| the **readiness row** on the diagnostics page | `battery power: is missing or not numeric` | `battery power: no usable value this cycle from - and this row cannot tell which of two things that is … Confirm the entity in Developer Tools before replacing it` | + +⚠️ **THE THIRD ROW IS THE ONE THAT COST AN OPERATOR AN EVENING, AND IT IS THE +WORST THING A DIAGNOSTIC CAN DO: IT ASSERTED A FALSE FACT ABOUT A NAMED +ENTITY.** It read *"is missing or not numeric"* for **any** `None`, and one of +the ways that value becomes `None` is the freshness gate withholding a reading +that is present, numeric and updating - `Freshness.reading()` returns `None` +there **by design**. So the operator opened Developer Tools on the entity the row +named, found it working, and had every reason to conclude the diagnostics were +broken. **It is fixed by saying less, not by looking harder:** the row does not +branch on the gate's state, because the gate already has a surface of its own - +`stale_note()` rides `liveness_notes()` a few rows below with the whole +`STALE_READING` sentence in it - so your discriminator is *"is that row there?"*, +and this row's only job is to stop pre-empting the answer. + +⚠️ **WHAT IT DOES NOT GIVE YOU IS WHICH OF THE TWO IT WAS.** Naming which would +need the SoC and the pack floor, and those are deliberately not `freshness.py`'s +inputs - they are [[TEL-08]]'s and [[SAFETY-32]]'s numbers, on their own rows. +**SoC is STATED in these sentences and never WIRED into them**: part 11b drives +the same two worlds at 50 % and at 11 % and the sentence does not move one byte. +What changed is that the add-on now tells you it cannot tell, instead of guessing +and blaming your hardware. **The reading rule is unchanged from +`### SAFETY-57` below**: if the stale/withdrawn pair keeps repeating, the unit +really does want looking at; if it happens once at a low SoC and stops, the pack +was empty and the site has resumed. + +⚠️ **AND THE THREE WORDINGS SHARE NO HELPER, ON PURPOSE.** Two of them are log +lines about a **behaviour** and the third is a banner about an **entity**; they +answer different questions ("what did the register do" against "can I trust this +entity"), and one helper serving both would have to drop whichever half the other +caller does not need - and the half it dropped would be the operator's next step. + +**Full account: `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`.** + +### TEL-11 — `pv_entity`: PV production gets recorded, and stays out of the control law + +⚠️ **UNSET, THIS IS NOT A CHANGE AT ALL - AND THAT IS MEASURED RATHER THAN +ASSERTED.** `pv_entity` ships empty, which is what **every** install that +upgrades into this version lands on. Empty means the add-on **reads no entity, +announces no entity, publishes no topic, shows no status row and adds no page +row.** `tools/tel11_pv_trace.py` drives five grid behaviours × eight +PV-sensor behaviours through 24 cycles of the **shipped** `Controller`, on +`release/1.0` and on this tree, and hashes two separate documents - one of +**control** decisions and one of **telemetry**: + +| document | tree | `pv_entity` | sha256 | +|---|---|---|---| +| control | `release/1.0` | unset | `846a10a5b89e3210…` | +| control | TEL-11 | unset | `846a10a5b89e3210…` | +| control | TEL-11 | **set** | `846a10a5b89e3210…` | +| telemetry | `release/1.0` | unset | `a443a6ceaffe072b…` | +| telemetry | TEL-11 | unset | `a443a6ceaffe072b…` | +| telemetry | TEL-11 | **set** | `4e6d1cb289a95722…` | + +**Read the two `unset` rows first.** They are byte-identical in **both** +documents, which is a stronger claim than "the control path is untouched": an +install that does not set the option sees no change of any kind, announcements +and status rows included. **Then read the `set` row of the control document** - +identical again, while telemetry moves. ⚠️ The tool **REFUSES** unless the two +trees' `app/` fingerprints differ and unless exactly one side reacts to the +option, so a matching pair cannot be two runs of the same tree. + +⚠️ **IT IS NOT A CONTROL INPUT, AND IT DOES NOT CHANGE WHAT THE LOOP DOES.** It +is instrumentation and nothing else. It is not read by the control law, the +allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog; it +is **not part of `inputs_ok`**, so a PV integration that breaks cannot stop your +battery being regulated; and its status row is a **caution** (`warn`), never a +`bad` row, so a telemetry channel can never put a battery site into NOT READY. +`test_pv.py` part 6 drives 40 cycles of the real `cycle()` and asserts the +commands are bit-identical across unset / no reading / 0 W / 8 kW, with a +**computed** census of all 33 modules under `app/` showing the material is +mentioned in exactly three of them: `pv.py`, `main.py`, `mqtt.py`. Wiring +production into the control law would be a behaviour change on a system driving a +real battery - and it would destroy the measurement, because the loop would then +be reacting to the thing being measured. + +**WHAT YOU GET IF YOU DO SET IT.** One new MQTT-discovery sensor, +`sensor.goodwe_pv_power`, `device_class: power` and ⚠️ **`state_class: +measurement`** - which is the point of the row, because that is what puts the +number into the recorder's long-term statistics, and a week of statistics is what +the attribution needs. **Positive is producing.** The unit is **read off your +entity's own `unit_of_measurement`** - `mW`, `W`, `kW`, `MW`, `GW`, `TW`, Home +Assistant's own power units, every factor an exact power of ten. A sensor with +**no** unit, or with a unit that is not power, is **refused by name, and the +status page says which entity and which unit.** There is deliberately no +magnitude heuristic: nothing can separate 30 W of dusk from 30 kW of noon, and a +silent factor of 1000 in the one channel that exists to be believed a week later +is worse than a refusal you can read. + +⚠️ **ABSENCE IS PUBLISHED AS `unknown`, NOT AS 0 AND NOT SKIPPED. THIS IS THE +DESIGN DECISION WORTH YOUR ATTENTION.** + +| what happened | `sensor.goodwe_pv_power` | the status page | +|---|---|---| +| `pv_entity` not set | the entity does not exist | no row at all | +| set, sensor answered 4100 W | `4100.0` | `PV 4 100 W` | +| set, sensor answered 0 W | `0.0` | `PV 0 W` | +| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason | + +The last two rows are why the key is shaped this way. `publish()` normally +**drops** a `None`, which is right for `circulation` - there the last figure +standing beats a zero that reads as health - and **exactly wrong here**: a +dropped row leaves Home Assistant holding the last number it was told, for ever, +and the recorder then draws a **flat line through the outage**, which is +indistinguishable from a measurement. Anyone reading that week back would +conclude the sun was not shining, ⚠️ **which is precisely the conclusion this +channel exists to TEST rather than to manufacture.** So the absence is published, +using Home Assistant's own no-value payload (`mqtt/const.py` declares +`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it *before* the numeric +branch), the entity's state becomes **`unknown`**, and the recorder stores a +**gap**. A failed read also **clears** the previous good one: the value never +outlives its own sensor. + +⚠️ **WHY IT IS A HOME ASSISTANT ENTITY AND NOT A REGISTER.** The RS485 bus this +add-on speaks carries the **battery** inverter it drives. On nearly every site the +PV inverter is a different box from a different vendor on its own connection, +reporting into Home Assistant through its own integration - so this takes +whatever `sensor.*` your PV integration already provides, **any brand Home +Assistant can read**, and nothing anywhere assumes it is on the bus. + +**THERE IS NO `pv_invert`, AND THAT IS NOT AN OVERSIGHT.** `meter_invert` and +`batt_invert` exist because those two quantities are **bipolar and are read by +the control path**, where a reversed sensor hands the site a plausible number it +acts on. Production is unipolar and is read by **nothing**, so a reversed sensor +cannot mislead a decision, and it announces itself as **−4 kW at noon** in the +very history this key exists to create. A flag would only let an installer hide +it. There is no negative-value alarm either: a PV inverter's own standby draw +makes a small negative reading at night ordinary, so any threshold would be a +number nobody has measured. + +⚠️ **WHAT THIS RELEASE DOES NOT DO, AND IT IS THE REASON THE CARD EXISTS.** It +does not answer [[OPT-10]]'s question. That card measured this site over sixteen +days: the pack reached 90 % SoC on **two** of them, and **20.5 kWh of surplus +left the site while the pack still had room** - 12.6 kWh of it with the battery +not charging at all - and it could not say **why**, because PV production was +recorded **nowhere**. Three causes need three different fixes (no irradiance: +nothing to fix; a charge-power limit; the optimiser declining to charge, which is +the only software defect), and guessing between them is how an optimiser gets +tuned to solve a cloud problem. **0.11.0 adds the channel. The attribution needs +a week of history through it, and is OPT-10's own card.** So the reason to set +`pv_entity` on the day you take this update is that the clock on that week starts +when you do. + +**Full account: `DECISIONS/TEL-11-pv-production.md`, with the operator's version +under "PV production" in `DOCS.md`.** + ## 0.10.1 **A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last @@ -50,8 +352,13 @@ operator can observe on an installed add-on - and are excluded for the reason 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. +on the first read after a rest). Both were open when this section was cut, both +are wording or bound changes rather than deadlocks, and neither was allowed to +delay this one. ⚠️ **[[SAFETY-56]] HAS SINCE BEEN FIXED AND IS IN `## 0.11.0` +ABOVE.** The wording quoted in this paragraph is what **0.10.1 actually ships** +and is left standing for that reason - if the box in front of you reports +`0.10.1`, that is the sentence it will print at you. It is not what 0.11.0 says. +[[SAFETY-58]] is still open. ### SAFETY-57 — the stale-reading verdict gets an exit @@ -168,6 +475,17 @@ a clearance the state could not produce. ⚠️ Its **diagnosis** half still say pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately untouched here. +⚠️ **AND THAT IS NOW A STATEMENT ABOUT 0.10.1 ONLY — [[SAFETY-56]] REWROTE THE +DIAGNOSIS HALF, AND `## 0.11.0` ABOVE CARRIES THE NEW WORDING.** The quotation +above is left exactly as it stands because it is what the 0.10.1 image prints, +and a reader on 0.10.1 needs to recognise it. The same applies to the reading +rule in the paragraph above: on 0.11.0 the add-on no longer asserts that +*"the register really is not following"* — it names both explanations and says +it cannot tell them apart — so *"the pair repeats → look at the unit, it happens +once and stops → the pack was empty"* stops being a correction of the message +and becomes the message's own advice. Nothing about **what** the gate does moved +in either card. + ## 0.10.0 ⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE @@ -389,7 +707,10 @@ out of it that no test in this repository had reached**: [[SAFETY-56]] 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. +which is what a patch number is for. ⚠️ **AND SAFETY-56'S IS IN `## 0.11.0`**, +which is where it went because it landed after 0.10.1 had already been cut. Both +defects named in this paragraph are now fixed; this section, and `## 0.10.1` +below it, describe images that still contain them, and are left saying so. Contents of this release, in merge order since 0.9.0 (`86bdf49`): diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index da627ef..5ad4e0c 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.10.1" +version: "0.11.0" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart