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
6.2 KiB
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.