TEL-07 — a second MQTT device for inverter B, without renaming A
MQTT discovery announced one device (app/mqtt.py:27) and every entity hung
off one unique_id prefix, so at two inverters B's per-unit telemetry had
nowhere to go: it would collide with A's or not be announced at all. It has
somewhere now, and not one entity that already exists moved.
⚠️ The asymmetry is deliberate. Please do not "clean it up".
app/mqtt.py:20-24 records the finding this rests on: HA 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). The second half of the
mechanism is the half that makes it silent: HA keeps the id it composed at
first discovery. So re-scoping the device does not rename an entity in place —
it creates a new entity beside a dead one. The recorded history stays on the
old id, the old id stops updating, and nothing errors. Every dashboard card,
template and automation naming it keeps pointing at a sensor that has quietly
gone flat, including the three live watchdog automations
(goodwe_master_watchdog_held_*, goodwe_master_parked_at_0_w_*) and sensor.goodwe_p1_sample_age, which the ESP32's stale-input guard subscribes to.
one device per inverter (tempting)
what this PR does
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
The last row is the argument. The tidy design's only real benefit is a pair of
symmetric, predictable ids — and this one 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. DECISIONS/TEL-07-second-mqtt-device.md says
this where the next reader will find it before they refactor.
⭐ The evidence is a diff of the announced payloads
Not an assertion that the code looks right. The release/1.0 baseline in test_mqtt.py was generated by running that commit's module (the command is
in the file) rather than transcribed — a transcription would grade this branch
against my memory of release/1.0.
roster of one (`inverters:` empty — i.e. every install running today):
9 topics, 0 missing, 0 changed, 0 added
roster of one, P1 off (omit=('p1_age',)):
8 topics, 0 missing, 0 changed, 0 added
roster of two (inverter-a + inverter-b, SIMULATED):
9 topics, 0 missing, 0 changed, 8 added
ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_a_{setpoint,battery,soc,sign_latch}/config
ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_b_{setpoint,battery,soc,sign_latch}/config
Part 2 shows _announce() publishes exactlydiscovery_payloads(), so what
is diffed is the bytes paho is handed — without it every diff here would be a
diff of a second implementation sitting in the same file.
The composed entity ids at a roster of two, modelled independently of app/mqtt.py (importing its own slugifier would move the model and the subject
together) and anchored on the one composed id that was verified against the live
system, sensor.goodwe_p1_sample_age:
The first eight are unchanged; all sixteen are distinct; and for every per-unit
entity object_idequals the composed id, so an HA that did honour object_id would produce the same ids as one that does not.
Which sensors are per-unit, and why
Per-unit:setpoint (this unit's share of the site target — the number its
own heartbeat re-emits), battery (Inverter.batt, register 0x0518, this
unit's own reading), 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 (mqtt.py:98), so a second copy is a
second answer to "is the meter fresh?", one of which is always the one nobody is
looking at. Also site: circulating power (it is a property of the set, sum(|Bᵢ|) − |sum(Bᵢ)|), controller status, maintenance phase, capacity-tariff
peak. Asserted, at every roster length 1–6: each announced exactly once.
Named as absent rather than left looking finished: the card also lists inverter AC power and per-unit heartbeats. 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 what the omit note exists to
refuse. They are gaps, and they are in the gap list.
At a roster of one nothing is announced and nothing is published — no
second device, no renamed entity, no added topic — and the same expression
(announced_units()) is the gate on both halves, so the two cannot disagree.
⚠️ Measured, not asserted — and the mutation table found three holes itself
Sixteen mutations were run through tools/mutation_run.sh (bytecode purged, tree
fingerprinted, byte delta printed, every edit re-read from disk). Twelve live in test_mqtt.py part 8 as a running table that re-executes mutated source into
a fresh namespace, so no .pyc is involved at any point; each is named with the
property it breaks.
Three properties in that table exist only because the table found their
absence. On the first run these were GREEN:
survivor found
what it would have shipped
the unit dropped from the state topic
A's and B's four topics collapse onto one set — both inverters read one number
grid moved into the per-unit set
a per-inverter grid sensor, the exact thing AC 3 forbids
units=() in main()'s publisher call
the controller publishes eight per-unit topics beside a publisher announcing none of them
A fourth, in inverter.py: the roster fallback naming two nameless entries inverter-a (one Store namespace, so one unit's SAFETY-02 latch is the other's)
survived everything. All four are red now. The wiring one is closed by the weakest check in the file — an AST read of main()'s one MqttPublisher(...)
call — and it says so in its own text rather than dressing it up, because nothing
else in the file can reach a line that opens a broker, a web server and a control
loop.
On the publisher half, mutations run by hand and all red: the SoC row fed from inv.batt (+1 byte, and it changes a real answer — A's 41 % becomes 1200);
the roster paired reversed (+16 bytes, 4 checks red); the latch pinned to clear; the N≥2 gate dropped on the publish side only; every unit's setpoint row
showing the site target. The fixture gives A and B different names, different
SoC (41/88 %), opposite battery signs (+1200/−300 W) and different latch states,
which is what makes a swap observable at all — MUTATION.md habit 4, and the
defect this board has shipped six cards running.
The SAFETY-32 package-wide structural pin already covers the new reductions
test_peakguard.py part 12j now enumerates 9 roster reductions in mqtt.py,
found from its one seed with no fixture and at every roster length:
mqtt.py:198 unit_slugs for stmt enumerate(units) / units
mqtt.py:216 announced_units for..in units
mqtt.py:217 announced_units len() names
mqtt.py:292 discovery_payloads for stmt enumerate(zip(names, slugs)) / names / slugs
mqtt.py:362 _announce for stmt discovery_payloads(self.units, self.omit)
None is sliced. That is structural coverage of "the whole roster arrives", so
this PR does not duplicate it; what it adds is the behaviour the pin cannot
see (which device, which id, which topic, which value).
⚠️ Honest gaps, enumerated with their size (printed by part 9)
HA itself is not in the loop.compose() is a model, anchored on
exactly one measured id. If HA changes that rule, or if it differs for a
device with via_device, every id claim moves with it and the file stays
green. Size: the 8 per-unit ids. Needs a broker, an HA and the owner
present — this branch must not go near the live install.
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 as verified live on 2026-08-26. Those cannot
both come from one composition rule — the first is what an earlier device
name composed, and HA keeps what it composed first. 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 ids.
B's payloads have no released baseline. A's are diffed against release/1.0; B's can only be pinned to this branch (a sha256 of the eight
payloads). A wrong device_class is caught as a change from review, not as
an error. Size: 4 sensors × 4 optional fields.
No broker runs. QoS, real retain semantics, and what HA does with a
retained config for a device it has never seen are out of scope. Size: the
whole transport.
The slug de-collision is order-dependent.validate.py rule 9(c) refuses exact duplicate names, not inverter-b beside Inverter B; those slug to
one string, and two entities under one unique_id is not an error in HA — it drops the second, so one inverter's telemetry silently stops existing. unit_slugs() suffixes the later one with its roster index, so reordering the
roster swaps which unit's ids move. Size: 4 ids on one unit, only when two
names slug alike. Left as a follow-up rather than a new startup refusal,
because a fresh refusal on a live install is a bigger change than this
card is allowed to be.
inverter AC power and per-unit heartbeat age are not announced. See
above. Size: 2 sensors × N units, deliberately absent.
Part 8 mutates app/mqtt.py only.Controller.publish()'s four per-unit
lines are graded by part 7's differing values; their mutations were run by
hand (listed above). Size: 4 lines. Also invisible: a rename of unit_state_key() applied to both halves at once — harmless, since the
topics are internal.
⚠️ Inverter B does not physically exist (~2026-09-12)
Every roster of two in this PR is simulated. max_w: 4600 is the datasheet
figure for a GW5048D-ES (serial 55048ESU22AW0992), recorded in NOTES.md
§1.1b; nothing about B has ever been measured, and 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 existing ids are still updating — not that the new
ones appeared.
Checks
for f in test_*.py; do python3 -B "$f"; done — 21/21 PASS, run both in goodwe-addon-ci and in CI's own goodwe-addon-checks:1 with the checkout
mounted read-only and PYTHONDONTWRITEBYTECODE=1. test_mqtt.py writes nothing
to disk. test_peakguard.py was additionally run 4× in isolation (CI-06):
green every time, no red to disbelieve. Bytecode cache before the graded run: 0;
after: 0.
No deploy, no hardware touched, no rig, no live Home Assistant. The add-on is
live on 0.6.0 and this must not reach it without the owner present.
## TEL-07 — a second MQTT device for inverter B, without renaming A
MQTT discovery announced **one** device (`app/mqtt.py:27`) and every entity hung
off one `unique_id` prefix, so at two inverters B's per-unit telemetry had
nowhere to go: it would collide with A's or not be announced at all. It has
somewhere now, and **not one entity that already exists moved.**
### ⚠️ The asymmetry is deliberate. Please do not "clean it up".
`app/mqtt.py:20-24` records the finding this rests on: HA 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). The second half of the
mechanism is the half that makes it silent: **HA keeps the id it composed at
first discovery.** So re-scoping the device does not rename an entity in place —
it creates a **new entity beside a dead one**. The recorded history stays on the
old id, the old id stops updating, and nothing errors. Every dashboard card,
template and automation naming it keeps pointing at a sensor that has quietly
gone flat, including the three live watchdog automations
(`goodwe_master_watchdog_held_*`, `goodwe_master_parked_at_0_w_*`) and
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input guard subscribes to.
| | **one device per inverter** (tempting) | **what this PR does** |
|---|---|---|
| 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** |
The last row is the argument. The tidy design's only real benefit is a pair of
symmetric, predictable ids — and this one 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. `DECISIONS/TEL-07-second-mqtt-device.md` says
this where the next reader will find it before they refactor.
### ⭐ The evidence is a diff of the announced payloads
Not an assertion that the code looks right. The `release/1.0` baseline in
`test_mqtt.py` was **generated by running that commit's module** (the command is
in the file) rather than transcribed — a transcription would grade this branch
against my memory of `release/1.0`.
```
roster of one (`inverters:` empty — i.e. every install running today):
9 topics, 0 missing, 0 changed, 0 added
roster of one, P1 off (omit=('p1_age',)):
8 topics, 0 missing, 0 changed, 0 added
roster of two (inverter-a + inverter-b, SIMULATED):
9 topics, 0 missing, 0 changed, 8 added
ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_a_{setpoint,battery,soc,sign_latch}/config
ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_b_{setpoint,battery,soc,sign_latch}/config
```
Part 2 shows `_announce()` publishes **exactly** `discovery_payloads()`, so what
is diffed is the bytes paho is handed — without it every diff here would be a
diff of a second implementation sitting in the same file.
The composed entity ids at a roster of two, modelled **independently** of
`app/mqtt.py` (importing its own slugifier would move the model and the subject
together) and anchored on the one composed id that was verified against the live
system, `sensor.goodwe_p1_sample_age`:
```
sensor.goodwe_setpoint / _grid_power / _battery_power / _battery_soc
sensor.goodwe_circulating_power / _maintenance_phase / _controller_status / _p1_sample_age
sensor.goodwe_inverter_a_setpoint / _battery_power / _battery_soc / _sign_latch
sensor.goodwe_inverter_b_setpoint / _battery_power / _battery_soc / _sign_latch
```
The first eight are unchanged; all sixteen are distinct; and for every per-unit
entity `object_id` **equals** the composed id, so an HA that did honour
`object_id` would produce the same ids as one that does not.
### Which sensors are per-unit, and why
**Per-unit:** `setpoint` (this unit's share of the site target — the number its
own heartbeat re-emits), `battery` (`Inverter.batt`, register `0x0518`, this
unit's own reading), `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** (`mqtt.py:98`), so a second copy is a
second answer to "is the meter fresh?", one of which is always the one nobody is
looking at. Also site: circulating power (it *is* a property of the set,
`sum(|Bᵢ|) − |sum(Bᵢ)|`), controller status, maintenance phase, capacity-tariff
peak. Asserted, at every roster length 1–6: each announced exactly once.
**Named as absent rather than left looking finished:** the card also lists
*inverter AC power* and per-unit *heartbeats*. 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 what the `omit` note exists to
refuse. They are gaps, and they are in the gap list.
At a roster of one **nothing** is announced and **nothing** is published — no
second device, no renamed entity, no added topic — and the same expression
(`announced_units()`) is the gate on both halves, so the two cannot disagree.
### ⚠️ Measured, not asserted — and the mutation table found three holes itself
Sixteen mutations were run through `tools/mutation_run.sh` (bytecode purged, tree
fingerprinted, byte delta printed, every edit re-read from disk). Twelve live in
`test_mqtt.py` part 8 as a running table that re-executes mutated **source** into
a fresh namespace, so no `.pyc` is involved at any point; each is named with the
property it breaks.
**Three properties in that table exist only because the table found their
absence.** On the first run these were **GREEN**:
| survivor found | what it would have shipped |
|---|---|
| the unit dropped from the **state topic** | A's and B's four topics collapse onto one set — both inverters read one number |
| `grid` moved into the per-unit set | a per-inverter grid sensor, the exact thing AC 3 forbids |
| `units=()` in `main()`'s publisher call | the controller publishes eight per-unit topics beside a publisher announcing none of them |
A fourth, in `inverter.py`: the roster fallback naming two nameless entries
`inverter-a` (one Store namespace, so one unit's SAFETY-02 latch is the other's)
survived everything. All four are red now. The wiring one is closed by the
**weakest check in the file** — an AST read of `main()`'s one `MqttPublisher(...)`
call — and it says so in its own text rather than dressing it up, because nothing
else in the file can reach a line that opens a broker, a web server and a control
loop.
On the publisher half, mutations run by hand and all red: the SoC row fed from
`inv.batt` (**+1 byte**, and it changes a real answer — A's 41 % becomes 1200);
the roster paired reversed (+16 bytes, 4 checks red); the latch pinned to
`clear`; the N≥2 gate dropped on the publish side only; every unit's setpoint row
showing the site target. The fixture gives A and B **different names, different
SoC (41/88 %), opposite battery signs (+1200/−300 W) and different latch states**,
which is what makes a swap observable at all — `MUTATION.md` habit 4, and the
defect this board has shipped six cards running.
### The SAFETY-32 package-wide structural pin already covers the new reductions
`test_peakguard.py` part 12j now enumerates **9 roster reductions in `mqtt.py`**,
found from its one seed with no fixture and at every roster length:
```
mqtt.py:198 unit_slugs for stmt enumerate(units) / units
mqtt.py:216 announced_units for..in units
mqtt.py:217 announced_units len() names
mqtt.py:292 discovery_payloads for stmt enumerate(zip(names, slugs)) / names / slugs
mqtt.py:362 _announce for stmt discovery_payloads(self.units, self.omit)
```
None is sliced. That is structural coverage of "the whole roster arrives", so
this PR does **not** duplicate it; what it adds is the behaviour the pin cannot
see (which device, which id, which topic, which value).
### ⚠️ Honest gaps, enumerated with their size (printed by part 9)
1. **HA itself is not in the loop.** `compose()` is a *model*, anchored on
exactly one measured id. If HA changes that rule, or if it differs for a
device with `via_device`, every id claim moves with it and the file stays
green. **Size: the 8 per-unit ids.** Needs a broker, an HA and the owner
present — this branch must not go near the live install.
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` as verified live on 2026-08-26. Those cannot
both come from one composition rule — the first is what an **earlier** device
name composed, and HA keeps what it composed first. **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 ids.**
3. **B's payloads have no released baseline.** A's are diffed against
`release/1.0`; B's can only be pinned to this branch (a sha256 of the eight
payloads). A wrong `device_class` is caught as a *change from review*, not as
an error. **Size: 4 sensors × 4 optional fields.**
4. **No broker runs.** QoS, real retain semantics, and what HA does with a
retained config for a device it has never seen are out of scope. **Size: the
whole transport.**
5. **The slug de-collision is order-dependent.** `validate.py` rule 9(c) refuses
*exact* duplicate names, not `inverter-b` beside `Inverter B`; those slug to
one string, and two entities under one `unique_id` is not an error in HA — it
**drops** the second, so one inverter's telemetry silently stops existing.
`unit_slugs()` suffixes the later one with its roster index, so reordering the
roster swaps which unit's ids move. **Size: 4 ids on one unit, only when two
names slug alike.** Left as a follow-up rather than a new startup refusal,
because a fresh refusal on a **live** install is a bigger change than this
card is allowed to be.
6. **`inverter AC power` and per-unit heartbeat age are not announced.** See
above. **Size: 2 sensors × N units, deliberately absent.**
7. **Part 8 mutates `app/mqtt.py` only.** `Controller.publish()`'s four per-unit
lines are graded by part 7's differing values; their mutations were run by
hand (listed above). **Size: 4 lines.** Also invisible: a rename of
`unit_state_key()` applied to *both* halves at once — harmless, since the
topics are internal.
### ⚠️ Inverter B does not physically exist (~2026-09-12)
Every roster of two in this PR is **simulated**. `max_w: 4600` is the datasheet
figure for a GW5048D-ES (serial `55048ESU22AW0992`), recorded in `NOTES.md`
§1.1b; nothing about B has ever been measured, and 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 existing ids are still updating** — not that the new
ones appeared.
### Checks
`for f in test_*.py; do python3 -B "$f"; done` — **21/21 PASS**, run both in
`goodwe-addon-ci` and in CI's own `goodwe-addon-checks:1` with the checkout
mounted read-only and `PYTHONDONTWRITEBYTECODE=1`. `test_mqtt.py` writes nothing
to disk. `test_peakguard.py` was additionally run **4× in isolation** (CI-06):
green every time, no red to disbelieve. Bytecode cache before the graded run: 0;
after: 0.
**No deploy, no hardware touched, no rig, no live Home Assistant.** The add-on is
live on 0.6.0 and this must not reach it without the owner present.
Card: https://trello.com/c/8vXVwRcA
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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
admin
merged commit 86eaef2517 into release/1.02026-09-05 15:51:55 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
TEL-07 — a second MQTT device for inverter B, without renaming A
MQTT discovery announced one device (
app/mqtt.py:27) and every entity hungoff one
unique_idprefix, so at two inverters B's per-unit telemetry hadnowhere to go: it would collide with A's or not be announced at all. It has
somewhere now, and not one entity that already exists moved.
⚠️ The asymmetry is deliberate. Please do not "clean it up".
app/mqtt.py:20-24records the finding this rests on: HA composes an entity idfrom the device name plus the entity name, and
object_idin the discoverypayload did not override it (tested on HA 2026.8). The second half of the
mechanism is the half that makes it silent: HA keeps the id it composed at
first discovery. So re-scoping the device does not rename an entity in place —
it creates a new entity beside a dead one. The recorded history stays on the
old id, the old id stops updating, and nothing errors. Every dashboard card,
template and automation naming it keeps pointing at a sensor that has quietly
gone flat, including the three live watchdog automations
(
goodwe_master_watchdog_held_*,goodwe_master_parked_at_0_w_*) andsensor.goodwe_p1_sample_age, which the ESP32's stale-input guard subscribes to.inverter-ainverter-bgoodwe_rs485_controller_inverter_bsensor.goodwe_inverter_a_*/..._b_*The last row is the argument. The tidy design's only real benefit is a pair of
symmetric, predictable ids — and this one 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.
DECISIONS/TEL-07-second-mqtt-device.mdsaysthis where the next reader will find it before they refactor.
⭐ The evidence is a diff of the announced payloads
Not an assertion that the code looks right. The
release/1.0baseline intest_mqtt.pywas generated by running that commit's module (the command isin the file) rather than transcribed — a transcription would grade this branch
against my memory of
release/1.0.Part 2 shows
_announce()publishes exactlydiscovery_payloads(), so whatis diffed is the bytes paho is handed — without it every diff here would be a
diff of a second implementation sitting in the same file.
The composed entity ids at a roster of two, modelled independently of
app/mqtt.py(importing its own slugifier would move the model and the subjecttogether) and anchored on the one composed id that was verified against the live
system,
sensor.goodwe_p1_sample_age:The first eight are unchanged; all sixteen are distinct; and for every per-unit
entity
object_idequals the composed id, so an HA that did honourobject_idwould produce the same ids as one that does not.Which sensors are per-unit, and why
Per-unit:
setpoint(this unit's share of the site target — the number itsown heartbeat re-emits),
battery(Inverter.batt, register0x0518, thisunit's own reading),
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 oneinstance per actuator by construction.
Site:
gridandp1_sample_age_s— there is one P1 meter and one gridconnection, so a per-inverter copy invents two of a thing there is one of, and
p1_sample_age_sis a watchdog input (mqtt.py:98), so a second copy is asecond answer to "is the meter fresh?", one of which is always the one nobody is
looking at. Also site: circulating power (it is a property of the set,
sum(|Bᵢ|) − |sum(Bᵢ)|), controller status, maintenance phase, capacity-tariffpeak. Asserted, at every roster length 1–6: each announced exactly once.
Named as absent rather than left looking finished: the card also lists
inverter AC power and per-unit heartbeats. Neither has a source in the add-on
today — nothing reads an AC-power register per unit, and
Inverter.last_beatisa monotonic loop clock with no wall-clock age published anywhere. Announcing them
would create entities nothing ever feeds, which is what the
omitnote exists torefuse. They are gaps, and they are in the gap list.
At a roster of one nothing is announced and nothing is published — no
second device, no renamed entity, no added topic — and the same expression
(
announced_units()) is the gate on both halves, so the two cannot disagree.⚠️ Measured, not asserted — and the mutation table found three holes itself
Sixteen mutations were run through
tools/mutation_run.sh(bytecode purged, treefingerprinted, byte delta printed, every edit re-read from disk). Twelve live in
test_mqtt.pypart 8 as a running table that re-executes mutated source intoa fresh namespace, so no
.pycis involved at any point; each is named with theproperty it breaks.
Three properties in that table exist only because the table found their
absence. On the first run these were GREEN:
gridmoved into the per-unit setunits=()inmain()'s publisher callA fourth, in
inverter.py: the roster fallback naming two nameless entriesinverter-a(one Store namespace, so one unit's SAFETY-02 latch is the other's)survived everything. All four are red now. The wiring one is closed by the
weakest check in the file — an AST read of
main()'s oneMqttPublisher(...)call — and it says so in its own text rather than dressing it up, because nothing
else in the file can reach a line that opens a broker, a web server and a control
loop.
On the publisher half, mutations run by hand and all red: the SoC row fed from
inv.batt(+1 byte, and it changes a real answer — A's 41 % becomes 1200);the roster paired reversed (+16 bytes, 4 checks red); the latch pinned to
clear; the N≥2 gate dropped on the publish side only; every unit's setpoint rowshowing the site target. The fixture gives A and B different names, different
SoC (41/88 %), opposite battery signs (+1200/−300 W) and different latch states,
which is what makes a swap observable at all —
MUTATION.mdhabit 4, and thedefect this board has shipped six cards running.
The SAFETY-32 package-wide structural pin already covers the new reductions
test_peakguard.pypart 12j now enumerates 9 roster reductions inmqtt.py,found from its one seed with no fixture and at every roster length:
None is sliced. That is structural coverage of "the whole roster arrives", so
this PR does not duplicate it; what it adds is the behaviour the pin cannot
see (which device, which id, which topic, which value).
⚠️ Honest gaps, enumerated with their size (printed by part 9)
compose()is a model, anchored onexactly one measured id. If HA changes that rule, or if it differs for a
device with
via_device, every id claim moves with it and the file staysgreen. Size: the 8 per-unit ids. Needs a broker, an HA and the owner
present — this branch must not go near the live install.
sensor.goodwe_rs485_controller_goodwe_battery_soc;mqtt.pyrecordssensor.goodwe_p1_sample_ageas verified live on 2026-08-26. Those cannotboth come from one composition rule — the first is what an earlier device
name composed, and HA keeps what it composed first. 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 ids.
release/1.0; B's can only be pinned to this branch (a sha256 of the eightpayloads). A wrong
device_classis caught as a change from review, not asan error. Size: 4 sensors × 4 optional fields.
retained config for a device it has never seen are out of scope. Size: the
whole transport.
validate.pyrule 9(c) refusesexact duplicate names, not
inverter-bbesideInverter B; those slug toone string, and two entities under one
unique_idis not an error in HA — itdrops the second, so one inverter's telemetry silently stops existing.
unit_slugs()suffixes the later one with its roster index, so reordering theroster swaps which unit's ids move. Size: 4 ids on one unit, only when two
names slug alike. Left as a follow-up rather than a new startup refusal,
because a fresh refusal on a live install is a bigger change than this
card is allowed to be.
inverter AC powerand per-unit heartbeat age are not announced. Seeabove. Size: 2 sensors × N units, deliberately absent.
app/mqtt.pyonly.Controller.publish()'s four per-unitlines are graded by part 7's differing values; their mutations were run by
hand (listed above). Size: 4 lines. Also invisible: a rename of
unit_state_key()applied to both halves at once — harmless, since thetopics are internal.
⚠️ Inverter B does not physically exist (~2026-09-12)
Every roster of two in this PR is simulated.
max_w: 4600is the datasheetfigure for a GW5048D-ES (serial
55048ESU22AW0992), recorded inNOTES.md§1.1b; nothing about B has ever been measured, and 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 existing ids are still updating — not that the new
ones appeared.
Checks
for f in test_*.py; do python3 -B "$f"; done— 21/21 PASS, run both ingoodwe-addon-ciand in CI's owngoodwe-addon-checks:1with the checkoutmounted read-only and
PYTHONDONTWRITEBYTECODE=1.test_mqtt.pywrites nothingto disk.
test_peakguard.pywas additionally run 4× in isolation (CI-06):green every time, no red to disbelieve. Bytecode cache before the graded run: 0;
after: 0.
No deploy, no hardware touched, no rig, no live Home Assistant. The add-on is
live on 0.6.0 and this must not reach it without the owner present.
Card: https://trello.com/c/8vXVwRcA
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU