Compare commits

...
6 Commits
Author SHA1 Message Date
adminandClaude Opus 5 8a6acd9fa2 SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING,
same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390
capture reads, a 66-cell swept grid, and six driven controller worlds produce
the byte-identical document `6030175e...` on origin/release/1.0 and on this
branch, while the sentences differ.

THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the
register has stopped reporting reality, or the SOURCE cannot comply with the
command - and nothing `freshness.py` is handed separates them. Three sentences
asserted the first. On the live 2026-09-21 episode it was the second: the pack
read 11 % for the whole 850 s hold, every command in that hold was a discharge,
and the register answered the first CHARGE command three seconds later. The
readiness row was worse still, because it asserted a false fact about a NAMED
entity that was present, numeric and updating - so the operator opened
Developer Tools, found it working, and had every reason to conclude the
diagnostics were broken.

THE FIX. Each message states what was OBSERVED and names BOTH explanations.
Three wordings, no shared helper: two are a log line about a behaviour, the
third is a banner about an entity, and a helper serving both would drop
whichever half the other caller does not need. SoC stays STATED, NOT WIRED -
part 11b drives the two worlds at 50 % and 11 % and the sentence does not move
one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()`
already rides `liveness_notes()` a few rows below, so the discriminator is "is
that row there?", and part 11d asserts it is present in one world and absent in
the other.

THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground
truth written down rather than derived from the published stream, asserts they
are really two (40 of 40 reads differ) while the published stream is
bit-identical, and only then grades the sentences. Eleven mutations, all red:
each of the three sentences reverted (3, 2 and 2 named reds), the two
explanations transposed at ZERO bytes (1), the second explanation deleted (1),
the admission of ambiguity replaced by a ranking (1), all three reverted at
once (5), and two aimed at this card's own check - collapsing the world pair
(2) and emptying the accusation list, which a positive control against the
three sentences as 0.10.1 shipped them catches (1).

AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE.
Transposing `label` and `entity` in the row made it unfindable and the run died
on an IndexError - a red with no name and every later check ungraded, FCST-06's
shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3
after. And the first invariance run compared a tree with itself, because it
archived a branch whose edits were not committed yet; the tool now REFUSES to
report "behaviour unchanged" unless the sentences halves differ, and both of
its refusals are driven.

SAFETY-57's part 10h check was the handoff and is inverted rather than deleted:
it required the accusation to still be present so that removing it had to be a
deliberate act. This is that act. Its escape clause is untouched.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 21:02:40 +02:00
admin 239e120bf6 SEC-2: write down the release path, now that master refuses a direct push (#108)
Reviewed on the PR (issuecomments 1480, 1485, 1488, 1489). Documentation only: docs/RELEASE.md (new, 116 lines) plus a 2-line pointer in README.md's file map. No code, no workflow change.

Verified against the forge rather than accepted from the document:
- Both branch_protections rules re-read field-for-field identical (enable_push false, enable_force_push false, status check `checks / unit (pull_request)`, required_approvals 0, block_admin_merge_override true, no whitelists, no file patterns); master's rule created 2026-09-23T03:09:21; default_branch still master. Section 2's After table is exact. No protection was changed by this review - both rules re-read with their original updated_at.
- The empty-PR 405 trap reproduced independently on my own control (throwaway PR #111 between two same-SHA unprotected branches): empty PR -> 405 "Please try again later"; same PR after one commit -> 409 "head out of date". Section 1's "read the message, not the code" is correct and both messages are quoted accurately. PR #111 closed, branches deleted.
- release/1.0 (b0b24e5) is an ancestor of master (0f14c2a), so a literal release PR is empty right now - PR #109 has 0 commits, 0 files, and Gitea opened it anyway. Section 1's closing note is right.
- A master-base PR gets a genuine CI run, not a laundered skip: PR #107 head cb7e45b3 shows Waiting -> In progress -> success, "Successful in 7m56s", run 814. This PR's own head 9d593429 likewise, run 818.
- All five required steps are present and correctly ordered: work merges to release/1.0 with a version bump in goodwe_controller/config.yaml; release via PR release/1.0 -> master; direct push refused; green check before merge; `ha store reload` after, without which the Supervisor keeps serving the cached version. required_approvals: 0 is stated twice, so no reader can believe the dev/review split is enforced.

One non-blocking defect left open (issuecomment 1485): section 1 calls 0f14c2a "the only commit on master that carries no (#NN) merge suffix". 274 of master's 297 commits carry no suffix. The true statement is stronger - no release has ever reached master through a pull request, and 6f5e8e3 (0.10.0) was promoted by the same direct push one release earlier, so 0.10.1 was the second, not a one-off. Background, not procedure; wants a follow-up line change.

A third comment of mine (issuecomment 1488, merge style) is withdrawn in issuecomment 1489 - it rested on a premise I had not checked and which is false.
2026-09-23 04:08:18 +02:00
glenn schrooyenandClaude Opus 5 9d593429d3 SEC-2: write down the release path, now that master refuses a direct push
checks / unit (pull_request) Successful in 7m56s
master is the default branch, which is what the Home Assistant Supervisor
clones - so whatever sits on it runs on a real 5 kW inverter. Until today it
had no branch protection at all, while every gate this repo built sat on
release/1.0, the branch that does not ship. 0.10.1 went straight onto master
on 2026-09-22 with no PR, no CI and no reviewer, and it succeeded.

master now carries the same rule as release/1.0 - enable_push false,
required context `checks / unit (pull_request)`, block_admin_merge_override,
required_approvals 0. That is a forge setting and cannot live in a commit, so
the part that can is this: the procedure it implies. Without it the next
release is attempted the old way and fails confusingly at the push.

docs/RELEASE.md records the path (merge to release/1.0, bump version, PR
release/1.0 -> master, wait for green, merge, then `ha store reload` before
the Supervisor offers anything), the before/after protection state of both
branches, and how the gate was proven to bite rather than merely badge green.

Documentation only - no code, no workflow change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 03:43:37 +02:00
admin b0b24e5d41 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0 (#105)
checks / unit (pull_request) Successful in 7m57s
Reviewed on the PR (issuecomments 1455-1462): identity verified unchanged (slug goodwe_controller, no image: key, installed slug 68285ac7_goodwe_controller corroborated from HA backup metadata); version-gate 11a-11d re-implemented and verified independently; the 16-of-93-at-790s pairing re-derived from the committed 16-day capture (>=710: 17, >=790: 16, >=815: 15); the SAFETY-57 entry verified as a faithful move carrying only the three declared #104 documentation fixes; corrected 0.10.0 preamble corroborated to the day against backup metadata; suite 30/30 reproduced with checks.yml's exact command. Two files, no control code. The 710 s/790 s split between test_freshness.py and this file is a known, stated gap and takes its own card.

NOTE: master (the Supervisor's default branch) is still 0.10.0 and still lacks SAFETY-57. This merge does not deliver; bringing master to release/1.0 is a fast-forward and is the remaining step.
2026-09-22 15:52:25 +02:00
glenn schrooyenandClaude Opus 5 eb88646899 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0
checks / unit (pull_request) Successful in 8m0s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:22:03 +02:00
admin b9f7bd0506 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested (#104)
Reviewed and merged by the PR review agent. Acceptance criterion re-driven independently off the committed RS-485 capture: the verdict is withdrawn with 0x0518 held constant at -3 W, no external change. Counterfactual release_s=inf verified byte-equivalent to the shipped-before module over 242336 reads, so the SAFETY-37 conjunction is untouched. 30/30 suite green in checks.yml's own image; 11 mutations re-applied on a purged tree; the argued-equivalent M7 proved equivalent over full internal state.

Three non-blocking documentation findings filed on the PR (comments 3/7, 4/7, 5/7) for a follow-up before the next version is cut.
2026-09-21 15:49:35 +02:00
10 changed files with 1244 additions and 110 deletions
+2
View File
@@ -33,6 +33,8 @@ estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
protected, releases go via PR from `release/1.0`
```
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
+116
View File
@@ -0,0 +1,116 @@
# Releasing — how a change reaches the battery
`master` is the **default branch**, and the default branch is what the Home
Assistant Supervisor clones when it installs or updates this add-on. Whatever
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
as a deployment, because that is what it is.
---
## 1. `master` is protected — releases go through a pull request
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
**The release path is now:**
1. Merge the work into `release/1.0` as usual, via a pull request that goes
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
the Supervisor keys the built image by `version:`, so without a bump nothing
is offered and nothing changes.
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
it will be refused by the pre-receive hook:
```
remote: error: Not allowed to push to protected branch master
! [remote rejected] ... (pre-receive hook declined)
```
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
filter**, so a `master` base matches and a real run happens — verified on
throwaway PR #107 (base `master`), which queued a genuine run.
⚠️ Green means green. A `skipped` status is laundered into a `success`
rollup by this Gitea and the merge gate accepts it — see the long note at
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
4. Merge the pull request. `required_approvals` is **0**, so a green check is
the whole gate; there is no second pair of eyes unless someone asks for one.
5. **Reload the store.** Merging to `master` does not, by itself, offer the
update. The Supervisor only sees the new version after:
```bash
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
```
Until `ha store reload` runs, the add-on page still shows the old version
and the release looks like it did nothing.
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
so a `release/1.0 → master` pull request is empty until new commits land on
`release/1.0`. That is expected; it means there is nothing to release yet.
---
## 2. Branch protection — before and after SEC-2
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
**Before (2026-09-23):**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | **no rule at all** | — | — | — |
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
**After:**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
Both rules are otherwise identical: `enable_push: false`,
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
no file patterns. `required_approvals` stays at **0** on purpose — raising it
would block the review agent from merging, and nobody asked for it.
The default branch was **not** changed. Changing it would change what the
Supervisor clones, which is a different decision from this one.
### How the gate was proven to actually bite
A protection rule on this forge can *appear* satisfied while having checked
nothing, so the rule was tested rather than trusted. The discriminator is a
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
branch protection *before* it checks the head, so
* `409 head out of date` → the request **cleared** protection;
* `405 Not all required status checks successful` → protection **refused** it.
Read the *message*, not just the code: `405` is also returned as
`Please try again later` for a pull request with **no commits**, which has
nothing to do with protection. An empty pull request on an unprotected base
gives that same `405` (verified, throwaway PR #110), so an empty
`release/1.0 → master` reports it too.
Validated against a control on an unprotected base (PR #106), which answered
`409` while its check was still pending — i.e. protection was no obstacle
there. On `master` (PR #107) the same probe answered `405` while pending, and
`409` once the check was genuinely green. A direct `git push` to `master` was
rejected by the pre-receive hook with `master`'s SHA unmoved.
---
## 3. Rolling back
There is no force-push escape hatch: `enable_force_push` is `false` on both
branches. To undo a bad release, land a revert on `release/1.0`, bump the
version again, and take it through §1. The `/addons/` rollback for the
reference install is in `HANDOVER.md`.
+202 -94
View File
@@ -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 <unit>` line in the action log
beside the existing `STALE <unit>` 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 <repo>:/w:ro -v <dir>:/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 <unit>` line in the action log
beside the existing `STALE <unit>` 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
@@ -0,0 +1,181 @@
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
**Text only. No behaviour changes in this card**, and §5 is the measurement that
says so: the same stimuli through the tree before and after produce a
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
and every readiness row's `(ok, warn)`.
---
## 1. The defect
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
healthy hardware has failed.
**The log line**, as emitted on the reference site on 2026-09-21:
> SAFETY-37: inverter-a battery-power register **is not following its own
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
> as MISSING rather than as a measurement … Treating it as MISSING, which
> commands 0 W.
**The readiness row**, at the same moment:
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
> **is missing or not numeric**
That entity was present, numeric and updating.
**SAFETY-37's conjunction is correct and is not touched by this card** —
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
genuinely stuck against a real command is exactly what it should catch, and a
pack that cannot comply is a state the site must stop in too. What was wrong is
what the sentences **concluded** from it.
## 2. Two worlds satisfy the conjunction
| | the register | the pack |
|---|---|---|
| **DEAD** | has stopped reporting reality | is doing what it was told |
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
A refused pack holds still under any setpoint, so an honest register publishes
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
separates them.** All three sentences asserted DEAD.
## 3. The live episode was REFUSED, and it is measured, not argued
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
| | |
|---|---|
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
| pack SoC throughout | **11 %** |
| first **charge** command after the restart | 11:05:31 at **-400 W** |
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
**A register that answers a reversed command inside one obligation window had
not stopped reporting.** What would not move was the pack. The sentence the
operator read named the one thing in the loop that was telling the truth.
⚠️ What this does **not** establish: that no register is ever intermittently
wrong. It excludes a register that had simply stopped; it cannot exclude one
that was wrong and then right. That is the point — the new sentence claims
neither.
## 4. The fix, and why it is three sentences and not one helper
Each message states **what was observed** and names **both** explanations.
| where | before | after |
|---|---|---|
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **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 - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
**Three sentences, three wordings, no shared helper.** The first two are a log
line about a **behaviour**; the third is a banner about a named **entity**, and
the two answer different questions ("what did the register do" against "can I
trust this entity"). A 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. The log line names the pair **once**, inside the `%s` it
already interpolates, rather than in both of its own halves.
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
asserts the sentence does not move one byte.
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
object at the same moment, and the row deliberately does not ask it: the same
loop renders the grid and SoC rows, which have no gate, and a branch per row
would be a second place where "why is this None" is decided. The gate already
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
the whole STALE_READING sentence in it — so the operator's discriminator is
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
and absent in ABSENT**.
## 5. Behaviour is byte-identical, measured
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
`origin/release/1.0` **archived out of git** and against this branch:
| | |
|---|---|
| the committed live capture | **2390 reads**, 7 verdict edges |
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
unchanged" unless the `sentences` halves **differ**. This card's first
invariance run was exactly that mistake — it archived a branch whose edits were
not committed yet, so `before` and `after` were the same code and the identity
was perfect and worthless.
Both of the tool's refusals are **driven**, not asserted:
| | |
|---|---|
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
## 6. The mutation battery
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
graded by `tools/mutation_run.sh` in the CI image.
| | mutation | Δ bytes | named reds |
|---|---|---|---|
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
| M5 | the second explanation deleted, the first kept | -216 | **1** |
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
| M8 | the row's second cause deleted | -309 | **1** |
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
⚠️ **M9's first run was a defect in this card's own check, and it is the one
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
with **no name attached**, with every check after that line **ungraded**. That
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
raising, and a precondition grades it: **0 named reds before, 3 after.**
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
True, and so is one over three mistyped literals. The positive control runs the
same predicate against the three sentences **as 0.10.1 shipped them** and
requires it to find an accusation in every one, and every literal to match at
least one of them (habit 7: vary one thing from an accepted baseline).
## 7. What part 11 does not grade
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
* It does not grade English. A sentence that named both explanations and then
said something useless about them would pass.
* The `no entity configured` branch beside the rewritten row is untouched and
ungraded: a blank entity really is the only thing it can be.
* The **grid-power and battery-SoC rows** go through the same rewritten line and
have no freshness gate today, so for them the second cause is a hypothesis
about this controller rather than a live possibility. That is weaker than the
battery-power case and is the price of not branching per row. A gate on either
signal — TEL-08 would be one — makes it exact.
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
healthy register could answer), and neither is the 710/790 disagreement
between `test_freshness.py` and the changelog. Both are separate cards; the
second has a **fourth site** at
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
off the PR table alone would miss.
@@ -360,6 +360,14 @@ two cards editing one sentence is how one of them silently loses. Only the
false, and part 10h has a named check asserting the diagnosis half is still
there so that rewrite has to be a deliberate act.
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
> the deliberate act: all three sentences now name **both** explanations and
> accuse neither, and part 10h's check is inverted — it requires the accusation
> to be **gone**. Nothing else in this section moved; the escape clause above is
> still this card's and is untouched by it. See
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
> `test_freshness.py` part 11 for the graded pair of worlds.
**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
+35 -1
View File
@@ -565,10 +565,44 @@ class Freshness:
# and different afterwards, and an operator comparing this line against
# a history graph is reading the register.
shown_s = self.unchanged_s(now_mono)
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
# is satisfied by TWO disjoint worlds, and nothing this object is handed
# separates them:
# (1) the register has stopped reporting reality - what the gate was
# built for - or
# (2) reality is not moving, because the SOURCE cannot comply with
# the command. A pack the BMS is refusing at its floor holds
# still under any setpoint, and a register reporting that
# truthfully publishes the same unchanged number a dead one does.
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
# whole 850 s hold, the commands were discharge-only, and the register
# answered the first CHARGE command within one obligation window
# (test_freshness.py 11a measures all three off the committed capture)
# - and this sentence asserted (1), which sent the operator to look at
# healthy hardware.
# Naming both is what the module can honestly say. Naming WHICH would
# need SoC and the pack floor - deliberately not this module's inputs
# (see the `soc` row in BOUNDS and the docstring's last section), and
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
#
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
# status page and by `_read_unit()` into the action log; that one is a
# BANNER about a named ENTITY, and the two answer different questions
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
self.reason = (
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"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
f"CANNOT TELL THEM APART: the register may have stopped reporting "
f"reality, or the source may be unable to comply with the command "
f"- a pack the BMS is refusing holds still under any setpoint, and "
f"an honest register then reads exactly like a frozen one. Either "
f"way the number cannot be read as this command's effect, 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 "
+53 -8
View File
@@ -1785,13 +1785,28 @@ class Controller:
# 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 "
# ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT
# STANDING ON PURPOSE. It used to open "battery-power register is
# not following its own command" - an accusation against a named
# piece of hardware, and FALSE on the very episode above. The
# pack read 11 % for the whole hold, every command in it was a
# DISCHARGE, and the register answered the first CHARGE command
# three seconds later - so the BMS was refusing, the register was
# publishing that refusal correctly, and the operator was sent to
# look at a working sensor. All three numbers are measured off the
# committed capture by test_freshness.py part 11a. The gate's
# conjunction is right and is NOT touched; only its conclusion was.
#
# ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE.
# This line's own words are the OBSERVATION plus the one thing a
# log reader has to be told before acting - that the observation is
# not yet a verdict - and `freshness._grade()`'s sentence, which
# this line already interpolates, is where the pair is spelled out.
# Saying it in both halves of one sentence is how an operator
# learns to skim it.
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
"ON THE REGISTER - %s. Treating it as MISSING, which "
"commands 0 W. It clears itself the moment the register "
"reports a different value, and failing that %.0f s "
"after the standing command falls below %.0f W "
@@ -4663,8 +4678,38 @@ class Controller:
if not entity:
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
# "<entity> is missing or not numeric" for ANY None, and one of
# the ways this value becomes None is SAFETY-37's freshness gate
# withholding a reading that is present, numeric and updating -
# `Freshness.reading()` returns None BY DESIGN there. On the
# live site on 2026-09-21 the operator opened Developer Tools on
# the entity this row named, found it working, and had every
# reason to conclude the diagnostics were broken.
#
# ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
# row could ask `inv.batt_fresh.stale` - it is on the same
# object at the same moment - but this loop also renders the
# grid and SoC rows, which have no gate, and a branch per row
# would be a second place where "why is this None" is decided.
# 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 the operator's discriminator
# is "is that row there?" and this row's job is only to stop
# pre-empting the answer. Same reason the wording is not shared
# with `freshness._grade()`: that one is a log line about a
# BEHAVIOUR, this one is a banner about an ENTITY.
out.append({"ok": False, "warn": False,
"text": f"{label}: {entity} is missing or not numeric"})
"text": f"{label}: no usable value this cycle from "
f"{entity} - and this row cannot tell which "
f"of two things that is: the entity is "
f"missing or not numeric, or it is "
f"publishing a number that this controller "
f"is withholding (a freshness gate does "
f"exactly that, and names itself in a row "
f"of its own). Confirm the entity in "
f"Developer Tools before replacing it"})
else:
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
+1 -1
View File
@@ -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
+346 -6
View File
@@ -35,6 +35,12 @@ module does honour.
7. THE POLICY TABLE tells the truth about what is wired.
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
10. SAFETY-57's EXIT from the verdict, against the committed live capture.
11. SAFETY-56: THE SENTENCE THIS GATE CAN HONESTLY SAY. The conjunction is
satisfied by two disjoint worlds - a register that has stopped reporting,
and a source that cannot comply - and nothing here separates them. Part
11 builds both, shows the verdict is byte-identical across them, and
grades the three operator-facing sentences against that ambiguity.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
Every two-unit case below is two copies of inverter A's behaviour with
@@ -1608,12 +1614,17 @@ 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 THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
"register is not following its own command' to still be present, so "
"that removing it had to be a deliberate act rather than a side effect "
"of editing the escape clause beside it. SAFETY-56 is that act, and the "
"requirement is now the inverse: the accusation is GONE from this line "
"and part 11 grades what replaced it. The escape clause above is "
"SAFETY-57's and is untouched by it",
"battery-power register is not following its own command"
not in _stale57[0]
and "did not move under a command that did" 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",
@@ -1630,6 +1641,335 @@ check("...and a re-trip's reason names the TWO durations separately, each "
Freshness(BOUNDS["batt"])))
# ==============================================================================
print()
print("11. SAFETY-56: the sentence this gate can honestly say")
# ⚠️ THE SUBJECT OF THIS PART IS A CLAIM, NOT A STRING, AND NOTHING IN IT
# CHANGES A DECISION. SAFETY-37's conjunction is correct and is untouched;
# what was wrong is what two sentences CONCLUDED from it. The conjunction is
# satisfied by two disjoint worlds -
#
# DEAD the register has stopped reporting reality. The pack really is
# doing what it was told; the published number does not say so.
# REFUSED the register is honest and the SOURCE cannot comply. The BMS is
# refusing at the pack floor, so the pack does nothing whatever the
# command asks, and an unchanged reading is the TRUTH.
#
# - and nothing this module is handed separates them. Three sentences named
# DEAD as fact: `freshness._grade()`'s "the reading is not following",
# `main.py`'s log line "battery-power register is not following its own
# command", and the NOT READY row's "<entity> is missing or not numeric". On
# the live 2026-09-21 episode all three were the wrong one of the two, and the
# third was the worst because it asserts a false fact about a NAMED entity: the
# operator opens Developer Tools, finds it working, and concludes the
# diagnostics are broken.
#
# So the property graded below is not "the new words are present". It is that
# the two worlds are REAL AND DIFFERENT (11a on the committed capture, 11b in a
# driven pair), that the gate CANNOT separate them (11b), and therefore that a
# clause asserting either one is false half the time - which is what makes 11c
# and 11d a claim rather than decoration. Habit 4: without 11a and 11b those
# two would be substring tests over a sentence this file also wrote.
# --- 11a. the old diagnosis was FALSE on the site it was printed on ----------
# ⚠️ OFF THE COMMITTED CAPTURE, NOT OFF A FIXTURE. Part 10a has already graded
# this file's bytes against the manifest and its decoder's sign convention
# against the frames, so the three series below are evidence rather than a
# story. Every boundary is derived in part 10b from the register's own longest
# hold; nothing here is typed.
_S56_CMDS_HELD = sorted({_D.held_at(_sp, t)
for t in range(int(_HOLD_FROM), int(_PARK))})
_S56_SOC_HELD = sorted({_D.held_at(_soc, t)
for t in range(int(_HOLD_FROM), int(_HUMAN))})
_S56_CHARGE_T, _S56_CHARGE_W = next((t, v) for t, v in _sp
if t > _PARK and v < 0.0)
_S56_AFTER = _D.held_at(_bt, _REG_MOVES + 5.0)
print(f" during the 850 s hold: commands {_S56_CMDS_HELD} W, "
f"SoC {_S56_SOC_HELD} %, register {_HOLD_W:+.0f} W")
print(f" first CHARGE command {hms(_S56_CHARGE_T)} at "
f"{_S56_CHARGE_W:+.0f} W -> register moves {hms(_REG_MOVES)} "
f"({_REG_MOVES - _S56_CHARGE_T:.0f} s) to {_S56_AFTER:+.0f} W")
check("11a. ⚠️ THE OLD DIAGNOSIS WAS FALSE ON THE ONE SITE IT WAS EVER "
"PRINTED ON. Through the whole hold the site asked for DISCHARGE only "
"(+260 to +1060 W) out of a pack reading 11 %, and the register answered "
"-3 W. Then the first CHARGE command arrives and it moves within ONE "
"17.0 s obligation window. A register that answers a reversed command in "
"one window had not stopped reporting; what would not move was the PACK",
all(v > 0.0 for v in _S56_CMDS_HELD)
and min(_S56_CMDS_HELD) == 260.0 and max(_S56_CMDS_HELD) == 1060.0
and _S56_SOC_HELD == [11.0] and _HOLD_W == -3.0
and _S56_CHARGE_W < 0.0
and 0.0 < _REG_MOVES - _S56_CHARGE_T <= F.BATT_OBLIGED_S
and _S56_AFTER != _HOLD_W)
check("...and the gate was RIGHT to fire anyway, which is why this card touches "
"no term of the conjunction. The three limits are still the derived ones "
"and the same capture still arms at 10:52:00 with the verdict standing: "
"a register genuinely stuck against a real command is exactly what "
"SAFETY-37 should catch, and a pack that cannot comply is a state the "
"site must stop in too. What was wrong was never the firing",
BOUNDS["batt"].unchanged_s == BATT_UNCHANGED_S
and BOUNDS["batt"].move_w == MOVE_W
and BOUNDS["batt"].release_s == F.BATT_OBLIGED_S
and hms(_TRIP) == "10:52:00" and _f_after.releases == 1
and next(r for r in _rows_after if r[0] == _TRIP)[1] is True)
# --- 11b. two worlds, one published stream, one verdict ----------------------
# ⚠️ GROUND TRUTH IS WRITTEN DOWN, NOT DERIVED FROM THE PUBLISHED STREAM
# (habit 2). A check that computes "what the pack was really doing" out of the
# number the register published is comparing a thing with itself, which is the
# defect MUTATION.md has recorded fifteen times.
_BATT_ENT = "sensor.batt_a"
_S56_N = 40
_S56_CMD = [800.0 if t < 1 else 1600.0 for t in range(_S56_N)]
_S56_PUB = [-3.0] * _S56_N # what the ENTITY publishes, both
_S56_TRUE_DEAD = list(_S56_CMD) # the pack obeyed; the register lied
_S56_TRUE_REFUSED = [-3.0] * _S56_N # the pack refused; the register told
async def _s56_world(soc_pct):
"""The shipped read path at N=1. The ground-truth stream above is never
handed to the controller - it is what the WORLD is, not an input."""
c, hass = one_site()
hass.numbers["sensor.soc_a"] = soc_pct
for t in range(_S56_N):
hass.numbers[_BATT_ENT] = _S56_PUB[t]
c.inverters[0].last_write = _S56_CMD[t]
await c.read_inputs(now_mono=float(t))
return c, hass
_S56_LOG0 = len(_LOGREC)
# ⚠️ THE TWO WORLDS ARE DRIVEN AT DIFFERENT SoC ON PURPOSE - 50 % against the
# 11 % the live pack sat at - so that "the sentence did not move" below is a
# measurement of this module's `soc` bound being STATED, NOT WIRED, and not an
# artefact of both runs being handed the same percentage. Wiring it is TEL-08's.
_c56_dead, _h56_dead = run(_s56_world(50.0))
_c56_ref, _h56_ref = run(_s56_world(11.0))
_pub_dead = [v for e, v in _h56_dead.served if e == _BATT_ENT]
_pub_ref = [v for e, v in _h56_ref.served if e == _BATT_ENT]
_S56_DIFFER = sum(1 for a, b in zip(_S56_TRUE_DEAD, _S56_TRUE_REFUSED) if a != b)
print(f" DEAD truth {_S56_TRUE_DEAD[:2]}... published {_pub_dead[:2]}...")
print(f" REFUSED truth {_S56_TRUE_REFUSED[:2]}... published {_pub_ref[:2]}...")
check(f"11b. PRECONDITION: the two worlds are REALLY TWO - their ground truth "
f"differs on {_S56_DIFFER} of {_S56_N} reads - and the PUBLISHED stream "
f"is bit-identical in both, so nothing downstream of {_BATT_ENT} can "
f"tell them apart. Without this the rest of part 11 is one world twice",
_S56_DIFFER == _S56_N and _pub_dead == _pub_ref == _S56_PUB
and len(_pub_dead) == _S56_N)
check("...and in REFUSED the register is RIGHT on every one of the 40 reads "
"(published == truth) while in DEAD it is wrong on all 40. That is the "
"fact the old sentence got backwards: it named the register in a world "
"where the register is the only thing telling the truth",
_S56_PUB == _S56_TRUE_REFUSED
and not any(p == t for p, t in zip(_S56_PUB, _S56_TRUE_DEAD)))
_r56_dead = _c56_dead.inverters[0].batt_fresh.reason
_r56_ref = _c56_ref.inverters[0].batt_fresh.reason
check("⚠️ AND THE GATE FIRES IDENTICALLY IN BOTH, WHICH IS THE WHOLE REASON "
"ITS SENTENCE MAY NOT NAME ONE OF THEM: same verdict, same MISSING "
"reading, same site stop - and the sentence is BYTE-IDENTICAL across two "
"worlds that share no ground truth at all",
_c56_dead.inverters[0].batt_fresh.stale is True
and _c56_ref.inverters[0].batt_fresh.stale is True
and _c56_dead.inverters[0].batt is None
and _c56_ref.inverters[0].batt is None
and _c56_dead.inputs_ok is False and _c56_ref.inputs_ok is False
and _r56_dead == _r56_ref and _r56_dead != "")
check("...and the two runs were driven at 50 % and 11 % SoC and the sentence "
"did not move one byte, because `BOUNDS['soc']` is STATED, NOT WIRED. A "
"message honest about its own ambiguity needs no new input, and adding "
"one here would make this sentence depend on a second measurement being "
"right - TEL-08's card, not this one",
_c56_dead.inverters[0].soc == 50.0 and _c56_ref.inverters[0].soc == 11.0
and _r56_dead == _r56_ref)
# --- 11c. both explanations, and no accusation, on every surface -------------
# The two explanations, each tagged with the world it is true OF. The emitted
# sentence is the same in both worlds (11b), so a sentence naming only one is
# false half the time - which is why BOTH are required, as an ORDERED PAIR
# (habit 14): two clauses in one sentence can always be transposed at zero
# bytes, and "the register may be unable to comply / the source may have
# stopped reporting reality" is a different and wrong claim.
_S56_EXPLANATIONS = [
("DEAD", "the register may have stopped reporting reality"),
("REFUSED", "the source may be unable to comply with the command"),
]
# ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the
# REGISTER or about the ENTITY, and 11a measured the register to be alive on
# the live episode while 11d measures the entity to be serving a float. They
# are listed with the file they came from so a reviewer can see the set is the
# whole set rather than the ones that were easy to find.
_S56_ACCUSATIONS = [
("app/freshness.py _grade()", "the reading is not following"),
("app/main.py _read_unit()", "register is not following its own command"),
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()]
def _s56_row(c):
"""The battery-power readiness row, or a NAMED absence - never a raise.
⚠️ IT MUST NOT RAISE, AND THAT IS FCST-06'S RULE RATHER THAN TIDINESS. The
first draft of this part was `[... ][0]`, and the mutation that transposes
`label` and `entity` in the row - zero bytes - makes the row unfindable. The
run then died on an IndexError with every check after this line UNGRADED:
a red with no name attached, which reads like broken infrastructure and gets
retried rather than read. Measured: 0 named reds before, 2 after.
"""
hits = [r for r in c.checks() if r["text"].startswith("battery power")]
if len(hits) != 1:
return {"ok": None, "warn": None,
"text": f"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
return hits[0]
_row56_ref = _s56_row(_c56_ref)
_S56_SURFACES = {
"reason (status page + action log)": _r56_ref,
"log line (SAFETY-37 ERROR)": _log56[-1] if _log56 else "",
"stale_note() (NOT READY row)": _c56_ref.inverters[0].stale_note(),
"checks() battery-power row": _row56_ref["text"],
}
for _n, _s in _S56_SURFACES.items():
print(f" {_n}: {_s[:96]}...")
check("PRECONDITION: the readiness row is FINDABLE BY ITS LABEL in both worlds "
"- exactly one `battery power` row each - so every row graded below is "
"that row, and a mutation that moves `label` out of the first slot is a "
"NAMED red here rather than an IndexError three checks later (FCST-06). "
f"REFUSED: {_row56_ref['text'][:44]!r}",
_row56_ref["ok"] is not None)
check("PRECONDITION: all four operator-facing surfaces were really produced by "
"this run and are non-empty, so the two quantifiers below are not over "
"an empty set - and the log line is the one the SHIPPED read path "
"emitted, not one this file formatted. Both worlds emitted one and the "
"two are the same string, which is 11b again on the log surface",
len(_log56) == 2 and _log56[0] == _log56[1]
and all(len(s) > 30 for s in _S56_SURFACES.values())
and _r56_ref in _log56[-1])
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
f"{_S56_EXPLANATIONS[1][1]!r} at {_idx56[1]}. Pinned as an ordered pair "
f"because transposing 'register' and 'source' between the two clauses is "
f"a zero-byte edit that leaves both words in the sentence (habit 14)",
all(i >= 0 for i in _idx56) and _idx56 == sorted(_idx56)
and len(set(_idx56)) == len(_idx56))
check("...and the sentence says in its own words that it CANNOT choose, rather "
"than leaving two explanations side by side for a reader to rank - an "
"operator who reads a list picks the first item",
"CANNOT TELL THEM APART" in _r56_ref)
# ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL:
# a quantifier over an empty list is True, and so is one over three literals
# with a typo in each. These are the three sentences as 0.10.1 SHIPPED them,
# quoted from the release rather than rebuilt, and the same predicate is
# required to FIND an accusation in every one of them (habit 7: vary one thing
# from an ACCEPTED baseline).
_S56_SHIPPED_010_1 = {
"reason": "STALE_READING: -3 unchanged for 35 s while the standing command "
"moved 800 W to +1600 W - the reading is not following, so it is "
"reported as MISSING rather than as a measurement (limits: "
"unchanged over 35 s, command over 250 W and moved by over 250 W)",
"log": "SAFETY-37: inverter-a battery-power register is not following its "
"own command - STALE_READING: ... Treating it as MISSING, which "
"commands 0 W.",
"row": f"battery power: {_BATT_ENT} is missing or not numeric",
}
check(f"POSITIVE CONTROL: the very same predicate, run against the three "
f"sentences as 0.10.1 SHIPPED them, finds an accusation in EVERY one - "
f"and every one of the {len(_S56_ACCUSATIONS)} literals matches at least "
f"one of them, so neither an empty list nor a mistyped literal can make "
f"the check below pass by vacuity",
len(_S56_ACCUSATIONS) == 3 and len(_S56_EXPLANATIONS) == 2
and all(any(t in old for _w, t in _S56_ACCUSATIONS)
for old in _S56_SHIPPED_010_1.values())
and all(any(t in old for old in _S56_SHIPPED_010_1.values())
for _w, t in _S56_ACCUSATIONS))
check("⚠️ AND NOT ONE OF THE THREE ACCUSATIONS SURVIVES, ON ANY OF THE FOUR "
"SURFACES: " + "; ".join(f"{w} -> {t!r}" for w, t in _S56_ACCUSATIONS)
+ ". This is the check that goes red if any of the three sentences is "
"reverted, and it is stated over the SURFACES rather than over the "
"source files because two of the three are assembled from two modules",
not any(t in s for _w, t in _S56_ACCUSATIONS
for s in _S56_SURFACES.values()))
# --- 11d. the NOT READY row that named a working entity ----------------------
async def _s56_absent():
"""The other half of the row's ambiguity: the entity really is gone."""
c, hass = one_site()
for t in range(3):
hass.numbers[_BATT_ENT] = None # Home Assistant has no state
c.inverters[0].last_write = 0.0
await c.read_inputs(now_mono=float(t))
return c, hass
_c56_abs, _h56_abs = run(_s56_absent())
_last_ref = [v for e, v in _h56_ref.served if e == _BATT_ENT][-1]
_last_abs = [v for e, v in _h56_abs.served if e == _BATT_ENT][-1]
_row56_abs = _s56_row(_c56_abs)
print(f" WITHHELD: {_BATT_ENT} served {_last_ref!r}, stale="
f"{_c56_ref.inverters[0].batt_fresh.stale} ABSENT: served "
f"{_last_abs!r}, stale={_c56_abs.inverters[0].batt_fresh.stale}")
check("11d. PRECONDITION: the row's two causes are both REACHED. In REFUSED "
f"{_BATT_ENT} served a real float on the last read and the gate is "
f"holding the value back; in ABSENT Home Assistant served None and the "
f"gate is not armed at all. Two different causes, one `inv.batt is None`",
_last_ref == -3.0 and _last_abs is None
and _c56_ref.inverters[0].batt_fresh.stale is True
and _c56_abs.inverters[0].batt_fresh.stale is False
and _c56_ref.inverters[0].batt is None
and _c56_abs.inverters[0].batt is None)
check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
"ok=False, warn=False, i.e. still NOT READY. The defect was never the "
"pass/fail, it was the row asserting a fact about a named entity that "
"was at that moment present, numeric and updating",
_row56_ref["ok"] is False and _row56_ref["warn"] is False
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
and _row56_ref["text"] == _row56_abs["text"])
check("...and what it says instead is the observation plus BOTH causes, with "
f"{_BATT_ENT} in its own slot - `label` and `entity` are two "
f"interpolations in one sentence and swapping them costs zero bytes, so "
f"the literal around each is pinned (habit 14)",
f"battery power: no usable value this cycle from {_BATT_ENT} - and this "
f"row cannot tell which of two things that is" in _row56_ref["text"]
and "the entity is missing or not numeric" in _row56_ref["text"]
and "a number that this controller is withholding" in _row56_ref["text"])
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
"row below?' answers the question this one declines to answer. A row "
"that pointed at a surface present in both states would be worse than "
"the accusation it replaced",
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks()))
check("...and the row's wording is NOT shared with the log line's, which is a "
"design choice rather than an omission: the two sentences have no clause "
"in common longer than the word 'entity', because one explains a "
"BEHAVIOUR to a log reader and the other tells a page reader whether to "
"trust a NAME. A shared helper would have to drop whichever half the "
"other caller does not need",
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
and "no usable value this cycle" not in _r56_ref)
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
# measures that the register answered a reversed command inside one
# obligation window, which excludes a register that had simply stopped; it
# does not exclude one that was intermittently wrong. That is the point: the
# sentence no longer claims to know either.
# * It does not grade ENGLISH. A wording that named both explanations and then
# said something useless about them would pass 11c.
# * The `no entity configured` branch beside the rewritten row is untouched and
# ungraded here - it has no second explanation, because a blank entity really
# is the only thing it can be.
# * The grid-power and battery-SoC rows go through the same rewritten line and
# have no freshness gate today, so for them the second cause is a hypothesis
# about this controller rather than a live possibility. That is weaker than
# the battery-power case and is the price of not branching per row; a gate on
# either of those signals (TEL-08 would be one) makes it exact.
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
@@ -0,0 +1,300 @@
"""SAFETY-56's behaviour-invariance instrument. `tools/safety56_behaviour_trace.py`
SAFETY-56 rewrote three operator-facing sentences and nothing else. This is what
says so: it drives a fixed stimulus set through the SHIPPED modules and prints
two documents -
`behaviour` every verdict, every `reading()`, every `releases`, every
`inputs_ok` and every readiness row's (ok, warn). NO SENTENCE
REACHES IT, so a tree whose only change is wording must produce
this document byte for byte.
`sentences` the four operator-facing surfaces, from the same stimulus.
Run it in two trees and compare:
python3 tools/safety56_behaviour_trace.py > /tmp/after.json
python3 tools/safety56_behaviour_trace.py --compare /tmp/before.json /tmp/after.json
⚠️ THE CONTROL IS PART OF THE TOOL AND NOT A SEPARATE STEP, WHICH IS MUTATION.md
HABIT 4 IN AN INSTRUMENT. `--compare` REFUSES to report "behaviour unchanged"
unless the `sentences` halves DIFFER. Without that, comparing a tree with itself
- which this card's first run did, by archiving a branch whose edits were not
committed yet - gives a perfect result that means nothing. An identity is
evidence only when the two sides could have differed, and ARCH-02's rehearsal
harness is the worked example of what happens when nobody checks.
⚠️ IT GRADES BEHAVIOUR, NEVER WORDING. Two trees that agree here can still print
different sentences - that is the point. `test_freshness.py` part 11 grades the
sentences themselves.
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13): anything outside `freshness.py`'s
verdict, `_read_unit()`'s gating and `checks()`'s rows; a roster of more than
one; the MQTT and ingress surfaces; and wall-clock behaviour, since every clock
here is the caller's monotonic argument. It is a pin on the three things this
card is forbidden to move, not a full behavioural equivalence.
⚠️ PURGE BEFORE USE on a tree you have just edited. `tools/mutation_run.sh`
does, and MUTATION.md says why `python3 -B` alone does not.
"""
import asyncio
import hashlib
import importlib.util
import json
import logging
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent # the add-on directory
sys.path.insert(0, str(HERE))
BOUNDS = Freshness = Controller = DEFAULTS = None
def _load():
"""The add-on's modules, imported LAZILY so that `--compare` runs anywhere.
⚠️ NOT A STYLE CHOICE. `--compare` is the half a reviewer runs on a laptop,
against two JSON files, in a directory that has no `app/` in it - and a
top-level import would make the comparison impossible exactly where it is
most useful.
"""
global BOUNDS, Freshness, Controller, DEFAULTS
from app.freshness import BOUNDS, Freshness # noqa: E402,F401
from app.main import Controller # noqa: E402,F401
from app.store import DEFAULTS # noqa: E402,F401
A = 5000.0
ONE = dict(meter_source="off", meter_entity="sensor.grid",
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", max_w=A, gain=0.6, slew_w=0,
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
stale_input_s=15, inverters=[], bms_soc_floor=10)
class MemStore:
def __init__(self, d=None):
self.data = dict(DEFAULTS, **(d or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Hass:
def __init__(self, numbers=None, limits=None):
self.numbers = dict(numbers or {})
self.limits_by = dict(limits or {})
self.served = []
self.writes = []
async def number(self, entity, invert=False):
v = self.numbers.get(entity)
self.served.append((entity, v))
return None if v is None else (-v if invert else v)
async def limits(self, entity):
return self.limits_by.get(entity, (None, None, None))
async def set_number(self, entity, value):
self.writes.append((entity, value))
return True
async def call(self, domain, service, data):
return True
def one_site(soc=50.0):
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": soc,
"sensor.batt_a": -1200.0},
{"number.sp_a": (-A, A, 1.0)})
c = Controller(dict(ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
return c, hass
async def _world(name, soc, reg_of, cmd_of, n):
c, hass = one_site(soc)
seen = []
for t in range(n):
hass.numbers["sensor.batt_a"] = reg_of(t)
c.inverters[0].last_write = cmd_of(t)
await c.read_inputs(now_mono=float(t))
seen.append([c.inverters[0].batt, c.inputs_ok,
c.inverters[0].batt_fresh.stale])
return {"name": name, "reads": seen,
"rows": [[r["ok"], r["warn"]] for r in c.checks()],
"n_rows": len(c.checks()), "events": len(c.events),
"inputs_bad": c.inputs_bad_since is not None,
"releases": [i.batt_fresh.releases for i in c.inverters]}
# The command and register schedules. Each of the conjunction's three terms is
# exercised alone and with the others, and the last two schedules take a verdict
# all the way through SAFETY-57's release and back into a re-trip.
CMDS = [lambda t: 0.0, lambda t: 1300.0, lambda t: -1200.0,
lambda t: 1300.0 if t >= 1 else -1200.0,
lambda t: 249.0 if t >= 1 else 0.0,
lambda t: 251.0 if t >= 1 else 0.0,
lambda t: 1300.0 if t < 40 else 0.0,
lambda t: 5.0 if t >= 1 else 1300.0,
lambda t: (t % 7) * 200.0 - 600.0,
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else (
0.0 if t < 90 else 1300.0),
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else 100.0]
REGS = [lambda t: -3.0, lambda t: -1200.0, lambda t: -1200.0 + (t % 3),
lambda t: None if t % 5 == 0 else -1200.0,
lambda t: 0.0, lambda t: float((t * 37) % 11) - 5.0]
WORLDS = [
("DEAD", 50.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
("REFUSED", 11.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
("ABSENT", 50.0, lambda t: None, lambda t: 0.0, 3),
("HEALTHY", 50.0, lambda t: -1200.0 + (t % 3),
lambda t: 1300.0 if t % 2 else -1200.0, 60),
("RELEASE", 50.0, lambda t: -1200.0,
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else 0.0, 60),
("RETRIP", 50.0, lambda t: -1200.0,
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else
(0.0 if t < 80 else 1300.0), 130),
]
def behaviour():
"""Everything this card is forbidden to move. No sentence may enter it."""
_load()
logging.disable(logging.CRITICAL)
out = {}
# 1. the committed live capture, read by read.
spec = importlib.util.spec_from_file_location(
"_s56decode", HERE / "tools" / "decode_bus_capture.py")
dec = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dec)
cap_file = (HERE / "scenarios" / "safety57-2026-09-21"
/ "bus-a-2026-09-21-1030-1110.log.gz")
events, _census = dec.decode(cap_file)
sp, bt = dec.series(events, "setpoint_w"), dec.series(events, "batt_w")
f = Freshness(BOUNDS["batt"])
t = float(int(max(sp[0][0], bt[0][0])) + 1)
t1 = int(min(sp[-1][0], bt[-1][0]))
cap = []
while t <= t1:
f.submit(dec.held_at(bt, t), dec.held_at(sp, t), t)
cap.append([t, f.stale, f.reading(), f.releases, f.held,
f.rested_since, f.graded_from, f.standing_at_grade,
f.released_at])
t += 1.0
out["capture_reads"] = len(cap)
out["capture_trace"] = hashlib.sha256(
json.dumps(cap, sort_keys=True).encode()).hexdigest()
out["capture_edges"] = [r[:4] for i, r in enumerate(cap)
if i == 0 or r[1] != cap[i - 1][1]]
# 2. the swept grid over the policy object.
sweep = []
for ci, cf in enumerate(CMDS):
for ri, rf in enumerate(REGS):
g = Freshness(BOUNDS["batt"])
row = []
for n in range(120):
g.submit(rf(n), cf(n), float(n))
row.append([g.stale, g.reading(), g.releases])
sweep.append([ci, ri, row])
out["sweep_cells"] = len(sweep)
out["sweep_trace"] = hashlib.sha256(
json.dumps(sweep, sort_keys=True).encode()).hexdigest()
out["sweep_stale_reads"] = sum(1 for _c, _r, row in sweep
for s, _v, _n in row if s)
out["sweep_missing_reads"] = sum(1 for _c, _r, row in sweep
for _s, v, _n in row if v is None)
out["sweep_releases"] = sum(row[-1][2] for _c, _r, row in sweep)
# 3. the shipped read path and every readiness row's verdict.
loop = asyncio.new_event_loop()
out["worlds"] = [loop.run_until_complete(_world(*w)) for w in WORLDS]
loop.close()
return out
def sentences():
"""The four operator-facing surfaces, from the REFUSED world."""
_load()
recs = []
class _H(logging.Handler):
def emit(self, record):
recs.append(record.getMessage())
logging.disable(logging.NOTSET)
log = logging.getLogger("goodwe")
log.addHandler(_H())
log.setLevel(logging.DEBUG)
async def go():
c, hass = one_site(11.0)
for t in range(40):
hass.numbers["sensor.batt_a"] = -3.0
c.inverters[0].last_write = 800.0 if t < 1 else 1600.0
await c.read_inputs(now_mono=float(t))
return c
loop = asyncio.new_event_loop()
c = loop.run_until_complete(go())
loop.close()
return {
"reason": c.inverters[0].batt_fresh.reason,
"log": [m for m in recs if "SAFETY-37" in m],
"stale_note": c.inverters[0].stale_note(),
"not_ready_row": [r["text"] for r in c.checks()
if r["text"].startswith("battery power")],
}
def compare(a_path, b_path):
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
beh_same = a["behaviour"] == b["behaviour"]
sent_same = a["sentences"] == b["sentences"]
ha = hashlib.sha256(json.dumps(a["behaviour"], sort_keys=True).encode())
hb = hashlib.sha256(json.dumps(b["behaviour"], sort_keys=True).encode())
print(f"behaviour A {ha.hexdigest()}")
print(f"behaviour B {hb.hexdigest()}")
if sent_same:
# ⚠️ THE REFUSAL, AND IT IS THE POINT OF THE TOOL. Two runs whose
# sentences agree are either the same tree twice or a card that changed
# nothing an operator reads. Either way "behaviour unchanged" is not a
# result, and reporting it would be an identity measured against itself.
print("REFUSED: the `sentences` halves are IDENTICAL, so these two runs "
"cannot be evidence that behaviour survived a WORDING change - "
"they may be the same tree twice. Compare a tree against one "
"whose sentences differ.")
return 2
if not beh_same:
for k in sorted(set(a["behaviour"]) | set(b["behaviour"])):
if a["behaviour"].get(k) != b["behaviour"].get(k):
print(f"BEHAVIOUR MOVED at {k!r}")
return 1
print("OK: sentences differ, behaviour is byte-identical.")
return 0
if __name__ == "__main__":
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
sys.exit(compare(sys.argv[2], sys.argv[3]))
print(json.dumps({"behaviour": behaviour(), "sentences": sentences()},
sort_keys=True, indent=1))