Compare commits

..
6 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 ce46a4b76d SAFETY-38 round 2: assert the ordered pair, and correct the M3 attribution
checks / unit (pull_request) Successful in 5m58s
Two blockers, one of them mine to fix and one a mis-measurement to undo. No
`app/` change in either: the shipped `sum` is correct.

1. A ZERO-BYTE SURVIVOR AT THE CALL SITE ONE LINE BELOW THE PINNED VALUE.
   Round 1 pinned `site_standing` and never pinned WHERE IT GOES. Swapping the
   detector's first two arguments at main.py:1620 -
   `evaluate(site_standing, self.grid, ...)` -> `evaluate(self.grid, site_standing, ...)`
   - is a ZERO-BYTE edit and left the round-1 tree fully green. The cost is the
   banner: the site stands at -500 W of charge into a +2500 W import and the
   LATCHING, SITE-STOPPING alarm reads "commanded +2500 W against a meter
   reading -500 W" - which instrument read what, inverted, on the one line
   somebody woken by it reads first (watchdog.py:658-665).

   Round 1's check could not reach it. It asserted `"-500 W"` appears SOMEWHERE
   in the sentence; under the swap it still does, IN THE METER SLOT. A substring
   test grades PRESENCE, never ARRANGEMENT. It now asserts the ORDERED PAIR:
   `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. Measured:
   red in test_watchdog.py on that one named check, green in the other twenty.

2. THE M3 ATTRIBUTION WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART.
   Round 1 recorded `list(standing.values())[0]` as closed by SAFETY-32's
   package-wide pin and told future authors NOT TO RE-COVER IT. Re-measured on
   release/1.0 @ 86eaef2 carrying only that edit: the full 21-file suite is
   GREEN and test_peakguard.py is PASS PASS PASS PASS PASS in isolation. THE
   MUTATION SURVIVES. It was originally measured once, that run hit CI-06's
   ~20% flake, and the red was recorded as a kill.

   The mechanism, read off 12j's own enumeration rather than argued: 12j taints
   names BOUND to roster values and lists ACCUMULATED in a loop. `standing` is a
   dict filled by SUBSCRIPT assignment (main.py:1554/1558), so it never enters
   the taint set and 1599 is not a roster site. On the mutated tree 12j lists
   main.py:1553, 1555 and 1690 - not 1599. Part 9a and 9b close it.

   Corrected in all four places it was committed: DECISIONS section 1 and
   section 4, CHANGELOG, and the part 9 header that carried the instruction.

MEASUREMENT, all on 86eaef2, fresh copy per mutation, mounted :ro, bytecode
purged by tools/mutation_run.sh, edit re-read from disk, byte delta printed:
  * M1-M10 re-run INDEPENDENTLY rather than carried forward - all 10 red, at
    the deltas the table records (M1/M2/M4 re-run in the exact wrapped form).
  * M11, the argument swap: 0 bytes, red on one check in one file.
  * W1 `"sign_site": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red; part 9a is one of
    the two killers.
  * W2 `"circulation": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red - but ENTIRELY
    on pre-existing checks. This card adds nothing to circulation and does not
    claim it.
Control: 21/21 green before and after. The suite is 21 files, not 20 - TEL-07
added test_mqtt.py in the rebase onto 86eaef2.

DECISIONS section 6a enumerates what was NOT closed: SAFETY-31's circ_wd
detector itself, the other eight CLAIM_SCOPE entries, actuator_of()'s body, the
n_units > 1 guard, and the fact that the "green in the other twenty files" half
of the ledger was re-verified on 86eaef2 only for M3 and M11.

MUTATION.md gains habit 14 - assert the ordered pair, not the substring - and
records this as the tenth instance of the family, found INSIDE THE FIX WRITTEN
TO CLOSE THE NINTH, for the second time in that list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 17:51:10 +02:00
adminandClaude Opus 5 eb7a1e754e SAFETY-38: record the CI-06 false red, and where each mutation lands
All ten mutations of `site_standing` are red in test_watchdog.py and in nothing
else - the other 19 files are green under every one of them, which is the
measurement of how unpinned the line was.

One exception, recorded rather than dropped: in the batch run M10
(`sorted(standing.values())[-1]`) also reported FAIL test_peakguard.py. Re-run
in isolation three times and once more through tools/mutation_run.sh - four
clean passes. The batch script had discarded the failure detail so the check
cannot be named; what can be said is that it did not reproduce in four attempts
and that no claim in this card rests on it. That is CI-06's file behaving the
way CI-06 records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 16:31:17 +02:00
adminandClaude Opus 5 0803149548 SAFETY-38: pin site_standing with shares that cannot answer for the site
`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the
first argument of SAFETY-30's latching, site-stopping sign detector AND the
`commanded_w` handed to SAFETY-18's `looks_frozen()`. It was pinned by nothing.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.

WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) all agree on it. Its values happen to
be equal, and a check comparing two things that happen to be equal cannot see
the difference it names. MUTATION.md habit 4, ninth instance, inside the site
half of the card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.

THE FIXTURE (test_watchdog.py part 9). Two SIMULATED units rated 220 W and
280 W, both obeying exactly, inverted gain, house importing 2000 W: the site
commands -500 W against a +2500 W meter and NO SINGLE SHARE clears the shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.

BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.

SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.

AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @ f72c48d, because every
fixture that existed was built where the two are the same number. They separate
when a device's reported range is narrower than its max_w: the `device` claim is
filed per actuator and never bounds the site target. Closed here by a
counterfactual that asks for -500 W while -250 W stands.

