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
+56
View File
@@ -2,6 +2,62 @@
## Unreleased
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
to go. It has one now — **without touching a single entity that already
exists.**
* **⭐ A's discovery payloads are byte-identical to `release/1.0`.** Same topics,
same `unique_id`s, same payload bytes, same retain flags, in the same order —
verified by **diffing the announced payloads**, not by reading the code:
`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. That is what protects the
live install's recorded history and the three watchdog automations that name
its entities.
* **⚠️ THE ASYMMETRY IS DELIBERATE — DO NOT "CLEAN IT UP".** The first roster
entry's per-unit rows go 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
system — 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 goes on pointing at a sensor that
quietly stopped updating. The composed ids come out symmetric anyway
(`sensor.goodwe_inverter_a_battery_power` /
`sensor.goodwe_inverter_b_battery_power`).
* **Four per-unit rows** — setpoint, battery power, SoC and the 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,
asserted rather than assumed.
* **Site-scoped sensors stay site-scoped.** `grid` and `p1_sample_age_s` are
announced **once**, on A's device, at every roster length 1–6. There is one P1
and one grid connection, and `p1_sample_age_s` is a **watchdog input** — 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, controller status,
maintenance phase and the capacity-tariff peak are site for the same reason.
* **`inverter.roster_names()`** is now the one answer to "what are the units
called", read by `build_roster()` **and** by the publisher — which has to know
the roster **before** a `Controller` exists, because announcing happens on the
broker's CONNACK.
* **`test_mqtt.py`**, a new suite: the recorded `release/1.0` baseline
(generated by running that commit's module, not transcribed), the payload
diffs, the composed entity ids modelled **independently** of `app/mqtt.py` and
anchored on the one id verified against the live system
(`sensor.goodwe_p1_sample_age`), the announcement-vs-publication agreement on
a real `Controller` with **different** SoC, battery sign and latch state on
the two units, and **twelve mutations of the shipped module, all red, each on
a named property**.
* **MEASURED, NOT ASSERTED.** Three of those properties exist because the
mutation 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. So was a roster fallback that named two
nameless entries `inverter-a`. Sixteen mutations were run through
`tools/mutation_run.sh`; the survivors are enumerated **with their size** in
`test_mqtt.py` part 9.
* ⚠️ **Inverter B does not physically exist** (~2026-09-12). Every fixture here
is **simulated**, from the datasheet and serial in `NOTES.md` §1.1b. Nothing
in this card was measured on a second inverter.
**The add-on configuration screen now speaks English and Dutch instead of raw
option keys (UX-01).** There was no `translations/` directory at all, so Home
Assistant fell back to rendering the option KEYS — an operator opening
@@ -0,0 +1,115 @@
# 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.
+47
View File
@@ -166,6 +166,53 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
### What appears in Home Assistant at two inverters
**Nothing you already have changes.** The eight entities this add-on has always
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
keep their device, their `unique_id`s and therefore their **entity ids and their
whole recorded history**. At a roster of one, nothing new is announced at all:
no second device, no renamed entity, not one added topic.
Declare a second inverter and **four rows per unit** appear beside them:
| per-unit row | what it is |
|---|---|
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
| `Battery power` | this unit's own reading, `+` = discharging |
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
first inverter's rows are added to the **existing** *GoodWe* device; every later
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
as connected via the controller. Home Assistant composes an entity id from the
**device name plus the entity name**, so giving the first inverter a device of
its own would rename every entity you already have — and because HA keeps the id
it composed at first discovery, you would get a new entity beside a dead one:
the history stays on the old id and every dashboard, template and automation
naming it silently stops updating. The entity ids come out symmetric anyway —
`sensor.goodwe_inverter_a_battery_power` beside
`sensor.goodwe_inverter_b_battery_power`.
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
device page. It is half of every entity id, including
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
to; a rename disarms that guard by leaving it waiting for a state that never
arrives, and it fails silently and looks correct.
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
There is one P1 meter and one grid connection; a per-inverter copy would invent
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
`Circulating power` (it is a property of the *set* of inverters), the controller
status, the maintenance phase and the capacity-tariff peak.
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### P1 meter ingestion
`meter_entity` above expects one signed sensor, which usually means a template
+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", ""),
+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)
+232 -24
View File
@@ -90,14 +90,239 @@ SENSORS = [
BASE = "goodwe_ctl"
AVAILABILITY = f"{BASE}/availability"
# --- TEL-07: a SECOND device, for inverter B, and NOT one per inverter -------
#
# ⚠️ READ THE DEVICE-NAME NOTE ABOVE BEFORE CHANGING ANYTHING HERE. The device
# name is half of every entity_id, so re-scoping `DEVICE` renames every entity
# the live install already has - and HA keeps the id it composed at FIRST
# discovery, so what a rename actually produces is a NEW entity beside a dead
# one: the history stays behind on the old id, and every dashboard, template
# and automation that names it (including `goodwe_master_watchdog_held_*` and
# `goodwe_master_parked_at_0_w_*`) keeps pointing at the entity that stopped
# updating. That is silent. So:
#
# `DEVICE` IS NOT TOUCHED AND ITS ENTITIES ARE NOT TOUCHED. It is the SITE
# device - the meter's view and the controller's own state - and at the
# shipped roster of one it is also, in effect, inverter A's.
#
# THE FIRST ROSTER ENTRY GETS NO NEW DEVICE. Its per-unit rows are announced
# ON THE DEVICE IT ALREADY HAS, under new `unique_id`s and new names. Nothing
# existing is renamed, so nothing existing loses its history.
#
# EVERY LATER ENTRY GETS ITS OWN DEVICE (`via_device` the site one).
#
# ⚠️ THE ASYMMETRY IS THE POINT, AND IT WILL LOOK LIKE AN OVERSIGHT. "One
# device per inverter", with A as `inverter-a` and B as `inverter-b`, is tidy,
# symmetrical, and is the change that destroys the live system's recorded
# history. DO NOT "CLEAN THIS UP". The composed entity_ids come out symmetric
# anyway - `sensor.goodwe_inverter_a_battery_power` and
# `sensor.goodwe_inverter_b_battery_power` - which is the whole benefit of the
# tidy version, without the cost.
#
# WHAT IS PER-UNIT AND WHAT IS SITE, and why the split is where it is:
#
# PER-UNIT setpoint (this unit's share of the site target, the number its
# own heartbeat re-emits), battery power (`inv.batt`, this unit's
# own meter reading), SoC (`inv.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 per actuator by construction.
#
# SITE `grid` and `p1_age`. There is ONE P1 meter and ONE grid
# connection; announcing them per inverter would invent two of a
# thing there is one of. `p1_sample_age_s` is additionally a
# WATCHDOG INPUT (see `omit` below and the firmware note above), so
# a second copy of it is a second copy of a safety signal - two
# answers to "is the meter fresh?", one of which is always the one
# nobody is looking at. Also site: circulating power (it is
# `sum(|Bi|) - |sum(Bi)|`, a property of the SET), the controller
# status, the maintenance phase, and the capacity-tariff peak.
#
# NOT ANNOUNCED, AND SAID OUT LOUD RATHER THAN LEFT LOOKING FINISHED: the
# card also lists "inverter AC power" and per-unit HEARTBEATS as per-unit.
# Neither has a source in this add-on today - nothing reads an AC-power
# register per unit, and `inv.last_beat` is a monotonic loop clock with no
# wall-clock age published anywhere. Announcing them would create entities
# nothing ever feeds, which is exactly what the `omit` note below refuses to
# do for `p1_sample_age_s`. They are gaps, not omissions.
UNIT_SENSORS = [
("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),
("battery", "battery_power", "Battery power", "W", "power", "measurement", None),
("soc", "battery_soc", "Battery SoC", "%", "battery", "measurement", None),
("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),
]
# ⚠️ A ROSTER OF ONE ANNOUNCES NOTHING NEW - not a device, not an entity, not a
# topic. Every install running today is a roster of one, and "the upgrade added
# four entities nobody asked for" is a change to a live system's entity list
# for no information: at N=1 the site rows ARE this unit's rows, exactly.
PER_UNIT_MIN_ROSTER = 2
def _slug(text) -> str:
"""Home Assistant's own object_id spelling: lowercase, `_`, no runs.
⚠️ ASCII-ONLY ON PURPOSE. `str.isalnum()` is true for 'é' and for '٤', and
HA transliterates those rather than keeping them, so treating them as
alphanumeric here would produce an id this module claims and HA does not.
They become separators, which is conservative in the right direction: two
names that differ ONLY outside ASCII collide, and `unit_slugs()` below is
what stops a collision becoming two entities with one `unique_id`.
"""
out = []
for ch in str(text).strip().lower():
out.append(ch if (ch.isascii() and ch.isalnum()) else "_")
slug = "".join(out)
while "__" in slug:
slug = slug.replace("__", "_")
return slug.strip("_")
def _title(name) -> str:
"""`inverter-b` -> `Inverter B`. The half of the entity_id HA composes."""
words = str(name).replace("-", " ").replace("_", " ").split()
return " ".join(w[:1].upper() + w[1:] for w in words) or str(name)
def unit_slugs(units) -> list:
"""One id-safe slug per roster entry, in roster order, ALL DISTINCT.
⚠️ THE DE-COLLISION IS NOT DECORATION. `validate.py` rule 9(c) refuses two
entries with the SAME name; it does not refuse `inverter-b` beside
`Inverter B`, and those two slug to one string. Two entities published with
one `unique_id` is not an error in HA - the second one is DROPPED, so one
inverter's telemetry silently stops existing. The suffix is the roster
INDEX, so it is stable for a given roster and does not depend on what the
other unit is called.
"""
slugs, seen = [], set()
for i, name in enumerate(units):
slug = _slug(name) or f"inverter_{i + 1}"
while slug in seen:
slug = f"{slug}_{i + 1}"
seen.add(slug)
slugs.append(slug)
return slugs
def announced_units(units) -> list:
"""The roster, in order - or EMPTY below `PER_UNIT_MIN_ROSTER`.
⚠️ ONE ANSWER TO "WHICH UNITS GET PER-UNIT ENTITIES", read by the
announcement here AND by `Controller.publish()` in main.py. Two spellings of
the same predicate is how an entity gets announced that nothing publishes
to, or published to on a topic nothing announced - and at N=1 the second
would be an added topic, which AC 4 forbids outright.
"""
names = [str(u) for u in units]
if len(names) < PER_UNIT_MIN_ROSTER:
return []
return names
def unit_state_key(slug: str, key: str) -> str:
"""The `publish()` key - i.e. the state topic under `BASE` - for one unit.
Written once, here, because main.py builds it and this module announces it;
a second transcription is a topic announced and never fed.
"""
return f"unit/{slug}/{key}"
def unit_device(name, slug: str) -> dict:
"""B's OWN device. ⚠️ Never returned for the first roster entry."""
return {
"identifiers": [f"{DEVICE['identifiers'][0]}_{slug}"],
"name": f"{DEVICE['name']} {_title(name)}",
"manufacturer": DEVICE["manufacturer"],
"model": DEVICE["model"],
# Hangs B under the controller in HA's device tree rather than beside
# it. Cosmetic, and the one line that says in the UI what the asymmetry
# above means: there is one controller, and it drives both.
"via_device": DEVICE["identifiers"][0],
}
def _payload(name, object_id, unique_id, state_topic, device,
unit, dev_class, state_class, icon) -> dict:
"""One discovery payload.
⚠️ KEY ORDER IS PART OF THE OUTPUT. `json.dumps` writes keys in insertion
order, so this dict's construction order IS the bytes on the wire, and AC 1
is a BYTE comparison against what `release/1.0` publishes. Do not reorder.
"""
cfg = {
"name": name,
"object_id": object_id,
"unique_id": unique_id,
"state_topic": state_topic,
"availability_topic": AVAILABILITY,
"device": device,
}
if unit:
cfg["unit_of_measurement"] = unit
if dev_class:
cfg["device_class"] = dev_class
if state_class:
cfg["state_class"] = state_class
if icon:
cfg["icon"] = icon
return cfg
def discovery_payloads(units=(), omit=()) -> list:
"""Every `(topic, payload)` this add-on announces, in announcement order.
Split out of `_announce()` so that the acceptance criterion can be graded as
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
reading of the code. `_announce()` publishes exactly this list and nothing
else, which is itself checked.
"""
omit = set(omit)
out = []
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in omit:
continue
out.append((
f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon)))
names = announced_units(units)
slugs = unit_slugs(names)
site_slug = _slug(DEVICE["name"])
for i, (name, slug) in enumerate(zip(names, slugs)):
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
# keeps the device it already has and carries its name in the ENTITY
# name instead; every later one gets its own device and does not.
# Either way HA composes device-name + entity-name into the same
# string, which is why `object_id` below can be written once for both.
device = DEVICE if i == 0 else unit_device(name, slug)
prefix = f"{_title(name)} " if i == 0 else ""
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
unique_id = f"{BASE}_unit_{slug}_{key}"
out.append((
f"homeassistant/sensor/{unique_id}/config",
_payload(prefix + label,
f"{site_slug}_{slug}_{oid}",
unique_id,
f"{BASE}/{unit_state_key(slug, key)}",
device, unit, dev_class, state_class, icon)))
return out
class MqttPublisher:
def __init__(self, host, port, username=None, password=None, omit=()):
def __init__(self, host, port, username=None, password=None, omit=(),
units=()):
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
# sensor that nothing will ever publish to is not harmless here:
# p1_sample_age_s is a watchdog input, and an entity that exists but is
# never fed is a worse signal than one that does not exist at all.
self.omit = set(omit)
# TEL-07. The roster's NAMES, in roster order - `inverter.roster_names()`,
# not a second reading of the option. Defaults to empty, which
# `announced_units()` treats exactly as a roster of one: nothing
# per-unit is announced, so every caller that has not been taught about
# the roster keeps publishing what it published before.
self.units = [str(u) for u in units]
self.enabled = mqtt is not None and bool(host)
self.client = None
if not self.enabled:
@@ -130,29 +355,12 @@ class MqttPublisher:
self.enabled = False
def _announce(self) -> None:
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in self.omit:
continue
cfg = {
"name": name,
"object_id": object_id,
"unique_id": f"{BASE}_{key}",
"state_topic": f"{BASE}/{key}",
"availability_topic": AVAILABILITY,
"device": DEVICE,
}
if unit:
cfg["unit_of_measurement"] = unit
if dev_class:
cfg["device_class"] = dev_class
if state_class:
cfg["state_class"] = state_class
if icon:
cfg["icon"] = icon
self.client.publish(
f"homeassistant/sensor/{BASE}_{key}/config",
json.dumps(cfg), retain=True,
)
# ⚠️ NO PAYLOAD IS BUILT HERE. `discovery_payloads()` is the whole
# announcement, so the check that diffs it against `release/1.0` is
# diffing what actually goes on the wire - and a payload built here
# instead would be one this add-on publishes and no check has ever seen.
for topic, cfg in discovery_payloads(self.units, self.omit):
self.client.publish(topic, json.dumps(cfg), retain=True)
self.client.publish(AVAILABILITY, "online", retain=True)
def publish(self, values: dict) -> None:
+800
View File
@@ -0,0 +1,800 @@
"""Runnable check for TEL-07 - the SECOND MQTT device, and A's history.
`python3 test_mqtt.py`
WHAT THIS FILE IS FOR, in one sentence: to prove by DIFFING THE ANNOUNCED
PAYLOADS - not by reading the code - that giving inverter B its own MQTT device
changes nothing about inverter A's.
⚠️ WHY THAT IS THE WHOLE CARD. `app/mqtt.py`'s own note records the finding:
Home Assistant composes an entity_id from the DEVICE name plus the ENTITY name,
and `object_id` in the discovery payload did NOT override it (tested on HA
2026.8). So re-scoping the device renames every entity - and because HA keeps
the id it composed at FIRST discovery, what a rename actually produces is a NEW
entity beside a DEAD one. The recorded history stays behind on the old id, and
every dashboard, template and automation that names it - including the three
live watchdog automations `goodwe_master_watchdog_held_*` and
`goodwe_master_parked_at_0_w_*` - goes on pointing at an entity that has quietly
stopped updating. Nothing errors. That is the failure this file exists to make
impossible to ship.
⚠️ AND THE TEMPTING DESIGN IS THE DESTRUCTIVE ONE. "One device per inverter" -
A becomes `inverter-a`, B becomes `inverter-b` - is symmetrical and tidy and is
exactly the change described above. A KEEPS THE DEVICE IT ALREADY HAS; only B
gets a new one. Part 8 below writes that tidy version and shows it going red.
THE SHAPE OF THE EVIDENCE. Part 1 records what `release/1.0` actually publishes -
generated by running THAT commit's module, not transcribed - and every later
part is a diff against it, reported as `N topics, M missing, C changed, A added`.
Part 1 The recorded baseline, and the diff at a roster of one and of none.
Part 2 `_announce()` publishes EXACTLY `discovery_payloads()` and nothing
else, so parts 1 and 3-8 are diffing what goes on the wire.
Part 3 A roster of two: A's nine messages byte-identical, eight added.
Part 4 Zero overlap between A's entities and B's - topics, unique_ids,
object_ids, state topics, and devices.
Part 5 Site-scoped sensors stay site-scoped, at every roster length 1-6.
Part 6 The composed entity_ids, modelled INDEPENDENTLY of `app/mqtt.py`, and
anchored on the one composed id that was verified against the live
system (`sensor.goodwe_p1_sample_age`, mqtt.py's own note).
Part 7 ⚠️ THE ANNOUNCEMENT AND THE PUBLICATION AGREE, on a real `Controller`,
with the two units carrying DIFFERENT numbers - so a swap is visible.
Part 8 ⚠️ VERIFIED BY BREAKING IT. Twelve mutations of the shipped module,
re-executed from mutated source, each one named with the property it
breaks. A property nothing breaks is a property nothing is watching.
Part 9 What these checks CANNOT see, enumerated and sized.
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
two units are equal cannot observe a bug that swaps them - MUTATION.md habit 4,
and the defect this repository has now shipped six cards in a row.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every number attributed
to it below is SIMULATED, from the datasheet and serial recorded in NOTES.md
§1.1b (GW5048D-ES, serial 55048ESU22AW0992, max_w 4600 W). Nothing here is
measured, and nothing here should ever be quoted as if it were.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
Part 8 sidesteps the whole problem by exec'ing mutated SOURCE into a fresh
namespace, so no `.pyc` is involved at any point.
"""
import ast
import asyncio
import hashlib
import json
import re
import sys
from pathlib import Path
from app import mqtt
from app.inverter import build_roster, roster_names
from app.main import Controller
HERE = Path(__file__).resolve().parent
MQTT_SRC = (HERE / "app" / "mqtt.py").read_text(encoding="utf-8")
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# ---------------------------------------------------------------------------
# The recorded baseline. ⚠️ GENERATED, NOT TRANSCRIBED. Reproduce it with:
#
# git show release/1.0:goodwe_controller/app/mqtt.py > /tmp/mqtt_base.py
# python3 - <<'EOF'
# import sys; sys.path.insert(0, "/tmp"); import mqtt_base as M
# class F:
# def __init__(self): self.m = []
# def publish(self, t, p=None, retain=False): self.m.append((t, p, retain))
# p = M.MqttPublisher.__new__(M.MqttPublisher)
# p.omit, p.client, p.enabled = set(), F(), True
# p._announce()
# for row in p.client.m: print(row)
# EOF
#
# A transcription would be a check that grades this branch against MY MEMORY of
# release/1.0 rather than against release/1.0.
# ---------------------------------------------------------------------------
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
'"model": "ES/BP series"}')
BASELINE = [
('homeassistant/sensor/goodwe_ctl_setpoint/config',
'{"name": "Setpoint", "object_id": "goodwe_setpoint", "unique_id": "goodwe_ctl_setpoint", '
'"state_topic": "goodwe_ctl/setpoint", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_grid/config',
'{"name": "Grid power", "object_id": "goodwe_grid_power", "unique_id": "goodwe_ctl_grid", '
'"state_topic": "goodwe_ctl/grid", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_battery/config',
'{"name": "Battery power", "object_id": "goodwe_battery_power", "unique_id": "goodwe_ctl_battery", '
'"state_topic": "goodwe_ctl/battery", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_soc/config',
'{"name": "Battery SoC", "object_id": "goodwe_battery_soc", "unique_id": "goodwe_ctl_soc", '
'"state_topic": "goodwe_ctl/soc", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "%", "device_class": "battery", '
'"state_class": "measurement"}', True),
('homeassistant/sensor/goodwe_ctl_circulation/config',
'{"name": "Circulating power", "object_id": "goodwe_circulating_power", '
'"unique_id": "goodwe_ctl_circulation", "state_topic": "goodwe_ctl/circulation", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", '
'"icon": "mdi:sync-alert"}', True),
('homeassistant/sensor/goodwe_ctl_phase/config',
'{"name": "Maintenance phase", "object_id": "goodwe_maintenance_phase", '
'"unique_id": "goodwe_ctl_phase", "state_topic": "goodwe_ctl/phase", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:battery-sync"}', True),
('homeassistant/sensor/goodwe_ctl_status/config',
'{"name": "Controller status", "object_id": "goodwe_controller_status", '
'"unique_id": "goodwe_ctl_status", "state_topic": "goodwe_ctl/status", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:heart-pulse"}', True),
('homeassistant/sensor/goodwe_ctl_p1_age/config',
'{"name": "P1 sample age", "object_id": "p1_sample_age_s", "unique_id": "goodwe_ctl_p1_age", '
'"state_topic": "goodwe_ctl/p1_age", "availability_topic": "goodwe_ctl/availability", '
'"device": ' + _DEV + ', "unit_of_measurement": "s", "device_class": "duration", '
'"state_class": "measurement"}', True),
('goodwe_ctl/availability', 'online', True),
]
class FakeClient:
"""Captures what `_announce()` actually hands paho."""
def __init__(self):
self.msgs = []
def publish(self, topic, payload=None, retain=False):
self.msgs.append((topic, payload, retain))
def announce(units=(), omit=(), module=mqtt):
"""The messages `_announce()` publishes, through the SHIPPED method.
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
other part goes through this one, so what is diffed is what paho is told.
"""
pub = module.MqttPublisher.__new__(module.MqttPublisher)
pub.omit = set(omit)
pub.units = [str(u) for u in units]
pub.enabled = True
pub.client = FakeClient()
pub._announce()
return pub.client.msgs
def diff(got, want=BASELINE):
"""`(missing, changed, added)` between two announcement lists, by topic."""
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
missing = [t for t in w if t not in g]
changed = [t for t in w if t in g and g[t] != w[t]]
added = [t for t in g if t not in w]
return sorted(missing), sorted(changed), sorted(added)
def report(label, got, want=BASELINE):
m, c, a = diff(got, want)
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
f"{len(a)} added")
for t in m:
print(f" MISSING {t}")
for t in c:
print(f" CHANGED {t}")
for t in a:
print(f" ADDED {t}")
return m, c, a
def cfgs(msgs):
"""The discovery payloads, parsed, without the availability message."""
return [json.loads(p) for t, p, _ in msgs if t.startswith("homeassistant/")]
# The roster this card exists for. ⚠️ SIMULATED: inverter B does not physically
# exist. Its rating is the datasheet's (NOTES.md §1.1b, GW5048D-ES, serial
# 55048ESU22AW0992) and has never been measured.
OPTS2 = {
"meter_source": "off",
"inverters": [
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 2000, "bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": 4600, "bms_soc_floor": 10},
],
}
NAMES2 = roster_names(OPTS2)
# ---------------------------------------------------------------------------
print("\n1. the recorded baseline, and a roster of one")
# ---------------------------------------------------------------------------
# AC 1 and AC 4. ⚠️ THE COMPARISON IS BYTE-FOR-BYTE ON THE PAYLOAD STRING, not
# on a parsed dict. `json.dumps` writes keys in insertion order, so a reordered
# payload is a different retained message on the broker even though the parsed
# dicts compare equal - and a reordered payload is a re-announcement of every
# entity on a live install.
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
announce(units=roster_names({})))
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
"release/1.0: same topics, same unique_ids, same payload bytes, same "
"retain flags, in the same order - no second device, no renamed entity, "
"no added topic",
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
check("...and the roster-of-one path is the one every install actually reads: "
"an empty `inverters:` names exactly one unit",
roster_names({}) == ["inverter-a"])
check("...and a publisher told NOTHING about the roster announces the same "
"thing, so a caller that has not been taught about units cannot change "
"what a live install sees",
announce(units=()) == BASELINE)
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
announce(units=roster_names({}), omit=("p1_age",)),
[row for row in BASELINE if "p1_age" not in row[0]])
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
"watchdog input is still not announced, and nothing else moved",
(m, c, a) == ([], [], []))
# ---------------------------------------------------------------------------
print("\n2. what is diffed is what goes on the wire")
# ---------------------------------------------------------------------------
# ⚠️ WITHOUT THIS PART, PARTS 1 AND 3-8 GRADE A FUNCTION THE ADD-ON MIGHT NOT
# USE. `_announce()` used to build its payloads inline; if it still did, every
# diff here would be a diff of a second implementation that happens to sit in
# the same file. This is the join.
_direct = [(t, json.dumps(cfg), True)
for t, cfg in mqtt.discovery_payloads(NAMES2)] + \
[(mqtt.AVAILABILITY, "online", True)]
check("`_announce()` publishes EXACTLY `discovery_payloads()`, in order, with "
"retain set, plus the availability message - so every diff in this file "
"is a diff of the bytes paho is handed",
announce(units=NAMES2) == _direct)
# ---------------------------------------------------------------------------
print("\n3. a roster of two: A untouched, B added")
# ---------------------------------------------------------------------------
TWO = announce(units=NAMES2)
m, c, a = report("roster of two (inverter-a + inverter-b, SIMULATED)", TWO)
check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
"BYTE-IDENTICAL - nothing missing, nothing changed. This is the criterion "
"the live install's recorded history depends on, and it is a diff of the "
"payloads, not a reading of the code",
(m, c) == ([], []))
check("...and the eight additions are all B's and A's own per-unit rows, and "
"all of them are NEW topics", len(a) == 8)
check("...and A's nine messages are still the FIRST nine, in the same order, "
"so a broker replaying retained messages sees them unchanged",
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
check("A's per-unit rows stay on THE DEVICE A ALREADY HAS - no new device is "
"created for the first roster entry, which is the whole asymmetry",
len(a_cfgs) == 4
and all(c_["device"] == mqtt.DEVICE for c_ in a_cfgs))
check("⭐ AC 2: B's rows are on B's OWN device, with its own identifiers",
len(b_cfgs) == 4
and all(c_["device"]["identifiers"] == ["goodwe_rs485_controller_inverter_b"]
for c_ in b_cfgs))
check("...and B's device hangs off the controller's via `via_device`, so HA "
"shows one controller driving two inverters rather than two strangers",
all(c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
for c_ in b_cfgs))
check("...and B's device carries the SAME manufacturer and model as the site "
"device - it is the same installation, not a second product",
all(c_["device"]["manufacturer"] == mqtt.DEVICE["manufacturer"]
and c_["device"]["model"] == mqtt.DEVICE["model"] for c_ in b_cfgs))
# A regression pin on the NEW payloads. ⚠️ THIS IS NOT EVIDENCE OF CORRECTNESS -
# it is generated from this branch, so it can only say "these bytes have not
# changed since the PR was reviewed". It exists because B's payloads have no
# released version to be diffed against, and a later edit that quietly changes
# a per-unit `device_class` or state topic should have to be deliberate.
_pin = hashlib.sha256(
json.dumps(unit_cfgs, sort_keys=False).encode()).hexdigest()[:16]
print(" the eight per-unit payloads, in announcement order:")
for c_ in unit_cfgs:
print(f" {c_['unique_id']:<40} {c_['object_id']:<34} "
f"{c_['state_topic']}")
print(f" regression pin (sha256 of the eight payloads): {_pin}")
check("the per-unit payloads are the ones this PR was reviewed with "
"(REGRESSION PIN, not a correctness claim - see the comment)",
_pin == "eb5022b0455158d7")
# ---------------------------------------------------------------------------
print("\n4. zero overlap between A's entities and B's")
# ---------------------------------------------------------------------------
def field(rows, key):
return [r[key] for r in rows]
for key in ("unique_id", "object_id", "state_topic"):
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
sv = set(field(cfgs(BASELINE), key))
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
f"none of B's collides with a SITE entity that already exists",
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
and not (av & sv))
all_ids = field(cfgs(TWO), "unique_id")
check("...and across the WHOLE announcement every unique_id is distinct - an "
"HA discovery collision is not an error, it DROPS the second entity, so "
"one inverter's telemetry would silently stop existing",
len(all_ids) == len(set(all_ids)) == 16)
all_topics = [t for t, _, _ in TWO]
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
# are two different names by the validator's test and one string once slugged.
COLLIDE = announce(units=["inverter-b", "Inverter B"])
col_ids = field(cfgs(COLLIDE), "unique_id")
print(" two names that slug alike ('inverter-b', 'Inverter B') ->",
[c_["unique_id"] for c_ in cfgs(COLLIDE) if c_["unique_id"] not in site_ids])
check("two roster names that differ only in case and punctuation - which "
"`validate.py` rule 9(c) accepts, because it refuses only EXACT "
"duplicates - still get distinct unique_ids",
len(col_ids) == len(set(col_ids)))
# ---------------------------------------------------------------------------
print("\n5. site-scoped sensors stay site-scoped")
# ---------------------------------------------------------------------------
# ⚠️ `p1_sample_age_s` IS A WATCHDOG INPUT (mqtt.py's `omit` note and the ESP32
# firmware's ingest guard). A second copy of it is a second answer to "is the
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
# the same argument without the safety edge: there is ONE grid connection.
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
"goodwe_ctl_phase", "goodwe_ctl_status")
rows = []
for n in range(1, 7):
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
ids = field(cfgs(announce(units=names)), "unique_id")
rows.append((n, [ids.count(s) for s in SITE_ONLY], len(ids)))
print(f" roster of {n}: {len(ids)} entities, "
+ ", ".join(f"{s}x{ids.count(s)}" for s in SITE_ONLY))
check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at every "
"roster length from 1 to 6 - announcing them per inverter would invent "
"two of a thing there is one of, and would duplicate a safety signal",
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
check("...and the site set does not grow with the roster while the per-unit "
"set does: 8 site entities always, 4 more per unit past the first",
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
site_devices = {json.dumps(c_["device"], sort_keys=True)
for c_ in cfgs(announce(units=NAMES2))
if c_["unique_id"] in site_ids}
check("...and every site entity is still on the SITE device, at a roster of two",
site_devices == {json.dumps(mqtt.DEVICE, sort_keys=True)})
# ---------------------------------------------------------------------------
print("\n6. the composed entity_ids")
# ---------------------------------------------------------------------------
# ⚠️ THIS SLUGIFIER IS DELIBERATELY NOT `mqtt._slug`. Importing the module's own
# spelling would make the model and the subject move together, so a mutation in
# the slugifier would change both sides equally and be invisible - "a check
# whose data cannot reach the failure it names", written in one import.
def compose(device_name, entity_name):
"""HA's entity_id for a discovery payload, per mqtt.py's own note."""
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s.strip("_")
# The ONE anchor there is: mqtt.py records that the live entity is
# `sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26,
# and that the firmware subscribes to that composed name. If this model does not
# reproduce that id, the model is wrong and every claim below it is worthless.
p1 = [c_ for c_ in cfgs(BASELINE) if c_["unique_id"] == "goodwe_ctl_p1_age"][0]
check("the model reproduces the ONE composed entity_id that was verified "
"against the live system - `sensor.goodwe_p1_sample_age`, which the ESP32 "
"ingest guard subscribes to - from the device name and the entity name",
compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age")
check("...and that id is NOT the `object_id` in the payload "
"(`p1_sample_age_s`), which is the whole reason the device name is "
"load-bearing and mqtt.py says so in capitals",
p1["object_id"] != "goodwe_p1_sample_age")
composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(TWO)]
print(" every composed entity_id at a roster of two:")
for c_, eid in zip(cfgs(TWO), composed):
print(f" {eid:<46} <- device {c_['device']['name']!r} + "
f"name {c_['name']!r}")
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
"entity can land on top of a site entity that already has history",
len(composed) == len(set(composed)) == 16)
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
check("...and the eight ids that exist today are unchanged by adding B, which "
"is AC 1 restated in the units HA actually uses",
composed[:8] == base_composed)
check("...and the per-unit ids come out SYMMETRIC even though the devices are "
"not - sensor.goodwe_inverter_a_battery_power beside "
"sensor.goodwe_inverter_b_battery_power - which is the entire benefit of "
"the 'one device per inverter' design, without its cost",
"sensor.goodwe_inverter_a_battery_power" in composed
and "sensor.goodwe_inverter_b_battery_power" in composed)
check("...and for every per-unit entity `object_id` equals the composed id, so "
"the two mechanisms agree and an HA that DID honour object_id would "
"produce the same ids as one that does not",
all("sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
for c_ in unit_cfgs))
# ---------------------------------------------------------------------------
print("\n7. the announcement and the publication agree")
# ---------------------------------------------------------------------------
# ⚠️ THE HAZARD mqtt.py's `omit` NOTE NAMES: an entity that exists and is never
# fed is a worse signal than one that does not exist. So the set of per-unit
# topics ANNOUNCED and the set `Controller.publish()` actually writes have to be
# the same set - and, because a matching set of KEYS is not a matching set of
# VALUES, the two units carry DIFFERENT numbers here so that a swap is red.
class _Store:
data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
def __init__(self):
self.last = {}
self.units = []
def publish(self, values):
self.last = dict(values)
def close(self):
pass
def controlled(opts, soc, batt, tripped):
"""A real Controller with per-unit readings planted. SIMULATED values."""
# ⚠️ `stop` is a REQUIRED keyword (CFG-01): a Controller that cannot stop
# itself used to be the default one. These cases only call publish(), so any
# Event will do - but they may not skip it.
pub = _Pub()
ctl = Controller(dict(opts), None, _Store(), pub, stop=asyncio.Event())
for inv, s, b, t in zip(ctl.inverters, soc, batt, tripped):
inv.soc, inv.batt, inv.target = s, b, -1.0 * b
inv.sign_wd.tripped = t
ctl.publish()
return ctl, pub.last
# A 41 / 88 %, A discharging 1200 W, B charging 300 W, and B latched, not A.
# Every one of those six numbers differs from its sibling, which is what makes
# the assertions below able to see a swap.
ctl2, vals2 = controlled(OPTS2, [41.0, 88.0], [1200.0, -300.0], [False, True])
announced_unit_topics = {c_["state_topic"] for c_ in unit_cfgs}
published_unit_topics = {f"{mqtt.BASE}/{k}" for k in vals2 if k.startswith("unit/")}
print(f" announced per-unit topics {len(announced_unit_topics)}, "
f"published {len(published_unit_topics)}, "
f"announced-but-never-fed {sorted(announced_unit_topics - published_unit_topics)}, "
f"fed-but-never-announced {sorted(published_unit_topics - announced_unit_topics)}")
check("⭐ every per-unit entity announced is one the controller actually "
"publishes to, and every per-unit topic published is one that was "
"announced - no entity that nothing feeds, no topic nothing declared",
announced_unit_topics == published_unit_topics)
check("⭐ AND THE VALUES LAND ON THE RIGHT UNIT: A's 41 % on A's topic and B's "
"88 % on B's, not the other way round",
vals2["unit/inverter_a/soc"] == 41.0
and vals2["unit/inverter_b/soc"] == 88.0)
check("...and the same for battery power, whose SIGNS differ between the two "
"units here (A discharging, B charging) so a swap cannot hide in a "
"magnitude",
vals2["unit/inverter_a/battery"] == 1200.0
and vals2["unit/inverter_b/battery"] == -300.0)
check("...and the SAFETY-02 sign latch is per unit: B is latched and A is not, "
"and the two rows say so separately",
vals2["unit/inverter_a/sign_latch"] == "clear"
and vals2["unit/inverter_b/sign_latch"] == "tripped")
check("...and each unit's setpoint row is its OWN share, not the site target",
vals2["unit/inverter_a/setpoint"] == -1200.0
and vals2["unit/inverter_b/setpoint"] == 300.0)
check("...and the SITE rows still mean what they meant: `soc` is the EMPTIEST "
"pack (41 %, not 88 and not the mean) and `battery` is the SUM (900 W), "
"so this card added rows beside them and did not re-point them",
vals2["soc"] == 41.0 and round(vals2["battery"]) == 900)
_, vals1 = controlled({"meter_source": "off", "setpoint_entity": "number.sp"},
[55.0], [700.0], [False])
print(f" roster of one publishes keys: {sorted(vals1)}")
check("⭐ AC 4 on the PUBLISH side: at a roster of one not one `unit/...` topic "
"is written either, so the live install's broker sees no new topic at all",
not [k for k in vals1 if k.startswith("unit/")])
# --- 7b. the wiring, which nothing above reaches -----------------------------
# ⚠️ FOUND BY MUTATION, NOT BY DESIGN. `units=()` in main()'s `MqttPublisher(...)`
# call survived every check in this file: parts 1-7 build the publisher
# themselves, so a controller publishing eight per-unit topics beside a
# publisher announcing none of them was GREEN. That is this repository's
# recurring defect - a check whose data cannot reach the failure it names - in
# the one line that joins the two halves together.
check("`roster_names()` and `build_roster()` give the same names in the same "
"order, on every shape - the flat keys, a declared roster, a nameless "
"entry and a non-dict entry - so the names the publisher is handed are "
"the names the units answer to",
all([inv.name for inv in build_roster(o, None)] == roster_names(o)
for o in ({}, {"setpoint_entity": "number.sp"}, OPTS2,
{"inverters": [{}, {}]},
{"inverters": [{"name": "roof west"}, "junk"]})))
check("...and a nameless entry gets a POSITIONAL name, not `inverter-a` twice: "
"two units under one name share one Store namespace, so one unit's "
"SAFETY-02 latch would be the other's",
roster_names({"inverters": [{}, {}]}) == ["inverter-1", "inverter-2"])
_call = [n for n in ast.walk(ast.parse((HERE / "app" / "main.py").read_text()))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == "MqttPublisher"]
_kw = [k for c_ in _call for k in c_.keywords if k.arg == "units"]
print(f" main.py builds MqttPublisher {len(_call)} time(s); "
f"units= is " + (ast.unparse(_kw[0].value) if _kw else "ABSENT"))
check("⚠️ A SOURCE READ, AND THE WEAKEST CHECK IN THIS FILE - said so rather "
"than dressed up. main()'s one `MqttPublisher(...)` hands it "
"`roster_names(opts)`; nothing else here can reach that line, because "
"main() opens a broker, a web server and a control loop",
len(_call) == 1 and len(_kw) == 1
and ast.unparse(_kw[0].value) == "roster_names(opts)")
# --- 7c. a name that is not ASCII -------------------------------------------
# HA transliterates before it slugs; this module cannot, so it treats anything
# outside ASCII as a separator. Either way the id must be an id.
_odd = announce(units=["caf\u00e9-a", "caf\u00e9-b"])
_odd_unit = [c_ for c_ in cfgs(_odd) if c_["unique_id"] not in site_ids]
print(" a roster named with non-ASCII ->",
sorted({c_["unique_id"] for c_ in _odd_unit}))
check("every unique_id, object_id and state topic this module produces is "
"lowercase ASCII, even from a roster name that is not - an id HA would "
"not have composed is an entity nothing can subscribe to",
_odd_unit
and all(re.fullmatch(r"[a-z0-9_]+", c_[k])
for c_ in _odd_unit for k in ("unique_id", "object_id"))
and all(re.fullmatch(r"[a-z0-9_/]+", c_["state_topic"])
for c_ in _odd_unit))
# The topics the SHIPPED controller actually feeds at a roster of two. Part 8
# grades every mutant announcement against this, which is what turns "the
# announcement changed" into "the announcement now claims an entity nothing
# will ever publish to".
FED = set(published_unit_topics)
# ---------------------------------------------------------------------------
print("\n8. verified by breaking it")
# ---------------------------------------------------------------------------
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
# what makes exec'ing it standalone honest rather than a stub.
def load(pairs):
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
⚠️ EVERY ANCHOR MUST OCCUR EXACTLY ONCE. A mutation whose anchor has drifted
out of the file would otherwise apply to nothing and be reported as a
survivor - a false red on the instrument rather than on the module.
"""
src = MQTT_SRC
for old, new in pairs:
if src.count(old) != 1:
raise AssertionError(f"mutation anchor not unique: {old!r}")
src = src.replace(old, new)
mod = type(sys)("mqtt_mutant")
exec(compile(src, "<mutant>", "exec"), mod.__dict__)
return mod
def properties(mod):
"""Every claim this file makes, as booleans, over ONE module."""
out = {}
try:
base = announce(units=["inverter-a"], module=mod)
two = announce(units=["inverter-a", "inverter-b"], module=mod)
none = announce(units=(), module=mod)
col = announce(units=["inverter-b", "Inverter B"], module=mod)
tc = cfgs(two)
uc = [c_ for c_ in tc if c_["unique_id"] not in site_ids]
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
out["A byte-identical at N=1"] = base == BASELINE
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
out["unique_ids all distinct"] = (
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
out["slug-alike names still distinct"] = (
len(set(c_["unique_id"] for c_ in cfgs(col))) == len(cfgs(col)))
out["no composed entity_id collision"] = len(set(comp)) == len(comp)
out["object_id == composed id"] = all(
"sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
for c_ in uc)
out["A keeps the device it has"] = bool(ac) and all(
c_["device"] == mqtt.DEVICE for c_ in ac)
out["B has its own device"] = bool(bc) and all(
c_["device"]["identifiers"] != mqtt.DEVICE["identifiers"] for c_ in bc)
out["B is via_device the site"] = bool(bc) and all(
c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
for c_ in bc)
out["site sensors announced once"] = all(
[c_["unique_id"] for c_ in cfgs(announce(units=[f"i{j}" for j in range(n)],
module=mod))].count(s) == 1
for n in (1, 2, 3) for s in SITE_ONLY)
# ⚠️ TWO PROPERTIES THAT WERE MISSING, AND THE INSTRUMENT FOUND THEIR
# ABSENCE ITSELF: on the first run of this table, "the unit is dropped
# from the state topic" and "`grid` is moved to the per-unit set" were
# both GREEN. The first collapses A's and B's four topics onto one set
# of four, so both inverters read one number; the second announces a
# per-inverter grid sensor, which is the exact thing AC 3 forbids. The
# ids stayed distinct through both, so every property above missed them.
out["per-unit state topics all distinct"] = (
len({c_["state_topic"] for c_ in uc}) == len(uc))
# ⚠️ GRADED AGAINST THE SHIPPED `Controller.publish()`, on purpose. An
# announcement is only honest if something feeds it - mqtt.py's `omit`
# note - so a per-unit entity the controller will never publish to is a
# defect however tidy its id looks. (A rename applied to BOTH halves at
# once is invisible here and is disclosed in part 9; a rename applied to
# one half is what actually happens, and is red.)
out["every announced per-unit topic is fed"] = (
{c_["state_topic"] for c_ in uc} == FED)
except Exception as err: # a mutation that CRASHES proves nothing; say so
out["did not raise"] = False
out["__error__"] = repr(err)[:70]
return out
SHIPPED = properties(mqtt)
check("the shipped module satisfies every property this instrument grades - "
"so a red below is the mutation and not the instrument",
all(v is True for k, v in SHIPPED.items()))
MUTATIONS = [
("⭐ RE-SCOPE A's DEVICE NAME (the destructive change, AC 5)",
[('"name": "GoodWe",', '"name": "GoodWe Inverter A",')]),
("⭐ RE-SCOPE A's DEVICE IDENTIFIERS",
[('"identifiers": ["goodwe_rs485_controller"],',
'"identifiers": ["goodwe_inverter_a"],')]),
("⭐ THE TEMPTING TIDY DESIGN: one device per inverter, A included",
[("device = DEVICE if i == 0 else unit_device(name, slug)",
"device = unit_device(name, slug)"),
('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
("B's rows land on A's device instead of B's own",
[("device = DEVICE if i == 0 else unit_device(name, slug)",
"device = DEVICE")]),
("A's per-unit entity names lose the unit prefix",
[('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
("per-unit entities announced at a roster of ONE",
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
("the unit is dropped from the unique_id",
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
'unique_id = f"{BASE}_unit_{key}"')]),
("the unit is dropped from the state topic",
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
("the slug de-collision is removed (rule 9(c) does not cover this)",
[("while slug in seen:", "while False and slug in seen:")]),
("every unit takes the FIRST unit's slug",
[("for i, (name, slug) in enumerate(zip(names, slugs)):",
"for i, (name, slug) in enumerate(zip(names, [slugs[0]] * len(slugs))):")]),
("`grid` is moved to the per-unit set (site sensor announced per inverter)",
[(' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n',
' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n'
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
("B's device drops `via_device`",
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
]
survivors = []
for label, pairs in MUTATIONS:
got = properties(load(pairs))
broke = sorted(k for k, v in SHIPPED.items()
if v is True and got.get(k) is not True)
if not broke:
survivors.append(label)
print(f" {'RED ' if broke else 'GREEN'} {label}")
if broke:
print(f" breaks: {', '.join(broke)}")
elif got.get("__error__"):
print(f" raised: {got['__error__']}")
check(f"⭐ ALL {len(MUTATIONS)} MUTATIONS ARE RED, each on a NAMED property: "
"re-scoping A's device, the tidy one-device-per-inverter design, B "
"landing on A's device, per-unit entities at a roster of one, a unit "
"dropped from an id or a topic, two units sharing a slug, and a site "
"sensor announced per inverter. A mutation nothing catches is a claim "
"nothing is watching",
not survivors)
if survivors:
print(" SURVIVED:", "; ".join(survivors))
# ⚠️ AC 6, THE FIXTURE'S OWN NON-VACUITY. If A and B were given the same name
# the whole of part 4 would still be green and would be observing nothing.
same = announce(units=["inverter-a", "inverter-a"])
swapped = announce(units=["inverter-b", "inverter-a"])
check("...and the fixture's two units are genuinely distinguishable: swapping "
"their order changes the announcement, and a roster of two IDENTICAL "
"names - which rule 9(c) refuses, and which this file therefore never "
"relies on - would still not collide",
swapped != TWO
and len({c_["unique_id"] for c_ in cfgs(same)}) == len(cfgs(same)))
# ---------------------------------------------------------------------------
print("\n9. what this file CANNOT see, enumerated and sized")
# ---------------------------------------------------------------------------
for line in [
"1. HA ITSELF IS NOT IN THE LOOP. `compose()` in part 6 is a MODEL of how "
"HA 2026.8 builds an entity_id, anchored on exactly ONE measured id "
"(sensor.goodwe_p1_sample_age, mqtt.py's note, verified 2026-08-26). If HA "
"changes that rule, or if it differs for a device with `via_device`, every "
"id claim here moves with it and this file stays green. SIZE: the 8 "
"per-unit ids at N=2. It cannot be closed from this repository - it needs a "
"broker, an HA, and the owner present.",
"2. THE TICKET AND THE MODULE DISAGREE ABOUT THE LIVE IDs. The card quotes "
"`sensor.goodwe_rs485_controller_goodwe_battery_soc`; mqtt.py records "
"`sensor.goodwe_p1_sample_age`. Those cannot both come from one composition "
"rule - the first is the id an EARLIER device name composed, and HA keeps "
"whatever it composed at first discovery. This card does not need to "
"resolve it, because it renames no device and no entity, and the byte-diff "
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
"wants one look at the live registry, which this branch must not take.",
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
"optional fields.",
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
"paho client, so QoS, retain semantics on a real broker, and what HA does "
"with a retained config for a device it has never seen are all out of "
"scope. SIZE: the whole transport.",
"5. THE SLUG DE-COLLISION IS ORDER-DEPENDENT. `inverter-b` before "
"`Inverter B` gives the SECOND one the `_2` suffix; reordering the roster "
"swaps which unit's ids move. Rule 9(c) does not refuse slug-alike names, "
"so this is reachable from a config an operator can write. SIZE: 4 ids on "
"one unit, and only when two names slug alike. Filed as a follow-up rather "
"than fixed in validate.py here, because a new refusal at startup on a "
"LIVE install is a bigger change than this card is allowed to be.",
"7. PART 8 MUTATES `app/mqtt.py` ONLY. `Controller.publish()`'s four "
"per-unit lines are graded by part 7 - with different SoC, different "
"battery sign and different latch states on the two units, so a swap or a "
"mis-paired slug is red - but no automated table mutates main.py here. "
"Those mutations were run by hand through `tools/mutation_run.sh` and are "
"recorded in the PR. SIZE: 4 lines. Also invisible: a rename of "
"`unit_state_key()` applied to BOTH halves at once, which is harmless "
"(the topics are internal) but would move every per-unit state topic "
"silently.",
"6. `inverter AC power` AND PER-UNIT HEARTBEAT AGE ARE NOT ANNOUNCED. The "
"card lists both as per-unit; neither has a source in the add-on today "
"(nothing reads an AC-power register per unit, and `inv.last_beat` is a "
"monotonic loop clock). Announcing them would create entities nothing "
"feeds. SIZE: 2 sensors x N units, deliberately absent, named in the PR.",
]:
print(" " + line)
# ---------------------------------------------------------------------------
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")