- During a bound episode an earlier over-rating count stays on the page (amber, under its own title); test_clamp pins it. - test_messages pins that every latch row says how it clears. - pv_entities description no longer claims the page names the entity (en, nl); allowlist hash updated for that option-desc line. - Plainer texts: multi-inverter direction banner, "fine now" after an episode, and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py. - TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 KiB
TEL-12 — several PV inverters, and why a partial total is worse than none
TEL-11 shipped pv_entity as a single str? in 0.11.0. The owner of the
reference site reported within minutes of it going live that they have
several PV inverters and could configure one.
That is not a property of one house. A multi-string or multi-orientation roof routinely carries two or three inverters, each publishing its own power sensor into Home Assistant, and the add-on is sold to clients. So the channel had to become a roster.
OPT-10 is blocked on this and cannot start recording until it lands. Which sets the bar: the number this channel publishes has to be one an attribution can believe, or the week of history OPT-10 is waiting for is worse than no history.
1. The surface: pv_entities beside pv_entity, and the measurement that
decided it
Two candidates, and they differ on exactly one question nobody in this
repository could answer by reading: does Supervisor accept a stored STRING for
a key whose schema has become a LIST? 0.11.0 shipped pv_entity: str?; the
live install has a string persisted in it; and an upgrade that refuses that
string leaves an add-on which will not start — CFG-04 §B's trap, in the other
direction, with the schema needed to fix it pinned to the version that cannot be
installed.
It was measured, against the release image, by a new §G of
tools/supervisor_shape_probe.py — the instrument CFG-04 built for exactly this
kind of question:
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
== G. TEL-12: the SHAPE of a several-inverter PV option ==
PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from
pv_entity='sensor.pv_power' pv_entities=[]
PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question)
a list value validates
PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all
string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}]
PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options
PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page
["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one
Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML
[{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]
PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it
== all cases passed ==
| Claim | Evidence |
|---|---|
Option 1 — pv_entity becomes a list — cannot ship |
§G: Invalid list for option 'pv_entity' on the string an 0.11.0 install has stored |
…and a [] default does not rescue it |
§G: _OPTIONS_MERGER's list strategy is override, so the operator's string survives the merge and is refused anyway |
| …and it is not a schema problem | §G: a list value validates fine against a list schema. It is a migration, and a migration is a bigger ticket than this |
One key CAN carry both shapes — match(.*)? |
§G, and reported even though it passes, because "no spelling accepts both" would have been a false reason to reject option 1 |
| …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer 417 all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI |
Option 2 — pv_entities beside pv_entity — ships |
§G: the 0.11.0 blob validates untouched, the new key defaults to [], three entities validate, the UI renders multiple: true |
pv_entities: [] in options: is load-bearing |
§G: the shipped defaults minus only that key are refused with Missing option 'pv_entities' in root — CFG-04 §B's isolated counterfactual, for this key |
| A downgrade is not the trap in reverse | §G: a schema without the key drops it and leaves pv_entity standing |
p1_phase_import_entities (options: [] + schema: - str) is the shipped
precedent for the pair; nothing new was invented.
So the surface is uglier than option 1 and that is the correct trade. The cleanest surface that breaks the one install this card exists to fix is not the cleanest surface.
And pv_entities REPLACES pv_entity rather than extending it
CFG-04's rule for inverters: ("declared, it REPLACES those six keys"), applied
to the only upgrade path that matters. The install being upgraded has
pv_entity set, to inverter A. Opening the Configuration tab, the natural action
is to put all the inverters in the new repeatable field — and under a union
rule that double-counts A unless the operator also remembers to clear the old
key. A doubled total is the same defect as a partial one pointing the other way,
in the one channel that exists to be believed a week later.
Under replacement the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about. It
de-duplicates within the list (a site cannot have two sensors with one
entity_id, so a repeated id is a typo that would double that inverter), and
Controller.checks() prints a caution whenever both keys are set saying
which one won — a replacement nobody is told about is the "one of which silently
wins" config.yaml refuses elsewhere.
⚠️ It is a caution and never a refusal to start. validate.py still does not
mention either key. A telemetry channel that could stop a battery site from
starting would be observability with a foot in the control path.
2. ⚠️⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL
Three inverters configured, one unavailable. The sum of the other two is a
smaller number indistinguishable from a real one. Fed into OPT-10's
attribution it reads as "there was less PV than there was" — which is the "no
irradiance" conclusion the channel exists to test rather than to manufacture.
Two designs were genuinely available:
(a) publish the total as absent — pv.total() answers None, the topic
carries HA's own None, the entity goes to unknown, the recorder stores a gap.
(b) publish the partial sum alongside a count of how many contributed.
(a) shipped. It wins on the one question that matters, which is who reads the number and when:
- The MQTT row is read back cold, a week later, by OPT-10 and by anything
that plots
sensor.goodwe_pv_powerover that week. A partial sum there is a smaller number indistinguishable from a real one, and the count that would have qualified it lives in a second entity with its own history — so believing the total requires joining two recorder series by timestamp to find out which samples to distrust. Nobody re-derives that. And it is worse than a gap in exactly the direction that matters: a gap makes the attribution say "I do not know"; a partial makes it say something false. - The status page is read live, by a human who wants the diagnostic. So the partial information is not thrown away — the row names every entity that did report, with its watts, and every one that did not, with its reason, and states plainly that there is no site total. Different reader, different instant, different rule.
A count published beside the total would also carry no information in the
state (a) admits: the total is published only when every entity contributed, so
the count would be the constant len(roster).
And this extends TEL-11's rule rather than weakening it. mqtt.py's
ABSENT_AS_UNKNOWN is still exactly one key and publish()'s None handling is
untouched: the line that turns an absence into HA's unknown did not have to
change, because "absence is never zero" already covered this one layer up.
The mirror, which matters just as much
A member honestly reporting 0 W is summed, not treated as absent. The
test is watts is None, never falsiness. Shading, a tripped string and
nightfall are all real zeros, and a roster that blanked the site whenever one
member read zero would report an absence on every clear evening and defeat
the channel from the other end.
⚠️ And the answer is NOT "document a template sensor that adds them up"
An operator may legitimately prefer one, and pv_entity will happily point at
it. Shipping it as the answer pushes the absence problem onto them unseen: a
template sensor summing an unavailable source silently yields the partial
total — the same defect one layer down, in a place this add-on cannot see or
report. DOCS.md says so in those terms.
The three published states, side by side
| what happened | frame | goodwe_ctl/pv |
status page |
|---|---|---|---|
| no roster at all | no pv key |
no topic, no entity | no row |
| 1200 / 2100 / 450 W, all three | pv: 3750.0 |
3750.0 |
PV 3 750 W + one ok row naming all three |
all three answer, one of them 0 W |
pv: 1650.0 |
1650.0 |
PV 1 650 W |
one of the three unavailable |
pv: None |
None → unknown, recorder gap |
PV — + a caution naming the missing entity, its reason, and what the others reported |
| all three gone | pv: None |
as above | as above |
3. Still not a control input
Unchanged, and re-proved at the new roster size.
tools/tel11_pv_trace.py was extended rather than replaced — the file name
still says TEL-11 because DECISIONS/TEL-11-pv-production.md §5 and
test_pv.py's mutation table quote it, and a renamed instrument makes both
references dangle for no gain.
It now produces three document sets per tree — unset, one, multi — and
grades five claims. The roster cells live in their own document precisely so
unset and one keep the control hashes TEL-11 recorded; appending cells to
an existing document would have moved a hash whose stability is itself the
evidence.
$ python3 tools/tel11_pv_trace.py --compare base.json head.json
HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them.
supports_roster A False / B True (self-reported; the verdict above is MEASURED)
tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4
tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c
telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff
OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. …
OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement.
OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision.
OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells.
OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W.
$ echo $?
0
The control hash is unmoved: 846a10a5b89e… on unset and on one, on both
trees. The roster control document 2aafbfedfb9c… is byte-identical between
the two trees, which is the strongest available form of the claim — the baseline
runs those cells with a key it has never heard of, so its control document for
them is 0.11.0's.
⚠️ 2/5 is a claim TEL-11 could not make. TEL-11's baseline had no channel at
all to be identical to; TEL-12's baseline is 0.11.0, so "one entity behaves
exactly as 0.11.0 does" is a real cross-tree identity over both documents.
pv.total() passes a single entity's (watts, why) through verbatim for exactly
this reason, and Controller.checks() keeps 0.11.0's two f-strings in a
len(...) == 1 branch rather than generalising them.
⚠️ TEL-11's --compare refusal had to change, and that is a finding
TEL-11's rule was "exactly ONE side's set document may differ from its own
unset — that side is the HEAD". It was correct evidence when written, because
release/1.0 predated pv_entity entirely. release/1.0 is 0.11.0 now, both
sides react to pv_entity, and that refusal fires on every honest run. The
discriminator is the roster instead: only the HEAD has heard of pv_entities, so
only the HEAD's multi document fails to reduce to its own unset one.
It is asserted from the documents, not from tree.supports_roster — a
self-report is the tree describing itself, whereas "this side's behaviour did not
move when pv_entities was set" is the tree being measured. supports_roster is
printed beside the verdict so a disagreement between the two is visible.
See MUTATION.md habit 24.
4. Mutation battery
Twelve mutations, each on a fresh cp -a of the clean worktree (never
reverted — habit 18 / TARIFF-09's method), __pycache__ purged before the run
and the tree checked for one afterwards, byte delta from stat and the edit
confirmed on disk with cmp, suites run in the CI image with checks.yml's own
command one file at a time under a :ro mount.
| mutation | bytes | reds |
|---|---|---|
N1 total() returns the partial sum, not an absence |
+42 B | 6 named in test_pv.py + 1 crash |
| N1b ⭐ THE REJECTED DESIGN: the partial sum with a contributor count | −282 B | 10 named + the trace REFUSED 5/5 |
N2 absence tested by falsiness (not w) |
−4 B | 2 named |
N3 pv_entities UNIONS with pv_entity |
−27 B | 3 named |
| N4 the de-duplication dropped | −15 B | 1 named |
N5 sum → max over the roster |
+0 B | 6 named |
N6 the roster-of-one stops passing why through |
+13 B | 8 named |
N7 optional_keys() back to reading pv_entity |
−6 B | 1 named + 1 crash |
N8 self.grid -= self.pv_w in read_inputs() |
+106 B | 3 named + the trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) |
N9 the roster caution made a bad row (NOT READY) |
+1 B | 1 named |
N10 entity_is_ignored() always False |
+10 B | 2 named |
N11 pv_entities: [] deleted from options: |
−18 B | 2 named in test_translations.py + 11 CASE(S) FAILED in the probe |
No survivors.
⚠️ N5 is +0 B — the risky delta. sum and max are the same three
characters, so neither the file size nor its mtime moved, and TEST-15's
stale-bytecode defect is exactly what manufactures a survivor there. The run was
on a fresh purged copy and cmp confirmed the edit on disk before it started.
⚠️ N1b is the row that matters, and it is not a defect. It is the other defensible design, implemented and run. The point is that the checks and the instrument can tell the two apart — an unexamined partial sum is what the card forbids, so a check that could not see one would be decoration:
REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'),
('IMPORT-3K/EAST-DEAD', '2550.0'), ('IMPORT-3K/GARAGE-DEAD', '3300.0'),
('IMPORT-3K/WEST-DEAD', '1650.0')] … - a smaller number indistinguishable from a
real one, which is the defect this card exists to prevent.
⚠️ N8's 1/5 stays GREEN and that is correct rather than a miss. With no PV
entity configured pv_w is None and the leak cannot fire — which is what makes
1/5 a claim about the default and 2/5–4/5 the claims about the channel.
The fixture values are the design (habit 10 and habit 23)
test_pv.py carries two rosters over the same three entities, because the
two claims need different magnitudes and one fixture cannot serve both.
- Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W. The sum is separable from max (2100), min (450), first (1200), last (450), mean (1250), count (3) and every partial of itself (2550 / 1650 / 3300) — the family is computed, printed and asserted non-colliding as its own precondition. One member is in kW so "correct against their own units" is exercised. Not one member is zero: a member contributing 0 W could go absent without moving the sum of the rest, and a fixture like that cannot tell "published the absence" from "published the partial sum" — habit 23 with a coinciding value in the fixture rather than in the plant, which is precisely the trap the brief named. Absence is driven at every position of the roster, not at one (habit 10's fixture-as-a-SET), and each position's partial is derived from the fixture rather than typed beside it.
- Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W. Sized so every partial (6500 / 3500 / 6000) would still move a loop settling on a 600 W import. Habit 23's own question is asked twice — shift the meter by 8000 W, and again by 3500 W, the smallest partial any single dead member could produce — because a leak of the partial is a different mutation leaking a smaller number.
Two false results this card produced, and what they were
Both were in the instrument, and both are recorded rather than tidied away.
partial_w/partial_wirewere first written inside each telemetry cell. They are a fact about the stimulus, produced by the tool and identical on both trees — so every roster cell differed from its unset reference for a reason that was not behaviour, and the HEAD/baseline discriminator fired on both sides. They live in a separate top-levelpartialsmap now.- The
WEST-ZEROcell (all three answer, one honestly reporting 0 W) had its true total labelled as its "partial". 5/5 then refused the shipped tree for publishing 1650 W, which it is required to publish. A cell with no dead member has no partial to leak, andneverisNonethere.
5. What none of this can see
- Home Assistant itself. That
Noneon the state topic renders as stateunknownand that the recorder keeps a gap is read out of HA's source and quoted inapp/mqtt.py; whether the long-term statistics table then interpolates across the gap is HA's behaviour, and OPT-10 will be reading it. - Whether the operator's sensors are telling the truth. A sensor wired
backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no
pv_invert— §2 of the TEL-11 record is the argument, and it holds at a roster. - A roster larger than three. Three is the smallest size at which the sum
separates from the reduction family and absence can be driven at every
position. Nothing here says what twenty entities do to a cycle's latency — the
read is
asyncio.gathered across the roster for exactly that reason, so a slow sensor costs what one costs, but that is an argument rather than a measurement. - A PV roster combined with a two-inverter battery roster. No fixture
crosses CFG-04's
inverters:withpv_entities. - The real MQTT transport, and wall-clock behaviour. As TEL-11.
6. Not deployable as merged
config.yaml stays at 0.11.0, so tools/predeploy_check.py reports a NO-OP
until PKG cuts a version. A CHANGELOG entry is wanted and was deliberately
not written: test_version_gate.py 11b forbids an ## Unreleased heading and
the bump is PKG's call. The material an entry needs is this file.
The option surface moves 76 keys → 77, one new optional key, pv_entities,
empty on every install that does not set it.
Superseded in part (UX-08, 2026-09-27): the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See UX-08-short-notifications.md, phase 2b item 3.