TEL-07: a second MQTT device for inverter B, and A keeps the one it has
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
This commit is contained in:
2026-09-05 14:14:53 +02:00
co-authored by Claude Opus 5
parent f72c48dbfd
commit 26c5ee8adc
7 changed files with 1321 additions and 34 deletions
+39 -8
View File
@@ -357,6 +357,37 @@ def roster_entries(opts: dict) -> list:
return list(entries)
def roster_names(opts: dict) -> list:
"""The unit names, in roster order. THE one answer, for two callers.
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
every unit past the first its own MQTT device, and `MqttPublisher` is
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
announcing happens on the broker's CONNACK, which can land first. Building a
second roster there would construct a second set of `Inverter`s off the same
Store; transcribing the naming rule there would be a third spelling of it.
So the naming rule lives here, `build_roster()` reads it, and the two cannot
drift.
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
latch would be the other's - a fault on B latching A is the exact failure
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
this only decides what the refusal is allowed to call it in the meantime.
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
which is what every install running today reads.
"""
entries = roster_entries(opts)
if not entries:
return [UNIT_A]
names = []
for i, entry in enumerate(entries):
entry = entry if isinstance(entry, dict) else {}
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
return names
def build_roster(opts: dict, store, sign_cfg=None) -> list:
"""The site's inverters, in roster order.
@@ -414,15 +445,15 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
store=store, sign_cfg=sign_cfg)]
roster = []
for i, entry in enumerate(entries):
names = roster_names(opts)
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
# these names, and it has to build them BEFORE a `Controller` exists - so a
# second spelling of the fallback here would be an entity announced under a
# name no unit answers to. `zip` pairs them positionally, which is what
# `roster_names()` is defined to be.
for entry, name in zip(entries, names):
entry = entry if isinstance(entry, dict) else {}
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
# both called `inverter-a` would share one Store namespace, so one
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
# a nameless entry outright; this only decides what the refusal is
# allowed to call it in the meantime.
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
roster.append(Inverter(
name,
setpoint_entity=entry.get("setpoint_entity", ""),