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
+32 -2
View File
@@ -53,11 +53,12 @@ from .hass import HomeAssistant
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries)
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import MqttPublisher
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
@@ -3073,6 +3074,28 @@ class Controller:
if self.p1_enabled:
# Recomputed here, once a second, on purpose - see P1Ingest.
values["p1_age"] = round(self.p1.published_age_s, 1)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
#
# ⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
# `announced_units()` answers `[]` at a roster of one, `zip` then yields
# nothing, and the values dict comes out byte-identical to what shipped -
# so the condition that decides what is ANNOUNCED is the same expression
# that decides what is PUBLISHED. Written as a second predicate, the two
# could disagree, and both directions are bad: an announced entity
# nothing feeds (the hazard `omit` exists for) or, at N=1, a published
# topic nothing announced, which AC 4 forbids outright.
#
# ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and
# `soc` stays the EMPTIEST pack; these are additions beside them, not a
# re-pointing of them, because those two ids are on the live install's
# dashboards and in its automations.
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
values[unit_state_key(slug, "setpoint")] = inv.target
values[unit_state_key(slug, "battery")] = inv.batt
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
self.mqtt.publish(values)
async def shutdown(self) -> None:
@@ -3779,6 +3802,13 @@ async def amain() -> None:
broker.get("username") if broker else None,
broker.get("password") if broker else None,
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
# would construct a SECOND set of `Inverter`s off the same
# Store, minutes before `Controller.__init__` constructs the
# real ones; `roster_names()` is the naming rule on its own and
# is what `build_roster()` itself reads. At a roster of one this
# announces nothing new - see `announced_units()`.
units=roster_names(opts),
)
except Exception as err: # noqa: BLE001
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)