Files
goodwe-addon/goodwe_controller/DECISIONS/TEL-07-second-mqtt-device.md
T
adminandClaude Opus 5 26c5ee8adc
checks / unit (pull_request) Successful in 4m58s
TEL-07: a second MQTT device for inverter B, and A keeps the one it has
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

6.2 KiB
Raw Blame History

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.