10 mutations of that one line, each on a fresh full-repo copy mounted :ro,
purged by tools/mutation_run.sh, the edit re-read from disk and the byte delta
printed. Ledger and honest gaps in DECISIONS/SAFETY-38-site-standing-pin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 16:31:17 +02:00
admin 86eaef2517 Merge pull request 'TEL-07: a second MQTT device for inverter B, and A keeps the one it has' (#51) from TEL-07 into release/1.0 2026-09-05 15:51:54 +02:00
adminandClaude Opus 5 26c5ee8adc TEL-07: a second MQTT device for inverter B, and A keeps the one it has
checks / unit (pull_request) Successful in 4m58s
MQTT discovery announced ONE device and hung every entity off one unique_id
prefix, so at two inverters B's per-unit telemetry had nowhere to go. It has
somewhere now, and not one entity that already exists moved.

⚠️ THE ASYMMETRY IS DELIBERATE. The first roster entry's per-unit rows are
announced on THE DEVICE IT ALREADY HAS; every later entry gets its own device,
via_device the controller. "One device per inverter" is tidier and is the change
that renames every entity on the live install - and because HA keeps the id it
composed at FIRST discovery, a rename produces a NEW entity beside a DEAD one:
the history stays behind and every dashboard, template and automation naming it
goes on pointing at a sensor that quietly stopped updating, including the three
live watchdog automations. The composed ids come out symmetric anyway
(sensor.goodwe_inverter_a_battery_power / ..._inverter_b_...), so the tidy
design's only real benefit is bought here without its cost.

  * four per-unit rows - setpoint, battery power, SoC, SAFETY-02 sign latch -
    announced only from a roster of two. At N=1 nothing is announced and
    nothing is published: no second device, no renamed entity, no added topic.
  * grid and p1_sample_age_s stay SITE-scoped and are announced ONCE, at every
    roster length 1-6. One P1, one grid connection, and p1_sample_age_s is a
    watchdog input - a second copy is a second answer to "is the meter fresh?".
  * inverter.roster_names() is the one answer to what the units are called,
    read by build_roster() and by the publisher, which must know the roster
    before a Controller exists because announcing happens on the CONNACK.
  * DECISIONS/TEL-07-second-mqtt-device.md records the two designs side by side.

VERIFIED BY DIFFING THE ANNOUNCED PAYLOADS, not by reading the code. The
release/1.0 baseline in test_mqtt.py was generated by running that commit's
module, not transcribed. Roster of one: 9 topics, 0 missing, 0 changed, 0 added.
Roster of two: 0 missing, 0 changed, 8 added.

MEASURED, NOT ASSERTED. Sixteen mutations through tools/mutation_run.sh; twelve
are in the file as a running table, each named with the property it breaks.
Three of those properties exist because the table found their ABSENCE - dropping
the unit from the state topic, moving `grid` into the per-unit set, and
`units=()` in main()'s publisher call were all GREEN on the first run, as was a
roster fallback naming two nameless entries `inverter-a`. Part 9 enumerates what
the file still cannot see, with sizes.

⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every roster of two here
is SIMULATED, from the datasheet and serial in NOTES.md §1.1b. Nothing about B
has been measured and no entity described here has been seen on a broker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 14:14:53 +02:00
admin f72c48dbfd Merge pull request 'UX-01: translations and task ordering for the add-on Configuration tab' (#50) from UX-01 into release/1.0 2026-09-05 13:11:51 +02:00
10 changed files with 2451 additions and 37 deletions
+150
View File
@@ -2,6 +2,156 @@
## Unreleased
**`site_standing` — the one quantity BOTH site guards are asked about — is now
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the
`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by
**nothing**, and three mutations of it disarmed both guards with the whole suite
green. **This card changes coverage only: not one byte of `app/` moves — the
shipped `sum` is correct and must not be "fixed".**
* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the
site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W,
both obeying exactly, inverted gain, house importing 2000 W — the site
commands **−500 W against a +2500 W meter** and no single share clears the
300 W gate. That is the case the site detector exists for, and it is the only
arrangement that can separate `sum` from the mean and from the largest share.
`test_watchdog.py` part 9a.
* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2
site fixture, runs shares of about −2500 W **each** — every one individually
over the gate — so sum, mean and largest-share all agree on it. *Its values
happen to be equal, and a check comparing two things that happen to be equal
cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance,
inside the site half of the very card that names it.
* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** —
`sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted
as a precondition, not described. The asymmetric ratings are load-bearing:
equal shares collapse three of the four onto one number, and the **smaller**
unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the
shipped 300 W**; only the hold is shortened, because the hold is a wall clock.
* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is
`looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the
site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280
and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction
and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at
all and files no floor**, so the battery may keep importing through a quarter
whose billed average is unobservable. `test_watchdog.py` part 9b. The open
quarter is deliberately backfilled with the stuck reading so the mutant cannot
reach the same action by the *coverage* route and mask the difference.
* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter
controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and
`[0]` are **the same number** — all three mutations are genuinely *equivalent*
on a shipped install and no N=1 fixture could ever separate them. Printed and
checked, beside the site detector still being absent there. Part 9c.
* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier
revision of this entry said one of the three — `list(standing.values())[0]` —
was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and
told future authors not to re-cover it. **That was false.** It had been
measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a
measured **~20%** false-red rate), and the red was recorded as a mutation
**kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit:
the **full 21-file suite is green** and `test_peakguard.py` passes **five for
five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot
see it is not the shape: part 12j taints names *bound* to roster values and
lists *accumulated* in a loop, and `standing` is a dict filled by **subscript
assignment**, so it never enters the taint set and `sum(standing.values())` is
not a roster site at all. 12j's own enumeration on the mutated tree lists
`main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed
this card; all three are now covered by it.** *A single red from
`test_peakguard.py` proves nothing — re-run it five times in isolation and
name the failing check.*
* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.**
Pinning the *value* `site_standing` said nothing about **where it goes**.
Swapping the detector's first two arguments —
`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`
— is **zero bytes** and left the tree fully green, because the check asserted
only that `"-500 W"` appeared **somewhere** in the banner. It still does:
**in the meter slot.** The swapped banner reads *"commanded +2500 W against a
meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told
to the operator **exactly backwards**, on the one line somebody woken by a
latching site-stopping alarm reads first. The check now asserts the **ordered
pair** rather than a substring, and is red on it. **No `app/` change:** the
hole was in the test.
* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.**
`site_standing = float(self.target)` — the arbiter's site *target* instead of
the sum of what actually **stands on the wire** — is **−11 bytes** and
survived the full suite, because in every fixture that existed the two are the
same number. They are not when a device's reported range is narrower than its
`max_w`: the `device` claim is filed **per actuator**, so it bounds each
unit's share and never the site target. A site asking for −500 W while −250 W
stands would then take a **latching site-wide false trip** under its own
300 W gate. Closed by part 9a's device-clamped counterfactual.
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
byte delta printed. All 13 red.** Every one was **re-run independently** in
this round rather than carried forward from the previous round's report. The
claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit
scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill
belongs entirely to pre-existing checks and is not claimed by this card.**
⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four
subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and
it is recorded rather than quietly dropped — unlike the one that *was*
believed, two bullets up. Ledger, and an **enumerated list of what was not
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
to go. It has one now — **without touching a single entity that already
exists.**
* **⭐ A's discovery payloads are byte-identical to `release/1.0`.** Same topics,
same `unique_id`s, same payload bytes, same retain flags, in the same order —
verified by **diffing the announced payloads**, not by reading the code:
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
`0 missing, 0 changed, 8 added` at a roster of two. That is what protects the
live install's recorded history and the three watchdog automations that name
its entities.
* **⚠️ THE ASYMMETRY IS DELIBERATE — DO NOT "CLEAN IT UP".** The first roster
entry's per-unit rows go on **the device it already has**; every later entry
gets its **own** device (`via_device` the controller). "One device per
inverter" is tidier and is the change that renames every entity on the live
system — and because HA keeps the id it composed at first discovery, a rename
produces a **new entity beside a dead one**: the history stays behind and
every dashboard, template and automation goes on pointing at a sensor that
quietly stopped updating. The composed ids come out symmetric anyway
(`sensor.goodwe_inverter_a_battery_power` /
`sensor.goodwe_inverter_b_battery_power`).
* **Four per-unit rows** — setpoint, battery power, SoC and the SAFETY-02 sign
latch — announced **only from a roster of two**. At N=1 nothing is announced
and nothing is published: no second device, no renamed entity, no added topic,
asserted rather than assumed.
* **Site-scoped sensors stay site-scoped.** `grid` and `p1_sample_age_s` are
announced **once**, on A's device, at every roster length 1–6. There is one P1
and one grid connection, and `p1_sample_age_s` is a **watchdog input** — a
second copy is a second answer to "is the meter fresh?", one of which is
always the one nobody is looking at. Circulating power, controller status,
maintenance phase and the capacity-tariff peak are site for the same reason.
* **`inverter.roster_names()`** is now the one answer to "what are the units
called", read by `build_roster()` **and** by the publisher — which has to know
the roster **before** a `Controller` exists, because announcing happens on the
broker's CONNACK.
* **`test_mqtt.py`**, a new suite: the recorded `release/1.0` baseline
(generated by running that commit's module, not transcribed), the payload
diffs, the composed entity ids modelled **independently** of `app/mqtt.py` and
anchored on the one id verified against the live system
(`sensor.goodwe_p1_sample_age`), the announcement-vs-publication agreement on
a real `Controller` with **different** SoC, battery sign and latch state on
the two units, and **twelve mutations of the shipped module, all red, each on
a named property**.
* **MEASURED, NOT ASSERTED.** Three of those properties exist because the
mutation table found their absence: dropping the unit from the *state topic*,
moving `grid` into the per-unit set, and `units=()` in main()'s publisher call
were all **green** on the first run. So was a roster fallback that named two
nameless entries `inverter-a`. Sixteen mutations were run through
`tools/mutation_run.sh`; the survivors are enumerated **with their size** in
`test_mqtt.py` part 9.
* ⚠️ **Inverter B does not physically exist** (~2026-09-12). Every fixture here
is **simulated**, from the datasheet and serial in `NOTES.md` §1.1b. Nothing
in this card was measured on a second inverter.
**The add-on configuration screen now speaks English and Dutch instead of raw
option keys (UX-01).** There was no `translations/` directory at all, so Home
Assistant fell back to rendering the option KEYS — an operator opening
@@ -0,0 +1,411 @@
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
**Status: DECIDED 2026-09-05.** `app/main.py`'s
`site_standing = float(sum(standing.values()))` is **correct and is not
changed**. What this card adds is the thing it never had: a **behavioural**
fixture whose per-unit shares **cannot** answer for the site, so that a change
of *meaning* in that reduction goes red instead of green.
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
card: not one byte of `app/` moves.
---
## 0. The line, and its two consumers
```python
site_standing = float(sum(standing.values())) # app/main.py:1598
```
`standing` is `{unit name -> the value THIS controller last put on that unit's
setpoint entity}` — `inv.last_write`, i.e. the number **after**
`clamp_to_rating()` has applied `max_w` and the device's own
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
`cycle()` is:
| consumer | where | what it does with it |
|---|---|---|
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
Both are P0 paths. **Neither was pinned to the reduction.**
## 1. What survived, and what the rescope measured
The card opened with three demonstrated live mutants, all green across the whole
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|---|---|---|
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
structural pin**, wrote that claim into four places, and told future authors
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
part worth keeping.
**How the error was made.** The mutation was measured **once**. That single run
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
was checked, because a red is the answer you are hoping for and it did not look
like it needed a second run.
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
independent re-measurement of the same tree hit the flake once —
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
of the mechanism that produced the original error.)
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
argued.** Part 12j taints names *bound* to roster-carrying values and lists
*accumulated* in a loop over one. `standing` is neither: it is a dict created
empty at `main.py:1554` and filled by **subscript assignment**
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
not a binding 12j follows. So `standing` never enters its taint set and
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
`1690` — the three `self.inverters` sites bracketing this code — and **not
`1599`**. No shape pin reaches this line; only the behavioural fixture below
does.
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
red from it proves nothing. Re-run it in isolation **at least five times** and
**name the failing check** before believing any red from it. This card is the
**third** measurement CI-06 has corrupted.
SAFETY-32's own disclosed blind spot says why the other two were always out of
reach:
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
> permanently. Only the behavioural sets can see them.*
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
different meaning. **No shape pin will ever close them.** That disclosure was
accurate, and is now confirmed by measurement rather than taken on trust.
`[0]` is the one that *looks* structural and therefore *looks* covered — and
that appearance is exactly what produced the false attribution above. It is
out of reach here for a different reason from the other two: not because the
shape is invisible, but because **the value never becomes a roster** in the
analysis's eyes. All three are closed by the same behavioural fixture, and
**all three needed it.**
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
−2500 W **each**. Every one of them is individually far over the gate, so on
those numbers
```
sum = −5000 W over mean = −2500 W over max = −2500 W over
```
— **all three reductions agree**, and a check whose two candidate answers happen
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
third failure mode, and this is its ninth instance.
At a roster of **one** it is not a fixture defect but arithmetic:
`sum == mean == max == the one share`, identically, so all three mutations are
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
them; §3c asserts that rather than leaving it to the reader.
## 3. The fixture, and the numbers it is built on
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
`{-220, -280}` in roster order and the meter reads +2500 W.
| reduction | value | vs the SHIPPED 300 W gate |
|---|---|---|
| `sum` | **−500 W** | **OVER** → trips |
| `sum / len` (the mean) | −250 W | under → silent |
| `max(…, key=abs)` | −280 W | under → silent |
| `list(…)[0]` | −220 W | under → silent |
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
mean, max and first onto one number, and putting the **smaller** unit first is
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
default so the fixture is graded against the magnitude an install runs; only the
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
campaign behind 325 s is measured on the rig, not here.
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
nothing else.** They are not measurements. In particular they are **not**
inverter B, which does not physically exist; B is a GW5048D-ES, serial
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
### 3a. The site sign detector
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
an operator reads is
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
per-unit watchdog trips — every unit is faithfully executing its share, which is
the asymmetry the site detector exists for.
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
with `following=True`.
Two counterfactuals sit beside it so the trip is attributable:
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
does **not** trip, so the trip above is the sum crossing 300 W and not the
mere presence of a second inverter;
* a site whose **device range is narrower than `max_w`** asks for −500 W while
only −250 W stands, and does **not** trip — see §5.
### 3b. The second consumer, covered rather than scoped out
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
discrimination applies at a different threshold: the site rail is
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
| reduction | value | vs the 475 W rail |
|---|---|---|
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
| the mean | 250 W | under → silent |
| `max(…, key=abs)` | 280 W | under → silent |
| `list(…)[0]` | 220 W | under → silent |
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
one and the frozen meter reads import, so the site *discharges* +500 W against a
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
sign latch park everything at 0 W, and the freeze conjunction could then never
be true: a check measuring the *other* guard's remedy.
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
not decoration.** Without it the block is mostly uncovered, `blindness()`
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
route* — a check on the action alone could not then see the difference it names.
Seeded, the measurement is honest:
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
billed quarter is unobservable;
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
importing through a quarter whose billed average cannot be measured. That is
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
The counterfactual comes from the **same run**: on the earlier cycles the
command had not yet reached the rail and the guard did not report SAFETY-18,
with the meter equally frozen throughout — so the verdict is produced by the
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
### 3c. At N=1, nothing changes — asserted
A real one-inverter controller is driven through the same frozen-meter
scenario. Its `standing` has a single entry, so `sum`, the mean,
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
site sign detector is still absent (part 7 counts the calls on the object); and
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
shipped install is unaffected" now rests on the roster rather than on the
reader.
## 4. Mutation ledger
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
fingerprint, and verifies no cache appears during the run). The mutated line was
**re-read from disk** after the edit and the byte delta printed. Shipped
`app/main.py` is 234 542 bytes at `86eaef2`.
| # | mutation of `site_standing` | Δ bytes | verdict |
|---|---|---|---|
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
| **M11** | **argument SWAP** at the call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
Before part 9 existed, **three** of these left the whole suite green — M1, M2
**and M3** — and the remaining seven were caught by SAFETY-30's own N≥2 checks,
none of which grade the *quantity*.
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
batch script had discarded the failure detail, so the failing check cannot be
named here; what can be said is that the red did not reproduce in four attempts
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
CI-06 records as flaky, and this is the behaviour it records. Believe no red
from it without three isolated re-runs.
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
fixture and by nothing else in the repository.** It is claimed here.
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
edit — the same characters, reordered — and it left the round-1 tree **21/21
green**.
What it costs is the banner. `SignWatchdog.evaluate()` formats
`commanded_w` first and `against_w` second, so under the swap part 9a's own
output reads:
```
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
```
The site is standing at **−500 W of charge into a +2500 W import** and the
**latching, site-stopping** banner tells the operator **the exact opposite** —
which instrument read what, inverted. `watchdog.py:658-665` names this failure
in its own words: *"a false statement about which instrument produced the
evidence, on the one line somebody woken by this alarm reads first."*
**Why round 1's check could not see it, and this is the general lesson.** It
asserted
```python
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
```
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
does: in the **meter slot**. A substring test cannot reach a failure that is
purely **positional**, and round 1's own comment claimed the opposite ("*would
put a different number in the store, the action log and the banner, and this is
where that shows*"). It shows the **number**, never its **position**. This is
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
card that exists to complain about exactly that.
**The fix is one line and touches no `app/` code** — assert the **ordered
pair**:
```python
f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
```
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
other twenty files** — one file, one check.
### 4b. Claim wiring, re-measured rather than assumed
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
edits to `arbiter.CLAIM_SCOPE`:
| # | mutation | Δ bytes | verdict |
|---|---|---|---|
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
is one of the two checks that kills W1, so this card contributes to that
coverage rather than merely inheriting it. **W2 is caught entirely by
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
claim to.
## 5. The survivor this card went looking for, and closed
M7 is not one of the card's three. It was found by asking the question the
recurring defect demands — *what mutation would my new checks NOT see?* — and it
is the answer: **−11 bytes**, and it survived every check above **and the whole
20-file suite**, because in every fixture built so far the arbiter's site target
and the sum of what actually stands on the wire are **the same number**.
They are not the same number when a setpoint device's own reported range is
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
site target, and `clamp_to_rating()` clips again on the way out. On two units
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
and **−250 W** stands.
Which one the guards mean is already written down, in both of them: SAFETY-02
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
the whole card about the difference. Those sentences are now made to bite: on
the site target the detector fires on a site standing at −250 W against a
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
false trip on an install whose inverters simply report a narrower range than the
operator typed.
## 6. Honest gaps
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
inv.target`, not on the reduction line — is **not** in this ledger. It is a
different line and a different card; §3a's obedience precondition would see
some of it, but that is not a measured claim and is not made here.
* **The freeze half is graded at ONE roster length (2) and one rail.** The
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
this card is about.
* **Everything about inverter B here is simulated and labelled so.** No number
in this document or in the fixture is a measurement of a device that does not
yet exist.
### 6a. Round 2: what was closed, and what was NOT
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
gaps left open stated rather than rounded off:
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
disk, byte delta printed):**
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
by part 9a and 9b. The false attribution corrected in all four places it was
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
9 header (which previously *instructed future authors not to re-cover it*).
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
independently in round 2 rather than carried forward from round 1's report.
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
the byte deltas are the table's own (+24/+9/+9).
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
killers.
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
pre-existing checks**.
**NOT closed, and not claimed:**
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
`circulating_w()` reduction over the roster. Only its **claim scope** was
mutated (W2), and that kill belongs to checks this card did not write.
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
untouched here.
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
classification statically; that is a different instrument and this card did
not re-measure it.
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
append at `main.py:1764` were **not** mutated.
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
The "green in the other twenty files" half of the round-1 claim was
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
weaker statement than round 1 made and is written down as such.
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
this card's subject. It is *characterised* here (five isolated runs on the M3
tree, all PASS) only far enough to overturn the false kill it produced.
@@ -0,0 +1,115 @@
# TEL-07 — why inverter A does not get a device of its own, and B does
**Status: DECIDED 2026-09-05.** MQTT discovery announces **two** devices at a
roster of two: the existing one, unchanged, plus one for the second inverter.
It does **not** announce one per inverter. This document exists because the
symmetrical design is the obvious one, is tidier, and would destroy the live
system's recorded history — so the next person to read `app/mqtt.py` needs the
reason written down where they will find it before they "fix" it.
Filed with TEL-07. Card `6a9bfa75f0a3550275ba4888`.
---
## 1. The mechanism, in one paragraph
`app/mqtt.py`'s own note records the finding, tested on HA 2026.8: Home
Assistant composes an entity id from the **device name plus the entity name**,
and `object_id` in the discovery payload does **not** override it. So the device
name is half of every entity id.
The second half of the mechanism is the half that makes it silent. Home
Assistant keeps the entity id it composed at **first** discovery. Re-scoping the
device therefore does not rename an entity in place; it creates a **new** entity
beside the old one. The recorded history stays on the old id, the old id stops
updating, and nothing errors. Every dashboard card, template and automation that
names it — including `goodwe_master_watchdog_held_*` and
`goodwe_master_parked_at_0_w_*`, which are live — keeps pointing at a sensor
that has quietly gone flat.
`sensor.goodwe_p1_sample_age` is the sharp end of it: the ESP32's stale-input
watchdog subscribes to that composed name, and its own comment calls this the
failure that "FAILS SILENTLY AND LOOKS CORRECT".
## 2. The two designs
| | **one device per inverter** | **what shipped** |
|---|---|---|
| A's device | new: `inverter-a` | **the one it already has** |
| B's device | new: `inverter-b` | new: `goodwe_rs485_controller_inverter_b` |
| A's existing entity ids | **all renamed** | **byte-identical** |
| A's recorded history | **stranded on dead ids** | kept |
| watchdog automations | **silently broken** | untouched |
| composed per-unit ids | `sensor.goodwe_inverter_a_*` / `..._b_*` | **the same** |
| reads as | symmetrical and tidy | asymmetrical, and needs this file |
The last two rows are the whole argument. The tidy design's only real benefit is
a pair of symmetric, predictable entity ids — and the shipped design produces
**exactly the same ids** by putting the unit's name in the *entity* name for the
first roster entry and in the *device* name for the others. There is nothing
left to buy, and the price was the live system's history.
## 3. What is per-unit and what is site
**Per-unit:** setpoint (this unit's share of the site target, the number its own
heartbeat re-emits), battery power (`Inverter.batt`, register `0x0518`, this
unit's own reading), SoC (`Inverter.soc` — **this** pack; the site row is the
*emptiest* pack, which at N≥2 may be the other one), and the SAFETY-02 sign
latch, which is one instance per actuator by construction.
**Site:** `grid` and `p1_sample_age_s`. There is one P1 meter and one grid
connection, so a per-inverter copy invents two of a thing there is one of — and
`p1_sample_age_s` is a **watchdog input**, so a second copy is a second answer to
"is the meter fresh?", one of which is always the one nobody is looking at.
Circulating power is site because it *is* a property of the set
(`sum(|Bᵢ|) − |sum(Bᵢ)|`); so are the controller status, the maintenance phase
and the capacity-tariff peak.
**Named as absent rather than left looking finished:** the card also lists
"inverter AC power" and per-unit heartbeats as per-unit. Neither has a source in
the add-on today — nothing reads an AC-power register per unit, and
`Inverter.last_beat` is a monotonic loop clock with no wall-clock age published
anywhere. Announcing them would create entities nothing ever feeds, which is the
hazard the `omit` note in `mqtt.py` exists to refuse. They are gaps.
## 4. Why a roster of one announces nothing
Every install running today is a roster of one, and at N=1 the site rows **are**
that unit's rows: `battery` is its power, `soc` is its pack. Four extra entities
would be four new rows carrying numbers already on the dashboard, on a live
system, for no information. `announced_units()` answers `[]` below
`PER_UNIT_MIN_ROSTER`, and `Controller.publish()` reads **that same list** —
the empty list is the gate on both halves, rather than a predicate written twice
that could later disagree.
## 5. How it was verified, and what that does not cover
The claim is a **diff of the announced payloads** against `release/1.0`,
generated by running that commit's module rather than transcribed from it:
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
`0 missing, 0 changed, 8 added` at a roster of two. `test_mqtt.py` part 2 shows
`_announce()` publishes exactly what is diffed, so the comparison is against the
bytes paho is handed.
Sixteen mutations were run. Twelve are in the file as a running table, each
named with the property it breaks. Three properties in that table exist only
because the table found their absence — dropping the unit from the state topic,
moving `grid` into the per-unit list, and `units=()` in main()'s publisher call
were all green on the first run, as was a roster fallback that named two
nameless entries `inverter-a`.
**What it does not cover is listed in `test_mqtt.py` part 9, with sizes.** The
largest item: Home Assistant itself is not in the loop. The entity-id
composition is a **model**, anchored on exactly one measured id
(`sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26).
Closing that needs a broker, an HA and the owner present, and this card is not
allowed near the live install.
## 6. ⚠️ Inverter B does not exist
Not until ~2026-09-12. Every roster of two in this repository's checks is
**simulated**; B's `max_w: 4600` is the datasheet's figure for a GW5048D-ES
(serial `55048ESU22AW0992`), recorded in `goodwe/NOTES.md` §1.1b, and nothing
about B has ever been measured. No entity described here has been seen on a
broker. When the second inverter is installed, the first thing to check is that
the eight ids in §1 are still updating — not that the new ones appeared.
+47
View File
@@ -166,6 +166,53 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
### What appears in Home Assistant at two inverters
**Nothing you already have changes.** The eight entities this add-on has always
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
keep their device, their `unique_id`s and therefore their **entity ids and their
whole recorded history**. At a roster of one, nothing new is announced at all:
no second device, no renamed entity, not one added topic.
Declare a second inverter and **four rows per unit** appear beside them:
| per-unit row | what it is |
|---|---|
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
| `Battery power` | this unit's own reading, `+` = discharging |
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
first inverter's rows are added to the **existing** *GoodWe* device; every later
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
as connected via the controller. Home Assistant composes an entity id from the
**device name plus the entity name**, so giving the first inverter a device of
its own would rename every entity you already have — and because HA keeps the id
it composed at first discovery, you would get a new entity beside a dead one:
the history stays on the old id and every dashboard, template and automation
naming it silently stops updating. The entity ids come out symmetric anyway —
`sensor.goodwe_inverter_a_battery_power` beside
`sensor.goodwe_inverter_b_battery_power`.
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
device page. It is half of every entity id, including
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
to; a rename disarms that guard by leaving it waiting for a state that never
arrives, and it fails silently and looks correct.
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
There is one P1 meter and one grid connection; a per-inverter copy would invent
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
`Circulating power` (it is a property of the *set* of inverters), the controller
status, the maintenance phase and the capacity-tariff peak.
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### P1 meter ingestion
`meter_entity` above expects one signed sensor, which usually means a template
+67 -3
View File
@@ -135,12 +135,15 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
It is not hypothetical and it is not rare. TEN instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
WRITTEN TO CLOSE THE SEVENTH.
COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX
WRITTEN TO CLOSE THE SEVENTH, the ninth inside the SITE HALF of the card
that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
check that closed it asserted a NUMBER and not the number's POSITION.
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
MAINT-05's, is the one that says why: the defect is not carelessness about
@@ -234,6 +237,47 @@ same place twice, the check is decoration.
assertion cannot fail is exactly the thing this section is about. Two habits
follow, and both are habit 5 and 6 below.
* **SAFETY-38** — `main.py`'s
`site_standing = float(sum(standing.values()))`, the first argument of
SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()`
input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site
fixture, and it runs shares of about **−2500 W each** — every one
individually over the 320 W gate it is configured with. On numbers like those
`sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be
swapped for either of the other two and the site guard went **blind with the
whole suite green** — still commanding −500 W of charge into a +2500 W import at
t = 595 s with `following=True`. At a roster of **one** it is worse and it is
not a fixture defect at all: `sum == mean == max == the one share`
*identically*, so every such mutation is genuinely **equivalent** there and no
N=1 fixture could ever separate them. The fixture that separates them is the
one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM
clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings
so that all four candidate reductions give four **distinct** answers.
⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE
POINT OF ASKING.** Having built the fixture, the question *what mutation would
this NOT see?* produced `float(self.target)` — the arbiter's site target
instead of the sum of what stands on the wire — at **−11 B**, green across the
whole suite, because every fixture in the repository was built where the two
are the same number. They separate only when a device's reported range is
narrower than its `max_w`, since the `device` claim is filed **per actuator**
and never bounds the site target. Closed in the same commit, by a
counterfactual that asks for −500 W while −250 W stands.
⚠️ **AND THEN THE FIX ITSELF WAS THE TENTH INSTANCE — A SUBSTRING WHERE THE
FAILURE WAS POSITIONAL.** The check written to pin the banner asserted
`f"{sum(...):.0f} W" in reason`: that **"−500 W" appears somewhere** in the
sentence. Swapping the detector's first two arguments
(`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`)
is a **ZERO-BYTE** edit that leaves both numbers in the string and moves each
into the other's slot — so the substring is still found, **in the meter
slot**, and the whole suite stayed green while the latching site-stopping
banner told the operator which instrument read what **exactly backwards**.
The fix is to assert the **ORDERED PAIR**,
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
The general habit is **14** below.
Note what these have in common with a masked rung: the mutation *runs*, and the
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
can see it, because the code under test really did behave identically on the
@@ -363,6 +407,26 @@ Cheap habits fall out of it, and all of them are now used in this repository:
completeness a reviewer falsifies in ten minutes**, and every round of this
card that claimed the latter was rejected for it.
14. **An `in` test against a formatted sentence grades PRESENCE, never
ARRANGEMENT — so assert the ORDERED PAIR.** SAFETY-38 pinned the banner of a
latching, site-stopping alarm with
`f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears
*somewhere*. Swapping the detector's first two arguments at the call site
(`evaluate(site_standing, self.grid, …)` →
`evaluate(self.grid, site_standing, …)`) is a **ZERO-BYTE** edit: both
numbers are still in the string, each in the other's slot. The substring was
still found — **in the meter slot** — the whole suite stayed green, and the
banner told the operator which instrument read what **exactly backwards**.
Whenever the property you mean is "**this** value went in **this** slot" — a
reason string, a log line, a notification, an MQTT payload — assert the
surrounding **literal text with the value embedded in its own position**
(`f"commanded {x:+.0f} W against a meter reading {y:+.0f} W"`), or assert
the fields **before** they are formatted. Two values that both appear in one
sentence can always be transposed by an edit that costs **nothing**, and
that no byte delta, no fingerprint and no shape pin can see. ⚠️ Note where
this one was found: **inside the fix written to close the previous instance
of the same family**, which is the second time in this list.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+39 -8
View File
@@ -357,6 +357,37 @@ def roster_entries(opts: dict) -> list:
return list(entries)
def roster_names(opts: dict) -> list:
"""The unit names, in roster order. THE one answer, for two callers.
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
every unit past the first its own MQTT device, and `MqttPublisher` is
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
announcing happens on the broker's CONNACK, which can land first. Building a
second roster there would construct a second set of `Inverter`s off the same
Store; transcribing the naming rule there would be a third spelling of it.
So the naming rule lives here, `build_roster()` reads it, and the two cannot
drift.
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
latch would be the other's - a fault on B latching A is the exact failure
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
this only decides what the refusal is allowed to call it in the meantime.
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
which is what every install running today reads.
"""
entries = roster_entries(opts)
if not entries:
return [UNIT_A]
names = []
for i, entry in enumerate(entries):
entry = entry if isinstance(entry, dict) else {}
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
return names
def build_roster(opts: dict, store, sign_cfg=None) -> list:
"""The site's inverters, in roster order.
@@ -414,15 +445,15 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
store=store, sign_cfg=sign_cfg)]
roster = []
for i, entry in enumerate(entries):
names = roster_names(opts)
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
# these names, and it has to build them BEFORE a `Controller` exists - so a
# second spelling of the fallback here would be an entity announced under a
# name no unit answers to. `zip` pairs them positionally, which is what
# `roster_names()` is defined to be.
for entry, name in zip(entries, names):
entry = entry if isinstance(entry, dict) else {}
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
# both called `inverter-a` would share one Store namespace, so one
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
# a nameless entry outright; this only decides what the refusal is
# allowed to call it in the meantime.
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
roster.append(Inverter(
name,
setpoint_entity=entry.get("setpoint_entity", ""),
+32 -2
View File
@@ -53,11 +53,12 @@ from .hass import HomeAssistant
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries)
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import MqttPublisher
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
@@ -3073,6 +3074,28 @@ class Controller:
if self.p1_enabled:
# Recomputed here, once a second, on purpose - see P1Ingest.
values["p1_age"] = round(self.p1.published_age_s, 1)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
#
# ⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
# `announced_units()` answers `[]` at a roster of one, `zip` then yields
# nothing, and the values dict comes out byte-identical to what shipped -
# so the condition that decides what is ANNOUNCED is the same expression
# that decides what is PUBLISHED. Written as a second predicate, the two
# could disagree, and both directions are bad: an announced entity
# nothing feeds (the hazard `omit` exists for) or, at N=1, a published
# topic nothing announced, which AC 4 forbids outright.
#
# ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and
# `soc` stays the EMPTIEST pack; these are additions beside them, not a
# re-pointing of them, because those two ids are on the live install's
# dashboards and in its automations.
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
values[unit_state_key(slug, "setpoint")] = inv.target
values[unit_state_key(slug, "battery")] = inv.batt
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
self.mqtt.publish(values)
async def shutdown(self) -> None:
@@ -3779,6 +3802,13 @@ async def amain() -> None:
broker.get("username") if broker else None,
broker.get("password") if broker else None,
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
# would construct a SECOND set of `Inverter`s off the same
# Store, minutes before `Controller.__init__` constructs the
# real ones; `roster_names()` is the naming rule on its own and
# is what `build_roster()` itself reads. At a roster of one this
# announces nothing new - see `announced_units()`.
units=roster_names(opts),
)
except Exception as err: # noqa: BLE001
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
+232 -24
View File
@@ -90,14 +90,239 @@ SENSORS = [
BASE = "goodwe_ctl"
AVAILABILITY = f"{BASE}/availability"
# --- TEL-07: a SECOND device, for inverter B, and NOT one per inverter -------
#
# ⚠️ READ THE DEVICE-NAME NOTE ABOVE BEFORE CHANGING ANYTHING HERE. The device
# name is half of every entity_id, so re-scoping `DEVICE` renames every entity
# the live install already has - and HA keeps the id it composed at FIRST
# discovery, so what a rename actually produces is a NEW entity beside a dead
# one: the history stays behind on the old id, and every dashboard, template
# and automation that names it (including `goodwe_master_watchdog_held_*` and
# `goodwe_master_parked_at_0_w_*`) keeps pointing at the entity that stopped
# updating. That is silent. So:
#
# `DEVICE` IS NOT TOUCHED AND ITS ENTITIES ARE NOT TOUCHED. It is the SITE
# device - the meter's view and the controller's own state - and at the
# shipped roster of one it is also, in effect, inverter A's.
#
# THE FIRST ROSTER ENTRY GETS NO NEW DEVICE. Its per-unit rows are announced
# ON THE DEVICE IT ALREADY HAS, under new `unique_id`s and new names. Nothing
# existing is renamed, so nothing existing loses its history.
#
# EVERY LATER ENTRY GETS ITS OWN DEVICE (`via_device` the site one).
#
# ⚠️ THE ASYMMETRY IS THE POINT, AND IT WILL LOOK LIKE AN OVERSIGHT. "One
# device per inverter", with A as `inverter-a` and B as `inverter-b`, is tidy,
# symmetrical, and is the change that destroys the live system's recorded
# history. DO NOT "CLEAN THIS UP". The composed entity_ids come out symmetric
# anyway - `sensor.goodwe_inverter_a_battery_power` and
# `sensor.goodwe_inverter_b_battery_power` - which is the whole benefit of the
# tidy version, without the cost.
#
# WHAT IS PER-UNIT AND WHAT IS SITE, and why the split is where it is:
#
# PER-UNIT setpoint (this unit's share of the site target, the number its
# own heartbeat re-emits), battery power (`inv.batt`, this unit's
# own meter reading), SoC (`inv.soc`, THIS pack - the site row is
# the EMPTIEST pack, which at N>=2 may be the other one), and the
# SAFETY-02 sign latch, which is per actuator by construction.
#
# SITE `grid` and `p1_age`. There is ONE P1 meter and ONE grid
# connection; announcing them per inverter would invent two of a
# thing there is one of. `p1_sample_age_s` is additionally a
# WATCHDOG INPUT (see `omit` below and the firmware note above), so
# a second copy of it is a second copy of a safety signal - two
# answers to "is the meter fresh?", one of which is always the one
# nobody is looking at. Also site: circulating power (it is
# `sum(|Bi|) - |sum(Bi)|`, a property of the SET), the controller
# status, the maintenance phase, and the capacity-tariff peak.
#
# NOT ANNOUNCED, AND SAID OUT LOUD RATHER THAN LEFT LOOKING FINISHED: the
# card also lists "inverter AC power" and per-unit HEARTBEATS as per-unit.
# Neither has a source in this add-on today - nothing reads an AC-power
# register per unit, and `inv.last_beat` is a monotonic loop clock with no
# wall-clock age published anywhere. Announcing them would create entities
# nothing ever feeds, which is exactly what the `omit` note below refuses to
# do for `p1_sample_age_s`. They are gaps, not omissions.
UNIT_SENSORS = [
("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),
("battery", "battery_power", "Battery power", "W", "power", "measurement", None),
("soc", "battery_soc", "Battery SoC", "%", "battery", "measurement", None),
("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),
]
# ⚠️ A ROSTER OF ONE ANNOUNCES NOTHING NEW - not a device, not an entity, not a
# topic. Every install running today is a roster of one, and "the upgrade added
# four entities nobody asked for" is a change to a live system's entity list
# for no information: at N=1 the site rows ARE this unit's rows, exactly.
PER_UNIT_MIN_ROSTER = 2
def _slug(text) -> str:
"""Home Assistant's own object_id spelling: lowercase, `_`, no runs.
⚠️ ASCII-ONLY ON PURPOSE. `str.isalnum()` is true for 'é' and for '٤', and
HA transliterates those rather than keeping them, so treating them as
alphanumeric here would produce an id this module claims and HA does not.
They become separators, which is conservative in the right direction: two
names that differ ONLY outside ASCII collide, and `unit_slugs()` below is
what stops a collision becoming two entities with one `unique_id`.
"""
out = []
for ch in str(text).strip().lower():
out.append(ch if (ch.isascii() and ch.isalnum()) else "_")
slug = "".join(out)
while "__" in slug:
slug = slug.replace("__", "_")
return slug.strip("_")
def _title(name) -> str:
"""`inverter-b` -> `Inverter B`. The half of the entity_id HA composes."""
words = str(name).replace("-", " ").replace("_", " ").split()
return " ".join(w[:1].upper() + w[1:] for w in words) or str(name)
def unit_slugs(units) -> list:
"""One id-safe slug per roster entry, in roster order, ALL DISTINCT.
⚠️ THE DE-COLLISION IS NOT DECORATION. `validate.py` rule 9(c) refuses two
entries with the SAME name; it does not refuse `inverter-b` beside
`Inverter B`, and those two slug to one string. Two entities published with
one `unique_id` is not an error in HA - the second one is DROPPED, so one
inverter's telemetry silently stops existing. The suffix is the roster
INDEX, so it is stable for a given roster and does not depend on what the
other unit is called.
"""
slugs, seen = [], set()
for i, name in enumerate(units):
slug = _slug(name) or f"inverter_{i + 1}"
while slug in seen:
slug = f"{slug}_{i + 1}"
seen.add(slug)
slugs.append(slug)
return slugs
def announced_units(units) -> list:
"""The roster, in order - or EMPTY below `PER_UNIT_MIN_ROSTER`.
⚠️ ONE ANSWER TO "WHICH UNITS GET PER-UNIT ENTITIES", read by the
announcement here AND by `Controller.publish()` in main.py. Two spellings of
the same predicate is how an entity gets announced that nothing publishes
to, or published to on a topic nothing announced - and at N=1 the second
would be an added topic, which AC 4 forbids outright.
"""
names = [str(u) for u in units]
if len(names) < PER_UNIT_MIN_ROSTER:
return []
return names
def unit_state_key(slug: str, key: str) -> str:
"""The `publish()` key - i.e. the state topic under `BASE` - for one unit.
Written once, here, because main.py builds it and this module announces it;
a second transcription is a topic announced and never fed.
"""
return f"unit/{slug}/{key}"
def unit_device(name, slug: str) -> dict:
"""B's OWN device. ⚠️ Never returned for the first roster entry."""
return {
"identifiers": [f"{DEVICE['identifiers'][0]}_{slug}"],
"name": f"{DEVICE['name']} {_title(name)}",
"manufacturer": DEVICE["manufacturer"],
"model": DEVICE["model"],
# Hangs B under the controller in HA's device tree rather than beside
# it. Cosmetic, and the one line that says in the UI what the asymmetry
# above means: there is one controller, and it drives both.
"via_device": DEVICE["identifiers"][0],
}
def _payload(name, object_id, unique_id, state_topic, device,
unit, dev_class, state_class, icon) -> dict:
"""One discovery payload.
⚠️ KEY ORDER IS PART OF THE OUTPUT. `json.dumps` writes keys in insertion
order, so this dict's construction order IS the bytes on the wire, and AC 1
is a BYTE comparison against what `release/1.0` publishes. Do not reorder.
"""
cfg = {
"name": name,
"object_id": object_id,
"unique_id": unique_id,
"state_topic": state_topic,
"availability_topic": AVAILABILITY,
"device": device,
}
if unit:
cfg["unit_of_measurement"] = unit
if dev_class:
cfg["device_class"] = dev_class
if state_class:
cfg["state_class"] = state_class
if icon:
cfg["icon"] = icon
return cfg
def discovery_payloads(units=(), omit=()) -> list:
"""Every `(topic, payload)` this add-on announces, in announcement order.
Split out of `_announce()` so that the acceptance criterion can be graded as
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
reading of the code. `_announce()` publishes exactly this list and nothing
else, which is itself checked.
"""
omit = set(omit)
out = []
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in omit:
continue
out.append((
f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon)))
names = announced_units(units)
slugs = unit_slugs(names)
site_slug = _slug(DEVICE["name"])
for i, (name, slug) in enumerate(zip(names, slugs)):
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
# keeps the device it already has and carries its name in the ENTITY
# name instead; every later one gets its own device and does not.
# Either way HA composes device-name + entity-name into the same
# string, which is why `object_id` below can be written once for both.
device = DEVICE if i == 0 else unit_device(name, slug)
prefix = f"{_title(name)} " if i == 0 else ""
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
unique_id = f"{BASE}_unit_{slug}_{key}"
out.append((
f"homeassistant/sensor/{unique_id}/config",
_payload(prefix + label,
f"{site_slug}_{slug}_{oid}",
unique_id,
f"{BASE}/{unit_state_key(slug, key)}",
device, unit, dev_class, state_class, icon)))
return out
class MqttPublisher:
def __init__(self, host, port, username=None, password=None, omit=()):
def __init__(self, host, port, username=None, password=None, omit=(),
units=()):
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
# sensor that nothing will ever publish to is not harmless here:
# p1_sample_age_s is a watchdog input, and an entity that exists but is
# never fed is a worse signal than one that does not exist at all.
self.omit = set(omit)
# TEL-07. The roster's NAMES, in roster order - `inverter.roster_names()`,
# not a second reading of the option. Defaults to empty, which
# `announced_units()` treats exactly as a roster of one: nothing
# per-unit is announced, so every caller that has not been taught about
# the roster keeps publishing what it published before.
self.units = [str(u) for u in units]
self.enabled = mqtt is not None and bool(host)
self.client = None
if not self.enabled:
@@ -130,29 +355,12 @@ class MqttPublisher:
self.enabled = False
def _announce(self) -> None:
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in self.omit:
continue
cfg = {
"name": name,
"object_id": object_id,
"unique_id": f"{BASE}_{key}",
"state_topic": f"{BASE}/{key}",
"availability_topic": AVAILABILITY,
"device": DEVICE,
}
if unit:
cfg["unit_of_measurement"] = unit
if dev_class:
cfg["device_class"] = dev_class
if state_class:
cfg["state_class"] = state_class
if icon:
cfg["icon"] = icon
self.client.publish(
f"homeassistant/sensor/{BASE}_{key}/config",
json.dumps(cfg), retain=True,
)
# ⚠️ NO PAYLOAD IS BUILT HERE. `discovery_payloads()` is the whole
# announcement, so the check that diffs it against `release/1.0` is
# diffing what actually goes on the wire - and a payload built here
# instead would be one this add-on publishes and no check has ever seen.
for topic, cfg in discovery_payloads(self.units, self.omit):
self.client.publish(topic, json.dumps(cfg), retain=True)
self.client.publish(AVAILABILITY, "online", retain=True)
def publish(self, values: dict) -> None:
+800
View File
@@ -0,0 +1,800 @@
"""Runnable check for TEL-07 - the SECOND MQTT device, and A's history.
`python3 test_mqtt.py`
WHAT THIS FILE IS FOR, in one sentence: to prove by DIFFING THE ANNOUNCED
PAYLOADS - not by reading the code - that giving inverter B its own MQTT device
changes nothing about inverter A's.
⚠️ WHY THAT IS THE WHOLE CARD. `app/mqtt.py`'s own note records the finding:
Home Assistant composes an entity_id from the DEVICE name plus the ENTITY name,
and `object_id` in the discovery payload did NOT override it (tested on HA
2026.8). So re-scoping the device renames every entity - and because HA keeps
the id it composed at FIRST discovery, what a rename actually produces is a NEW
entity beside a DEAD one. The recorded history stays behind on the old id, and
every dashboard, template and automation that names it - including the three
live watchdog automations `goodwe_master_watchdog_held_*` and
`goodwe_master_parked_at_0_w_*` - goes on pointing at an entity that has quietly
stopped updating. Nothing errors. That is the failure this file exists to make
impossible to ship.
⚠️ AND THE TEMPTING DESIGN IS THE DESTRUCTIVE ONE. "One device per inverter" -
A becomes `inverter-a`, B becomes `inverter-b` - is symmetrical and tidy and is
exactly the change described above. A KEEPS THE DEVICE IT ALREADY HAS; only B
gets a new one. Part 8 below writes that tidy version and shows it going red.
THE SHAPE OF THE EVIDENCE. Part 1 records what `release/1.0` actually publishes -
generated by running THAT commit's module, not transcribed - and every later
part is a diff against it, reported as `N topics, M missing, C changed, A added`.
Part 1 The recorded baseline, and the diff at a roster of one and of none.
Part 2 `_announce()` publishes EXACTLY `discovery_payloads()` and nothing
else, so parts 1 and 3-8 are diffing what goes on the wire.
Part 3 A roster of two: A's nine messages byte-identical, eight added.
Part 4 Zero overlap between A's entities and B's - topics, unique_ids,
object_ids, state topics, and devices.
Part 5 Site-scoped sensors stay site-scoped, at every roster length 1-6.
Part 6 The composed entity_ids, modelled INDEPENDENTLY of `app/mqtt.py`, and
anchored on the one composed id that was verified against the live
system (`sensor.goodwe_p1_sample_age`, mqtt.py's own note).
Part 7 ⚠️ THE ANNOUNCEMENT AND THE PUBLICATION AGREE, on a real `Controller`,
with the two units carrying DIFFERENT numbers - so a swap is visible.
Part 8 ⚠️ VERIFIED BY BREAKING IT. Twelve mutations of the shipped module,
re-executed from mutated source, each one named with the property it
breaks. A property nothing breaks is a property nothing is watching.
Part 9 What these checks CANNOT see, enumerated and sized.
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
two units are equal cannot observe a bug that swaps them - MUTATION.md habit 4,
and the defect this repository has now shipped six cards in a row.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every number attributed
to it below is SIMULATED, from the datasheet and serial recorded in NOTES.md
§1.1b (GW5048D-ES, serial 55048ESU22AW0992, max_w 4600 W). Nothing here is
measured, and nothing here should ever be quoted as if it were.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
Part 8 sidesteps the whole problem by exec'ing mutated SOURCE into a fresh
namespace, so no `.pyc` is involved at any point.
"""
import ast
import asyncio
import hashlib
import json
import re
import sys
from pathlib import Path
from app import mqtt
from app.inverter import build_roster, roster_names
from app.main import Controller
HERE = Path(__file__).resolve().parent
MQTT_SRC = (HERE / "app" / "mqtt.py").read_text(encoding="utf-8")
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# ---------------------------------------------------------------------------
# The recorded baseline. ⚠️ GENERATED, NOT TRANSCRIBED. Reproduce it with:
#
# git show release/1.0:goodwe_controller/app/mqtt.py > /tmp/mqtt_base.py
# python3 - <<'EOF'
# import sys; sys.path.insert(0, "/tmp"); import mqtt_base as M
# class F:
# def __init__(self): self.m = []
# def publish(self, t, p=None, retain=False): self.m.append((t, p, retain))
# p = M.MqttPublisher.__new__(M.MqttPublisher)
# p.omit, p.client, p.enabled = set(), F(), True
# p._announce()
# for row in p.client.m: print(row)
# EOF
#
# A transcription would be a check that grades this branch against MY MEMORY of
# release/1.0 rather than against release/1.0.
# ---------------------------------------------------------------------------
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
'"model": "ES/BP series"}')
BASELINE = [
('homeassistant/sensor/goodwe_ctl_setpoint/config',
'{"name": "Setpoint", "object_id": "goodwe_setpoint", "unique_id": "goodwe_ctl_setpoint", '
'"state_topic": "goodwe_ctl/setpoint", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_grid/config',
'{"name": "Grid power", "object_id": "goodwe_grid_power", "unique_id": "goodwe_ctl_grid", '
'"state_topic": "goodwe_ctl/grid", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_battery/config',
'{"name": "Battery power", "object_id": "goodwe_battery_power", "unique_id": "goodwe_ctl_battery", '
'"state_topic": "goodwe_ctl/battery", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_soc/config',
'{"name": "Battery SoC", "object_id": "goodwe_battery_soc", "unique_id": "goodwe_ctl_soc", '
'"state_topic": "goodwe_ctl/soc", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "%", "device_class": "battery", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_circulation/config',
'{"name": "Circulating power", "object_id": "goodwe_circulating_power", '
'"unique_id": "goodwe_ctl_circulation", "state_topic": "goodwe_ctl/circulation", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", '
'"icon": "mdi:sync-alert"}', True),
('homeassistant/sensor/goodwe_ctl_phase/config',
'{"name": "Maintenance phase", "object_id": "goodwe_maintenance_phase", '
'"unique_id": "goodwe_ctl_phase", "state_topic": "goodwe_ctl/phase", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:battery-sync"}', True),
('homeassistant/sensor/goodwe_ctl_status/config',
'{"name": "Controller status", "object_id": "goodwe_controller_status", '
'"unique_id": "goodwe_ctl_status", "state_topic": "goodwe_ctl/status", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:heart-pulse"}', True),
('homeassistant/sensor/goodwe_ctl_p1_age/config',
'{"name": "P1 sample age", "object_id": "p1_sample_age_s", "unique_id": "goodwe_ctl_p1_age", '
'"state_topic": "goodwe_ctl/p1_age", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "s", "device_class": "duration", '
'"state_class": "measurement"}', True),
('goodwe_ctl/availability', 'online', True),
]
class FakeClient:
"""Captures what `_announce()` actually hands paho."""
def __init__(self):
self.msgs = []
def publish(self, topic, payload=None, retain=False):
self.msgs.append((topic, payload, retain))
def announce(units=(), omit=(), module=mqtt):
"""The messages `_announce()` publishes, through the SHIPPED method.
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
other part goes through this one, so what is diffed is what paho is told.
"""
pub = module.MqttPublisher.__new__(module.MqttPublisher)
pub.omit = set(omit)
pub.units = [str(u) for u in units]
pub.enabled = True
pub.client = FakeClient()
pub._announce()
return pub.client.msgs
def diff(got, want=BASELINE):
"""`(missing, changed, added)` between two announcement lists, by topic."""
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
missing = [t for t in w if t not in g]
changed = [t for t in w if t in g and g[t] != w[t]]
added = [t for t in g if t not in w]
return sorted(missing), sorted(changed), sorted(added)
def report(label, got, want=BASELINE):
m, c, a = diff(got, want)
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
f"{len(a)} added")
for t in m:
print(f" MISSING {t}")
for t in c:
print(f" CHANGED {t}")
for t in a:
print(f" ADDED {t}")
return m, c, a
def cfgs(msgs):
"""The discovery payloads, parsed, without the availability message."""
return [json.loads(p) for t, p, _ in msgs if t.startswith("homeassistant/")]
# The roster this card exists for. ⚠️ SIMULATED: inverter B does not physically
# exist. Its rating is the datasheet's (NOTES.md §1.1b, GW5048D-ES, serial
# 55048ESU22AW0992) and has never been measured.
OPTS2 = {
"meter_source": "off",
"inverters": [
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 2000, "bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": 4600, "bms_soc_floor": 10},
],
}
NAMES2 = roster_names(OPTS2)
# ---------------------------------------------------------------------------
print("\n1. the recorded baseline, and a roster of one")
# ---------------------------------------------------------------------------
# AC 1 and AC 4. ⚠️ THE COMPARISON IS BYTE-FOR-BYTE ON THE PAYLOAD STRING, not
# on a parsed dict. `json.dumps` writes keys in insertion order, so a reordered
# payload is a different retained message on the broker even though the parsed
# dicts compare equal - and a reordered payload is a re-announcement of every
# entity on a live install.
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
announce(units=roster_names({})))
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
"release/1.0: same topics, same unique_ids, same payload bytes, same "
"retain flags, in the same order - no second device, no renamed entity, "
"no added topic",
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
check("...and the roster-of-one path is the one every install actually reads: "
"an empty `inverters:` names exactly one unit",
roster_names({}) == ["inverter-a"])
check("...and a publisher told NOTHING about the roster announces the same "
"thing, so a caller that has not been taught about units cannot change "
"what a live install sees",
announce(units=()) == BASELINE)
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
announce(units=roster_names({}), omit=("p1_age",)),
[row for row in BASELINE if "p1_age" not in row[0]])
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
"watchdog input is still not announced, and nothing else moved",
(m, c, a) == ([], [], []))
# ---------------------------------------------------------------------------
print("\n2. what is diffed is what goes on the wire")
# ---------------------------------------------------------------------------
# ⚠️ WITHOUT THIS PART, PARTS 1 AND 3-8 GRADE A FUNCTION THE ADD-ON MIGHT NOT
# USE. `_announce()` used to build its payloads inline; if it still did, every
# diff here would be a diff of a second implementation that happens to sit in
# the same file. This is the join.
_direct = [(t, json.dumps(cfg), True)
for t, cfg in mqtt.discovery_payloads(NAMES2)] + \
[(mqtt.AVAILABILITY, "online", True)]
check("`_announce()` publishes EXACTLY `discovery_payloads()`, in order, with "
"retain set, plus the availability message - so every diff in this file "
"is a diff of the bytes paho is handed",
announce(units=NAMES2) == _direct)
# ---------------------------------------------------------------------------
print("\n3. a roster of two: A untouched, B added")
# ---------------------------------------------------------------------------
TWO = announce(units=NAMES2)
m, c, a = report("roster of two (inverter-a + inverter-b, SIMULATED)", TWO)
check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
"BYTE-IDENTICAL - nothing missing, nothing changed. This is the criterion "
"the live install's recorded history depends on, and it is a diff of the "
"payloads, not a reading of the code",
(m, c) == ([], []))
check("...and the eight additions are all B's and A's own per-unit rows, and "
"all of them are NEW topics", len(a) == 8)
check("...and A's nine messages are still the FIRST nine, in the same order, "
"so a broker replaying retained messages sees them unchanged",
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
check("A's per-unit rows stay on THE DEVICE A ALREADY HAS - no new device is "
"created for the first roster entry, which is the whole asymmetry",
len(a_cfgs) == 4
and all(c_["device"] == mqtt.DEVICE for c_ in a_cfgs))
check("⭐ AC 2: B's rows are on B's OWN device, with its own identifiers",
len(b_cfgs) == 4
and all(c_["device"]["identifiers"] == ["goodwe_rs485_controller_inverter_b"]
for c_ in b_cfgs))
check("...and B's device hangs off the controller's via `via_device`, so HA "
"shows one controller driving two inverters rather than two strangers",
all(c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
for c_ in b_cfgs))
check("...and B's device carries the SAME manufacturer and model as the site "
"device - it is the same installation, not a second product",
all(c_["device"]["manufacturer"] == mqtt.DEVICE["manufacturer"]
and c_["device"]["model"] == mqtt.DEVICE["model"] for c_ in b_cfgs))
# A regression pin on the NEW payloads. ⚠️ THIS IS NOT EVIDENCE OF CORRECTNESS -
# it is generated from this branch, so it can only say "these bytes have not
# changed since the PR was reviewed". It exists because B's payloads have no
# released version to be diffed against, and a later edit that quietly changes
# a per-unit `device_class` or state topic should have to be deliberate.
_pin = hashlib.sha256(
json.dumps(unit_cfgs, sort_keys=False).encode()).hexdigest()[:16]
print(" the eight per-unit payloads, in announcement order:")
for c_ in unit_cfgs:
print(f" {c_['unique_id']:<40} {c_['object_id']:<34} "
f"{c_['state_topic']}")
print(f" regression pin (sha256 of the eight payloads): {_pin}")
check("the per-unit payloads are the ones this PR was reviewed with "
"(REGRESSION PIN, not a correctness claim - see the comment)",
_pin == "eb5022b0455158d7")
# ---------------------------------------------------------------------------
print("\n4. zero overlap between A's entities and B's")
# ---------------------------------------------------------------------------
def field(rows, key):
return [r[key] for r in rows]
for key in ("unique_id", "object_id", "state_topic"):
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
sv = set(field(cfgs(BASELINE), key))
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
f"none of B's collides with a SITE entity that already exists",
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
and not (av & sv))
all_ids = field(cfgs(TWO), "unique_id")
check("...and across the WHOLE announcement every unique_id is distinct - an "
"HA discovery collision is not an error, it DROPS the second entity, so "
"one inverter's telemetry would silently stop existing",
len(all_ids) == len(set(all_ids)) == 16)
all_topics = [t for t, _, _ in TWO]
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
# are two different names by the validator's test and one string once slugged.
COLLIDE = announce(units=["inverter-b", "Inverter B"])
col_ids = field(cfgs(COLLIDE), "unique_id")
print(" two names that slug alike ('inverter-b', 'Inverter B') ->",
[c_["unique_id"] for c_ in cfgs(COLLIDE) if c_["unique_id"] not in site_ids])
check("two roster names that differ only in case and punctuation - which "
"`validate.py` rule 9(c) accepts, because it refuses only EXACT "
"duplicates - still get distinct unique_ids",
len(col_ids) == len(set(col_ids)))
# ---------------------------------------------------------------------------
print("\n5. site-scoped sensors stay site-scoped")
# ---------------------------------------------------------------------------
# ⚠️ `p1_sample_age_s` IS A WATCHDOG INPUT (mqtt.py's `omit` note and the ESP32
# firmware's ingest guard). A second copy of it is a second answer to "is the
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
# the same argument without the safety edge: there is ONE grid connection.
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
"goodwe_ctl_phase", "goodwe_ctl_status")
rows = []
for n in range(1, 7):
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
ids = field(cfgs(announce(units=names)), "unique_id")
rows.append((n, [ids.count(s) for s in SITE_ONLY], len(ids)))
print(f" roster of {n}: {len(ids)} entities, "
+ ", ".join(f"{s}x{ids.count(s)}" for s in SITE_ONLY))
check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at every "
"roster length from 1 to 6 - announcing them per inverter would invent "
"two of a thing there is one of, and would duplicate a safety signal",
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
check("...and the site set does not grow with the roster while the per-unit "
"set does: 8 site entities always, 4 more per unit past the first",
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
site_devices = {json.dumps(c_["device"], sort_keys=True)
for c_ in cfgs(announce(units=NAMES2))
if c_["unique_id"] in site_ids}
check("...and every site entity is still on the SITE device, at a roster of two",
site_devices == {json.dumps(mqtt.DEVICE, sort_keys=True)})
# ---------------------------------------------------------------------------
print("\n6. the composed entity_ids")
# ---------------------------------------------------------------------------
# ⚠️ THIS SLUGIFIER IS DELIBERATELY NOT `mqtt._slug`. Importing the module's own
# spelling would make the model and the subject move together, so a mutation in
# the slugifier would change both sides equally and be invisible - "a check
# whose data cannot reach the failure it names", written in one import.
def compose(device_name, entity_name):
"""HA's entity_id for a discovery payload, per mqtt.py's own note."""
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s.strip("_")
# The ONE anchor there is: mqtt.py records that the live entity is
# `sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26,
# and that the firmware subscribes to that composed name. If this model does not
# reproduce that id, the model is wrong and every claim below it is worthless.
p1 = [c_ for c_ in cfgs(BASELINE) if c_["unique_id"] == "goodwe_ctl_p1_age"][0]
check("the model reproduces the ONE composed entity_id that was verified "
"against the live system - `sensor.goodwe_p1_sample_age`, which the ESP32 "
"ingest guard subscribes to - from the device name and the entity name",
compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age")
check("...and that id is NOT the `object_id` in the payload "
"(`p1_sample_age_s`), which is the whole reason the device name is "
"load-bearing and mqtt.py says so in capitals",
p1["object_id"] != "goodwe_p1_sample_age")
composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(TWO)]
print(" every composed entity_id at a roster of two:")
for c_, eid in zip(cfgs(TWO), composed):
print(f" {eid:<46} <- device {c_['device']['name']!r} + "
f"name {c_['name']!r}")
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
"entity can land on top of a site entity that already has history",
len(composed) == len(set(composed)) == 16)
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
check("...and the eight ids that exist today are unchanged by adding B, which "
"is AC 1 restated in the units HA actually uses",
composed[:8] == base_composed)
check("...and the per-unit ids come out SYMMETRIC even though the devices are "
"not - sensor.goodwe_inverter_a_battery_power beside "
"sensor.goodwe_inverter_b_battery_power - which is the entire benefit of "
"the 'one device per inverter' design, without its cost",
"sensor.goodwe_inverter_a_battery_power" in composed
and "sensor.goodwe_inverter_b_battery_power" in composed)
check("...and for every per-unit entity `object_id` equals the composed id, so "
"the two mechanisms agree and an HA that DID honour object_id would "
"produce the same ids as one that does not",
all("sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
for c_ in unit_cfgs))
# ---------------------------------------------------------------------------
print("\n7. the announcement and the publication agree")
# ---------------------------------------------------------------------------
# ⚠️ THE HAZARD mqtt.py's `omit` NOTE NAMES: an entity that exists and is never
# fed is a worse signal than one that does not exist. So the set of per-unit
# topics ANNOUNCED and the set `Controller.publish()` actually writes have to be
# the same set - and, because a matching set of KEYS is not a matching set of
# VALUES, the two units carry DIFFERENT numbers here so that a swap is red.
class _Store:
data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
def __init__(self):
self.last = {}
self.units = []
def publish(self, values):
self.last = dict(values)
def close(self):
pass
def controlled(opts, soc, batt, tripped):
"""A real Controller with per-unit readings planted. SIMULATED values."""
# ⚠️ `stop` is a REQUIRED keyword (CFG-01): a Controller that cannot stop
# itself used to be the default one. These cases only call publish(), so any
# Event will do - but they may not skip it.
pub = _Pub()
ctl = Controller(dict(opts), None, _Store(), pub, stop=asyncio.Event())
for inv, s, b, t in zip(ctl.inverters, soc, batt, tripped):
inv.soc, inv.batt, inv.target = s, b, -1.0 * b
inv.sign_wd.tripped = t
ctl.publish()
return ctl, pub.last
# A 41 / 88 %, A discharging 1200 W, B charging 300 W, and B latched, not A.
# Every one of those six numbers differs from its sibling, which is what makes
# the assertions below able to see a swap.
ctl2, vals2 = controlled(OPTS2, [41.0, 88.0], [1200.0, -300.0], [False, True])
announced_unit_topics = {c_["state_topic"] for c_ in unit_cfgs}
published_unit_topics = {f"{mqtt.BASE}/{k}" for k in vals2 if k.startswith("unit/")}
print(f" announced per-unit topics {len(announced_unit_topics)}, "
f"published {len(published_unit_topics)}, "
f"announced-but-never-fed {sorted(announced_unit_topics - published_unit_topics)}, "
f"fed-but-never-announced {sorted(published_unit_topics - announced_unit_topics)}")
check("⭐ every per-unit entity announced is one the controller actually "
"publishes to, and every per-unit topic published is one that was "
"announced - no entity that nothing feeds, no topic nothing declared",
announced_unit_topics == published_unit_topics)
check("⭐ AND THE VALUES LAND ON THE RIGHT UNIT: A's 41 % on A's topic and B's "
"88 % on B's, not the other way round",
vals2["unit/inverter_a/soc"] == 41.0
and vals2["unit/inverter_b/soc"] == 88.0)
check("...and the same for battery power, whose SIGNS differ between the two "
"units here (A discharging, B charging) so a swap cannot hide in a "
"magnitude",
vals2["unit/inverter_a/battery"] == 1200.0
and vals2["unit/inverter_b/battery"] == -300.0)
check("...and the SAFETY-02 sign latch is per unit: B is latched and A is not, "
"and the two rows say so separately",
vals2["unit/inverter_a/sign_latch"] == "clear"
and vals2["unit/inverter_b/sign_latch"] == "tripped")
check("...and each unit's setpoint row is its OWN share, not the site target",
vals2["unit/inverter_a/setpoint"] == -1200.0
and vals2["unit/inverter_b/setpoint"] == 300.0)
check("...and the SITE rows still mean what they meant: `soc` is the EMPTIEST "
"pack (41 %, not 88 and not the mean) and `battery` is the SUM (900 W), "
"so this card added rows beside them and did not re-point them",
vals2["soc"] == 41.0 and round(vals2["battery"]) == 900)
_, vals1 = controlled({"meter_source": "off", "setpoint_entity": "number.sp"},
[55.0], [700.0], [False])
print(f" roster of one publishes keys: {sorted(vals1)}")
check("⭐ AC 4 on the PUBLISH side: at a roster of one not one `unit/...` topic "
"is written either, so the live install's broker sees no new topic at all",
not [k for k in vals1 if k.startswith("unit/")])
# --- 7b. the wiring, which nothing above reaches -----------------------------
# ⚠️ FOUND BY MUTATION, NOT BY DESIGN. `units=()` in main()'s `MqttPublisher(...)`
# call survived every check in this file: parts 1-7 build the publisher
# themselves, so a controller publishing eight per-unit topics beside a
# publisher announcing none of them was GREEN. That is this repository's
# recurring defect - a check whose data cannot reach the failure it names - in
# the one line that joins the two halves together.
check("`roster_names()` and `build_roster()` give the same names in the same "
"order, on every shape - the flat keys, a declared roster, a nameless "
"entry and a non-dict entry - so the names the publisher is handed are "
"the names the units answer to",
all([inv.name for inv in build_roster(o, None)] == roster_names(o)
for o in ({}, {"setpoint_entity": "number.sp"}, OPTS2,
{"inverters": [{}, {}]},
{"inverters": [{"name": "roof west"}, "junk"]})))
check("...and a nameless entry gets a POSITIONAL name, not `inverter-a` twice: "
"two units under one name share one Store namespace, so one unit's "
"SAFETY-02 latch would be the other's",
roster_names({"inverters": [{}, {}]}) == ["inverter-1", "inverter-2"])
_call = [n for n in ast.walk(ast.parse((HERE / "app" / "main.py").read_text()))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == "MqttPublisher"]
_kw = [k for c_ in _call for k in c_.keywords if k.arg == "units"]
print(f" main.py builds MqttPublisher {len(_call)} time(s); "
f"units= is " + (ast.unparse(_kw[0].value) if _kw else "ABSENT"))
check("⚠️ A SOURCE READ, AND THE WEAKEST CHECK IN THIS FILE - said so rather "
"than dressed up. main()'s one `MqttPublisher(...)` hands it "
"`roster_names(opts)`; nothing else here can reach that line, because "
"main() opens a broker, a web server and a control loop",
len(_call) == 1 and len(_kw) == 1
and ast.unparse(_kw[0].value) == "roster_names(opts)")
# --- 7c. a name that is not ASCII -------------------------------------------
# HA transliterates before it slugs; this module cannot, so it treats anything
# outside ASCII as a separator. Either way the id must be an id.
_odd = announce(units=["caf\u00e9-a", "caf\u00e9-b"])
_odd_unit = [c_ for c_ in cfgs(_odd) if c_["unique_id"] not in site_ids]
print(" a roster named with non-ASCII ->",
sorted({c_["unique_id"] for c_ in _odd_unit}))
check("every unique_id, object_id and state topic this module produces is "
"lowercase ASCII, even from a roster name that is not - an id HA would "
"not have composed is an entity nothing can subscribe to",
_odd_unit
and all(re.fullmatch(r"[a-z0-9_]+", c_[k])
for c_ in _odd_unit for k in ("unique_id", "object_id"))
and all(re.fullmatch(r"[a-z0-9_/]+", c_["state_topic"])
for c_ in _odd_unit))
# The topics the SHIPPED controller actually feeds at a roster of two. Part 8
# grades every mutant announcement against this, which is what turns "the
# announcement changed" into "the announcement now claims an entity nothing
# will ever publish to".
FED = set(published_unit_topics)
# ---------------------------------------------------------------------------
print("\n8. verified by breaking it")
# ---------------------------------------------------------------------------
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
# what makes exec'ing it standalone honest rather than a stub.
def load(pairs):
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
⚠️ EVERY ANCHOR MUST OCCUR EXACTLY ONCE. A mutation whose anchor has drifted
out of the file would otherwise apply to nothing and be reported as a
survivor - a false red on the instrument rather than on the module.
"""
src = MQTT_SRC
for old, new in pairs:
if src.count(old) != 1:
raise AssertionError(f"mutation anchor not unique: {old!r}")
src = src.replace(old, new)
mod = type(sys)("mqtt_mutant")
exec(compile(src, "<mutant>", "exec"), mod.__dict__)
return mod
def properties(mod):
"""Every claim this file makes, as booleans, over ONE module."""
out = {}
try:
base = announce(units=["inverter-a"], module=mod)
two = announce(units=["inverter-a", "inverter-b"], module=mod)
none = announce(units=(), module=mod)
col = announce(units=["inverter-b", "Inverter B"], module=mod)
tc = cfgs(two)
uc = [c_ for c_ in tc if c_["unique_id"] not in site_ids]
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
out["A byte-identical at N=1"] = base == BASELINE
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
out["unique_ids all distinct"] = (
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
out["slug-alike names still distinct"] = (
len(set(c_["unique_id"] for c_ in cfgs(col))) == len(cfgs(col)))
out["no composed entity_id collision"] = len(set(comp)) == len(comp)
out["object_id == composed id"] = all(
"sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
for c_ in uc)
out["A keeps the device it has"] = bool(ac) and all(
c_["device"] == mqtt.DEVICE for c_ in ac)
out["B has its own device"] = bool(bc) and all(
c_["device"]["identifiers"] != mqtt.DEVICE["identifiers"] for c_ in bc)
out["B is via_device the site"] = bool(bc) and all(
c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
for c_ in bc)
out["site sensors announced once"] = all(
[c_["unique_id"] for c_ in cfgs(announce(units=[f"i{j}" for j in range(n)],
module=mod))].count(s) == 1
for n in (1, 2, 3) for s in SITE_ONLY)
# ⚠️ TWO PROPERTIES THAT WERE MISSING, AND THE INSTRUMENT FOUND THEIR
# ABSENCE ITSELF: on the first run of this table, "the unit is dropped
# from the state topic" and "`grid` is moved to the per-unit set" were
# both GREEN. The first collapses A's and B's four topics onto one set
# of four, so both inverters read one number; the second announces a
# per-inverter grid sensor, which is the exact thing AC 3 forbids. The
# ids stayed distinct through both, so every property above missed them.
out["per-unit state topics all distinct"] = (
len({c_["state_topic"] for c_ in uc}) == len(uc))
# ⚠️ GRADED AGAINST THE SHIPPED `Controller.publish()`, on purpose. An
# announcement is only honest if something feeds it - mqtt.py's `omit`
# note - so a per-unit entity the controller will never publish to is a
# defect however tidy its id looks. (A rename applied to BOTH halves at
# once is invisible here and is disclosed in part 9; a rename applied to
# one half is what actually happens, and is red.)
out["every announced per-unit topic is fed"] = (
{c_["state_topic"] for c_ in uc} == FED)
except Exception as err: # a mutation that CRASHES proves nothing; say so
out["did not raise"] = False
out["__error__"] = repr(err)[:70]
return out
SHIPPED = properties(mqtt)
check("the shipped module satisfies every property this instrument grades - "
"so a red below is the mutation and not the instrument",
all(v is True for k, v in SHIPPED.items()))
MUTATIONS = [
("⭐ RE-SCOPE A's DEVICE NAME (the destructive change, AC 5)",
[('"name": "GoodWe",', '"name": "GoodWe Inverter A",')]),
("⭐ RE-SCOPE A's DEVICE IDENTIFIERS",
[('"identifiers": ["goodwe_rs485_controller"],',
'"identifiers": ["goodwe_inverter_a"],')]),
("⭐ THE TEMPTING TIDY DESIGN: one device per inverter, A included",
[("device = DEVICE if i == 0 else unit_device(name, slug)",
"device = unit_device(name, slug)"),
('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
("B's rows land on A's device instead of B's own",
[("device = DEVICE if i == 0 else unit_device(name, slug)",
"device = DEVICE")]),
("A's per-unit entity names lose the unit prefix",
[('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
("per-unit entities announced at a roster of ONE",
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
("the unit is dropped from the unique_id",
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
'unique_id = f"{BASE}_unit_{key}"')]),
("the unit is dropped from the state topic",
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
("the slug de-collision is removed (rule 9(c) does not cover this)",
[("while slug in seen:", "while False and slug in seen:")]),
("every unit takes the FIRST unit's slug",
[("for i, (name, slug) in enumerate(zip(names, slugs)):",
"for i, (name, slug) in enumerate(zip(names, [slugs[0]] * len(slugs))):")]),
("`grid` is moved to the per-unit set (site sensor announced per inverter)",
[(' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n',
' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n'
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
("B's device drops `via_device`",
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
]
survivors = []
for label, pairs in MUTATIONS:
got = properties(load(pairs))
broke = sorted(k for k, v in SHIPPED.items()
if v is True and got.get(k) is not True)
if not broke:
survivors.append(label)
print(f" {'RED ' if broke else 'GREEN'} {label}")
if broke:
print(f" breaks: {', '.join(broke)}")
elif got.get("__error__"):
print(f" raised: {got['__error__']}")
check(f"⭐ ALL {len(MUTATIONS)} MUTATIONS ARE RED, each on a NAMED property: "
"re-scoping A's device, the tidy one-device-per-inverter design, B "
"landing on A's device, per-unit entities at a roster of one, a unit "
"dropped from an id or a topic, two units sharing a slug, and a site "
"sensor announced per inverter. A mutation nothing catches is a claim "
"nothing is watching",
not survivors)
if survivors:
print(" SURVIVED:", "; ".join(survivors))
# ⚠️ AC 6, THE FIXTURE'S OWN NON-VACUITY. If A and B were given the same name
# the whole of part 4 would still be green and would be observing nothing.
same = announce(units=["inverter-a", "inverter-a"])
swapped = announce(units=["inverter-b", "inverter-a"])
check("...and the fixture's two units are genuinely distinguishable: swapping "
"their order changes the announcement, and a roster of two IDENTICAL "
"names - which rule 9(c) refuses, and which this file therefore never "
"relies on - would still not collide",
swapped != TWO
and len({c_["unique_id"] for c_ in cfgs(same)}) == len(cfgs(same)))
# ---------------------------------------------------------------------------
print("\n9. what this file CANNOT see, enumerated and sized")
# ---------------------------------------------------------------------------
for line in [
"1. HA ITSELF IS NOT IN THE LOOP. `compose()` in part 6 is a MODEL of how "
"HA 2026.8 builds an entity_id, anchored on exactly ONE measured id "
"(sensor.goodwe_p1_sample_age, mqtt.py's note, verified 2026-08-26). If HA "
"changes that rule, or if it differs for a device with `via_device`, every "
"id claim here moves with it and this file stays green. SIZE: the 8 "
"per-unit ids at N=2. It cannot be closed from this repository - it needs a "
"broker, an HA, and the owner present.",
"2. THE TICKET AND THE MODULE DISAGREE ABOUT THE LIVE IDs. The card quotes "
"`sensor.goodwe_rs485_controller_goodwe_battery_soc`; mqtt.py records "
"`sensor.goodwe_p1_sample_age`. Those cannot both come from one composition "
"rule - the first is the id an EARLIER device name composed, and HA keeps "
"whatever it composed at first discovery. This card does not need to "
"resolve it, because it renames no device and no entity, and the byte-diff "
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
"wants one look at the live registry, which this branch must not take.",
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
"optional fields.",
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
"paho client, so QoS, retain semantics on a real broker, and what HA does "
"with a retained config for a device it has never seen are all out of "
"scope. SIZE: the whole transport.",
"5. THE SLUG DE-COLLISION IS ORDER-DEPENDENT. `inverter-b` before "
"`Inverter B` gives the SECOND one the `_2` suffix; reordering the roster "
"swaps which unit's ids move. Rule 9(c) does not refuse slug-alike names, "
"so this is reachable from a config an operator can write. SIZE: 4 ids on "
"one unit, and only when two names slug alike. Filed as a follow-up rather "
"than fixed in validate.py here, because a new refusal at startup on a "
"LIVE install is a bigger change than this card is allowed to be.",
"7. PART 8 MUTATES `app/mqtt.py` ONLY. `Controller.publish()`'s four "
"per-unit lines are graded by part 7 - with different SoC, different "
"battery sign and different latch states on the two units, so a swap or a "
"mis-paired slug is red - but no automated table mutates main.py here. "
"Those mutations were run by hand through `tools/mutation_run.sh` and are "
"recorded in the PR. SIZE: 4 lines. Also invisible: a rename of "
"`unit_state_key()` applied to BOTH halves at once, which is harmless "
"(the topics are internal) but would move every per-unit state topic "
"silently.",
"6. `inverter AC power` AND PER-UNIT HEARTBEAT AGE ARE NOT ANNOUNCED. The "
"card lists both as per-unit; neither has a source in the add-on today "
"(nothing reads an AC-power register per unit, and `inv.last_beat` is a "
"monotonic loop clock). Announcing them would create entities nothing "
"feeds. SIZE: 2 sensors x N units, deliberately absent, named in the PR.",
]:
print(" " + line)
# ---------------------------------------------------------------------------
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+558
View File
@@ -1993,6 +1993,564 @@ check("...and the counterfactual again: the same site with nothing latched "
not _bn_n["banner"].startswith("FAILSAFE")
and _bn_n["banner"] != _bn_b["banner"])
print()
print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are")
print(" asked about - pinned by shares that CANNOT answer for it")
# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD.
# `main.py`'s `site_standing = float(sum(standing.values()))` is the first
# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to
# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. THREE mutations of it
# were demonstrated live against a full green suite, and ALL THREE ARE COVERED
# HERE:
#
# sum(...) / max(1, len(standing)) the MEAN
# max(standing.values(), key=abs) the LARGEST SHARE
# list(standing.values())[0] the FIRST SHARE
#
# ⚠️ ROUND 1 OF THIS CARD SAID THE THIRD ONE WAS ALREADY CLOSED BY SAFETY-32's
# PACKAGE-WIDE STRUCTURAL PIN AND TOLD FUTURE AUTHORS "DO NOT RE-COVER THAT ONE
# HERE". THAT WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. It was
# measured ONCE; that single run happened to hit test_peakguard.py's ~20%
# CI-06 flake, the red was recorded as a mutation KILL, and the claim was then
# written into four places. RE-MEASURED IN ROUND 2, on release/1.0 @ 86eaef2
# carrying ONLY the `[0]` edit (+4 bytes, re-read from disk): the full suite is
# 21/21 GREEN, and test_peakguard.py run FIVE times in isolation on that same
# mutated tree is PASS PASS PASS PASS PASS. The mutation SURVIVES. (The
# reviewer's own independent re-measurement of the same tree caught the flake
# once - PASS PASS PASS FAIL PASS - which is the same verdict: a survivor, plus
# a demonstration of the flake that produced the original error.) A SINGLE RED
# FROM test_peakguard.py PROVES NOTHING - re-run it at least five times and NAME
# the failing check before believing it. That rule is CI-06's, and this card is
# the third measurement it has corrupted.
#
# THE MECHANISM, confirmed by reading 12j's own enumeration rather than by
# argument. SAFETY-32's part 12j taints names BOUND to roster-carrying values
# and lists ACCUMULATED in a loop over one. `standing` is neither: it is a dict
# created empty at main.py:1554 and filled by SUBSCRIPT assignment
# (`standing[inv.name] = stood`) inside the roster loop, and a subscript store
# is not a binding 12j follows. So `standing` never enters its taint set at all,
# and `sum(standing.values())` is not a roster site. Run 12j on a tree carrying
# the `[0]` mutation and it enumerates main.py:1553, 1555 and 1690 - the three
# `self.inverters` sites around this code - and NOT 1599. A structural pin
# cannot see this line. Only the behavioural fixture below can.
#
# Identical shape, different meaning - which is precisely the blind spot
# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean
# are edits invisible to any shape pin, permanently"). No shape pin will ever
# close them. Only a behavioural fixture can, and this is it.
#
# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING
# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is
# the only N>=2 site fixture in the suite and it runs at
# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of
# them individually far OVER the gate. On values like those the sum, the mean
# and the largest share all clear it, so all three reductions agree, and a
# check whose two candidate answers happen to be EQUAL cannot observe a change
# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there
# `sum == mean == max == the one share` identically, so every mutation is
# EQUIVALENT there - see 9c, where that identity is asserted rather than
# assumed.
#
# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE
# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W
# stand at their own rails, so `standing` is {-220, -280} in roster order:
#
# sum -500 W |500| > 300 OVER the gate -> TRIPS
# mean -250 W |250| < 300 under it -> silent
# max(..., key=abs) -280 W |280| < 300 under it -> silent
# list(...)[0] -220 W |220| < 300 under it -> silent
#
# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears
# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal
# shares would collapse mean, max and first onto one number and the fixture
# could not tell them apart, and putting the SMALLER unit first is what keeps
# `[0]` distinct from `max(key=abs)`.
#
# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W
# are chosen for the arithmetic in that table and for nothing else. In
# particular they are NOT inverter B, which does not physically exist; B's
# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in
# this section is a measurement of anything.
#
# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is
# left at its default so this fixture is graded against the magnitude an
# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s
# because the hold is a wall clock and the campaign behind 325 s is measured on
# the rig, not here. The gate is the coordinate this section is about.
from app import peakguard as _pg38 # noqa: E402
from app.freeze import rail_w as _rail_w38 # noqa: E402
from app.p1 import P1Sample as _P1Sample38 # noqa: E402
import time as _time38 # noqa: E402
_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above.
_S38_LOAD_W = 2000.0
_S38_FROZEN_METER_W = 2500.0
def _s38_roster(*ratings):
"""A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs.
⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second".
`Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` -
so a unit appended afterwards leaves the site loop bounded at ONE unit's
rating. That is harmless where part 3 uses it and fatal here: the whole
fixture is a site command standing at the ROSTER's rail.
"""
return [{"name": name, "setpoint_entity": f"number.sp_{tag}",
"soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}",
"max_w": w, "bms_soc_floor": 10}
for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"),
("a", "b", "c"), ratings)]
class _S38Plant:
"""Units that obey INSTANTLY and a meter that is `load - sum(measured)`.
The same modelling choice part 3 makes and for the same reason: an instant
plant removes the one mechanism that could make an obedient unit look
disobedient, so what remains to be graded is the ATTRIBUTION.
"""
def __init__(self, load_w=_S38_LOAD_W):
self.load_w = float(load_w)
self.writes = []
self.calls = []
self.ctrl = None
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
async def limits(self, entity):
return None
def measured(self):
if self.ctrl is None:
return []
return [float((i.last_write if i.last_write is not None else i.target) or 0.0)
for i in self.ctrl.inverters]
async def number(self, entity, invert=False):
m = self.measured()
if entity == "sensor.grid":
return self.load_w - sum(m)
for i, tag in enumerate(("a", "b", "c")):
if entity == f"sensor.soc_{tag}":
return 50.0
if entity == f"sensor.batt_{tag}":
return m[i] if i < len(m) else None
return None
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
return True
def _s38_ctrl(hass, ratings, store=None, **over):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": _s38_roster(*ratings),
"gain": -0.6,
# The per-unit pair, distinct from the site pair in BOTH
# coordinates, for part 3's reason: an equality below must be able
# to fail on the magnitude alone or on the hold alone.
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
# ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W
# default is the gate this whole section is graded against.
"site_sign_disagree_s": 2,
# SAFETY-31 is given a long window so the SITE is not stopped by
# circulation while this section grades the sign attribution. It is
# isolation, not a claim that SAFETY-31 is silent here.
"circulation_hold_s": 3600, "auto_start": True}
opts.update(over)
c = Controller(opts, hass, store if store is not None else _MemStore(),
_P(), stop=asyncio.Event())
for inv in c.inverters:
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
c.auto = True
hass.ctrl = c
return c
def _s38_stood(c):
return [inv.last_write if inv.last_write is not None else inv.target
for inv in c.inverters]
print()
print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose")
print(" SUM clears it - the case the site detector exists for")
async def _s38_site():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
seen = []
for _ in range(20):
await c.read_inputs()
await c.cycle()
seen.append(dict(grid=c.grid, stood=_s38_stood(c),
meas=[i.batt for i in c.inverters],
site=c.site_sign_wd.tripped,
units=[i.sign_wd.tripped for i in c.inverters],
following=c.following))
await asyncio.sleep(0.2)
return c, seen
_c38, _s38seen = asyncio.run(_s38_site())
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
_pre38 = [x for x in _s38seen if not x["site"]]
_mid38 = _pre38[-1]
_GATE38 = _c38.site_sign_wd.cfg.min_w
_share38 = list(_mid38["stood"])
_red38 = {"sum": sum(_share38),
"mean": sum(_share38) / max(1, len(_share38)),
"max(key=abs)": max(_share38, key=abs),
"[0]": _share38[0]}
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _share38]} measured "
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold "
f"{_c38.site_sign_wd.cfg.hold_s:.0f} s")
for _k, _v in _red38.items():
print(f" {_k:>13} = {_v:+7.1f} W "
f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate")
check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is "
"graded against the magnitude an install runs and not against a number "
"chosen to make it pass",
_GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0)
check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share "
"is BELOW the site gate and their SUM is OVER it. `_sign_site` above "
"cannot make this statement - its shares are each individually over the "
"gate, so sum, mean and largest-share all agree there and its values "
"cannot observe a change that swaps them",
len(_share38) >= 2
and all(abs(s) < _GATE38 for s in _share38)
and abs(sum(_share38)) > _GATE38)
check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, "
"of which exactly ONE clears the gate. That is what makes this fixture "
"able to tell `sum` from the mean, from the largest share and from the "
"first share - and it is the property the existing site fixture lacks",
len({round(v, 6) for v in _red38.values()}) == 4
and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"])
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is charging into an "
"importing house. Without this the trip below could be a per-unit fault "
"in disguise rather than the site fault this grades",
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
and sum(_mid38["stood"]) * _mid38["grid"] < 0
and _mid38["following"] is True)
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
"Under ALL THREE semantic mutations - `sum -> mean`, "
"`sum -> max(key=abs)` AND `sum -> list(...)[0]` - NOTHING trips, ever: "
"the reviewer's tree was still commanding -500 W of charge into a "
"+2500 W import at t = 595 s with following=True. This is the check all "
"three go red on. `[0]` is here BECAUSE IT IS NOT COVERED ELSEWHERE - "
"see the section header for the mis-measurement that said it was",
_c38.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38.inverters))
print(f" {_c38.site_sign_wd.reason!r}")
check("⚠️ ...and the sentence an operator reads carries THE SUM IN THE "
"COMMANDED SLOT AND THE METER IN THE METER SLOT - the ORDERED PAIR, not "
"two numbers loose in a sentence. ROUND 1 ASSERTED `f'{sum(...):.0f} W' "
"in reason`, i.e. that '-500 W' appears SOMEWHERE, AND THAT WAS A HOLE "
"ONE LINE WIDE. Swapping the detector's first two arguments - "
"`evaluate(site_standing, self.grid, ...)` -> "
"`evaluate(self.grid, site_standing, ...)`, a ZERO-BYTE edit at "
"main.py:1620 - leaves BOTH numbers in the string and moves each into "
"the other's slot, so the substring test still passed and the whole "
"suite stayed green. The swapped banner reads 'commanded +2500 W "
"against a meter reading -500 W': the site is standing at -500 W of "
"charge INTO a +2500 W import and the latching, site-stopping banner "
"tells the operator the exact opposite, on the one line somebody woken "
"by this alarm reads first. watchdog.py:658-665 calls naming the wrong "
"instrument there 'a false statement about which instrument produced "
"the evidence'. A check whose data cannot reach the failure it names is "
"MUTATION.md's own defect; this is the line that closes it",
f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason)
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
"rather than by some other guard happening to stop it",
_c38.target == 0.0 and "sign_site" in _c38.reason
and all(i.target == 0.0 for i in _c38.inverters)
and _c38.store.data.get("site_sign_tripped") is True)
# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the
# SUM crossing the gate rather than by two inverters being present. Same plant,
# same roster shape, same everything - except the units are rated so that the
# sum lands UNDER the gate. Nothing may trip.
_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate
async def _s38_under():
hass = _S38Plant()
c = _s38_ctrl(hass, _S38_SMALL)
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38u = asyncio.run(_s38_under())
print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} "
f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W")
check("...and the counterfactual: the SAME inverted-gain site whose shares sum "
"to 250 W - under the gate - does NOT trip, over the same 20 cycles. So "
"the trip above is the sum crossing 300 W and not the mere presence of a "
"second inverter",
abs(sum(_s38_stood(_c38u))) < _GATE38
and not _c38u.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38u.inverters))
# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS
# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR.
# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of
# the sum of what actually stands on the wire - is -11 bytes, and it survived
# every check above AND the whole 20-file suite, because in every fixture so
# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint
# device's own reported range is narrower than its `max_w`: the `device`
# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`),
# so it bounds each unit's SHARE and never the site target, and
# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W
# and -250 W stands.
#
# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02
# grades "the command standing on the wire", and `looks_frozen()`'s docstring
# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and
# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is
# the whole card about the difference. This check is those sentences made to
# bite: on the site target the detector fires on a site that is standing at
# -250 W against a +2250 W meter - under its own 300 W gate - which is a
# LATCHING, site-stopping false trip on an install whose inverters simply
# report a narrower range than the operator typed.
_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings
async def _s38_devclamped():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
for inv, d in zip(c.inverters, _S38_DEV):
inv.dev_min, inv.dev_max = -d, d
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38d = asyncio.run(_s38_devclamped())
_stood38d = _s38_stood(_c38d)
print(f" device-clamped: site target {_c38d.target:+.0f} W but only "
f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, "
f"meter {_c38d.grid:+.0f} W")
check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT "
"numbers here, on opposite sides of the gate - -500 W asked, -250 W "
"standing. Without that separation this check and the one above compare "
"two values that happen to be equal, which is the defect this card is "
"the sixth instance of",
abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38)
check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON "
"THE WIRE and not what the arbiter asked for. `float(self.target)` is "
"-11 bytes, survives the other 20 files, and turns this ordinary "
"narrow-rating install into a latching site-wide false trip",
not _c38d.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38d.inverters)
and _c38d.target != 0.0)
print()
print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`")
print(" inside `looks_frozen()`, and both mutations blind it too")
# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY.
# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide(
# ..., frozen=...)` call, where it is the EMITTED command graded against the
# site rail. Both surviving mutations shrink it there too, and nothing went red.
#
# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different
# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W.
#
# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W
# mean 250 W < 475 silent
# max(..., key=abs) 280 W < 475 silent
# list(...)[0] 220 W < 475 silent
#
# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED
# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W
# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to
# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain
# would have the site sign latch park everything at 0 W and the freeze
# conjunction could then never be true: a check measuring the other guard's
# remedy.
#
# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped
# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not
# import while the billed quarter cannot be measured". Under both mutations it
# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on
# charging through a quarter whose billed average is unobservable. That is the
# capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
class _S38Src:
"""The transport's own `unchanged_s`, which only homewizard_local has."""
def __init__(self, unchanged_s):
self.unchanged_s = float(unchanged_s)
def _s38_feed(c, w, ts=None, mono=None):
now = ts if ts is not None else datetime.now(timezone.utc)
c.p1.submit(_P1Sample38(
ingest_ts=now,
ingest_mono=mono if mono is not None else _time38.monotonic(),
telegram_ts=now, source="homewizard_local",
import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w,
per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),)))
def _s38_seed_quarter(c, w):
"""Backfill the OPEN quarter with the same stuck value, at 10 s spacing.
⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and
`blindness()` answers BLIND_COVERAGE - which is still blind, still floor
0 W, and would therefore MASK the mutations: the mutant would reach the
same action by a different route and a check on the action alone could not
see the difference it names. Seeded, the mutant is not blind at ALL, which
is the honest measurement of what the mutation costs. A meter that has been
stuck at the same reading all quarter is also what SAFETY-18 is about.
"""
wall = _time38.time()
q0 = (int(wall) // 900) * 900
mono0 = _time38.monotonic() - (wall - q0)
t = float(q0)
while t < wall:
_s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0))
t += 10.0
def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
# ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the
# shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no
# BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site
# climbs 125 W a cycle and crosses 475 W part way through the run, so the
# SAME run carries both answers.
c = _s38_ctrl(hass, ratings, meter_source="homewizard_local",
p1_host="meter.local", gain=gain)
c.p1_source = _S38Src(unchanged_s)
return c
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, ratings)
_s38_seed_quarter(c, meter_w)
seen = []
for _ in range(n):
_s38_feed(c, meter_w)
await c.read_inputs()
await c.cycle()
pg = c.peak_guard
seen.append(dict(stood=_s38_stood(c),
blind=None if pg is None else pg.blind,
floor=None if pg is None else pg.floor_w))
await asyncio.sleep(0.05)
return c, seen
_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W)))
_RAIL38 = _rail_w38(_c38f.site_max_w,
dev_min=_c38f.site_dev_bound("dev_min"),
dev_max=_c38f.site_dev_bound("dev_max"))
_last38 = _s38f[-1]
_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38]
print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; "
f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for "
f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold "
f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)")
for _x in _s38f:
print(" stood %-16s sum %+7.1f W floor=%s blind=%r"
% ([round(v) for v in _x["stood"]], sum(_x["stood"]),
_x["floor"], _x["blind"]))
check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail "
"and their SUM is at or over it - the same discrimination as 9a, at "
"SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the "
"largest share are 220, 250 and 280 W against a 475 W rail",
len(_last38["stood"]) >= 2
and all(abs(s) < _RAIL38 for s in _last38["stood"])
and abs(sum(_last38["stood"])) >= _RAIL38
and len({round(v, 6) for v in
(sum(_last38["stood"]),
sum(_last38["stood"]) / len(_last38["stood"]),
max(_last38["stood"], key=abs),
_last38["stood"][0])}) == 4)
check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids "
"charging - floor 0 W - because the SITE's total command is at the rail "
"while the meter has stopped moving. Under `sum -> mean` and "
"`sum -> max(key=abs)` it is not blind at all and files no floor, so the "
"battery may keep importing through an unmeasurable billed quarter. This "
"is the second consumer, and it is covered rather than scoped out",
_last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0)
check("...and the counterfactual, from the SAME run: on the earlier cycles the "
"site command had not yet reached the rail, and the guard did NOT report "
"SAFETY-18 - with the meter equally frozen the whole time. So the "
"verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, "
"not by `unchanged_s` alone",
len(_below38) >= 2
and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38))
print()
print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted")
# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a
# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME
# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at
# N=1 can ever separate them. That is a fact about the arithmetic, and leaving
# it implicit is how "the shipped install is covered" gets believed. It is
# asserted here, on a real one-inverter controller's own standing command, so
# the claim rests on the roster rather than on the reader.
_S38_ONE = (500.0,)
async def _s38_one():
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, _S38_ONE)
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
for _ in range(8):
_s38_feed(c, _S38_FROZEN_METER_W)
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.05)
return c
_c38one = asyncio.run(_s38_one())
_one38 = _s38_stood(_c38one)
_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)),
max(_one38, key=abs), _one38[0])
print(f" roster of {len(_c38one.inverters)}: stood "
f"{[round(v) for v in _one38]} -> sum/mean/max/first = "
f"{tuple(round(v, 1) for v in _onered38)}")
check("at a roster of ONE the four reductions are ONE NUMBER - so all three "
"mutations of `site_standing` are EQUIVALENT on a shipped install and no "
"N=1 fixture could ever tell them apart. That is why this card needed a "
"roster of two, stated as an assertion rather than left to the reader",
len(_c38one.inverters) == 1
and len({round(v, 6) for v in _onered38}) == 1)
check("...and the one-inverter site still reaches SAFETY-18's verdict on that "
"same number, unchanged: the command is at the rail, the meter has "
"stopped moving, the guard is blind and forbids charging",
_c38one.peak_guard is not None
and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN
and _c38one.peak_guard.floor_w == 0.0
and abs(sum(_one38)) >= _rail_w38(
_c38one.site_max_w,
dev_min=_c38one.site_dev_bound("dev_min"),
dev_max=_c38one.site_dev_bound("dev_max")))
check("...and the SITE sign detector is still absent at a roster of one - part "
"7 counts the calls; this only records that a fixture built the other "
"way round, from an `inverters:` roster, reaches the same state",
not _c38one.site_sign_wd.tripped
and _c38one.site_sign_wd.disagree_since is None)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")