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
116 lines
6.2 KiB
Markdown
116 lines
6.2 KiB
Markdown
# 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.
|