From 26c5ee8adc2b278d6ada83570c2ec2eada1840e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:14:53 +0200 Subject: [PATCH] TEL-07: a second MQTT device for inverter B, and A keeps the one it has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 56 ++ .../DECISIONS/TEL-07-second-mqtt-device.md | 115 +++ goodwe_controller/DOCS.md | 47 + goodwe_controller/app/inverter.py | 47 +- goodwe_controller/app/main.py | 34 +- goodwe_controller/app/mqtt.py | 256 +++++- goodwe_controller/test_mqtt.py | 800 ++++++++++++++++++ 7 files changed, 1321 insertions(+), 34 deletions(-) create mode 100644 goodwe_controller/DECISIONS/TEL-07-second-mqtt-device.md create mode 100644 goodwe_controller/test_mqtt.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index fb56a9f..b3fae63 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DECISIONS/TEL-07-second-mqtt-device.md b/goodwe_controller/DECISIONS/TEL-07-second-mqtt-device.md new file mode 100644 index 0000000..dcd8d2c --- /dev/null +++ b/goodwe_controller/DECISIONS/TEL-07-second-mqtt-device.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 5b2cdd0..25af066 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index 1a5cf73..d4e43ad 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -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", ""), diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index c5ff410..f8067d8 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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) diff --git a/goodwe_controller/app/mqtt.py b/goodwe_controller/app/mqtt.py index f29916c..c0101f0 100644 --- a/goodwe_controller/app/mqtt.py +++ b/goodwe_controller/app/mqtt.py @@ -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: diff --git a/goodwe_controller/test_mqtt.py b/goodwe_controller/test_mqtt.py new file mode 100644 index 0000000..fdd1bcc --- /dev/null +++ b/goodwe_controller/test_mqtt.py @@ -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, "", "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")