Files
goodwe-addon/goodwe_controller/tools/rehearse_roster.py
T
Claude Opus 5 638c213944
checks / unit (pull_request) Successful in 6m57s
ARCH-02 round 2: the pin could not tell one red leg from two, and the tool went
quiet when a flag was forgotten

Both blocking findings were reproduced before anything was changed, and both are
the class this card exists to guard against.

B1. THE PIN WAS A COUNT, AND A COUNT CANNOT SEPARATE TWO ROUTES. The rehearsal
reaches the disarmed latch by two independent paths and prints every finding
TWICE per red leg - once in the leg's block, once in the closing summary - so
ONE red leg satisfied `count(...) >= 2`. Measured: shipped code 4; with
`migrate_unit_keys()` handed the roster's own name (SAME-STEP fixed) 2, PASSES;
with a guard-side fix (both fixed) 0. The check named "NOT THE SAME-STEP PATH
ALONE" reported ok on the build whose only remaining failure IS the staged path.
The tool now prints `LEG <id>` / `END LEG <id>` and the pin requires the finding
in EACH leg by name.

⚠️ AND THE FIRST SPLITTER CARRIED THE SAME DEFECT ONE LEVEL DOWN - it opened on
the marker and never closed, so the last leg absorbed the closing summary and
read red whenever ANY leg was red. Caught by building the one fixture that
separates them. Part 5b is now that fixture, run every time: a tree in which
SAME-STEP is genuinely clean and STAGED is not. A per-thing check that has never
seen the things disagree is a total with extra steps.

⚠️ AND IT CORRECTS A CLAIM THIS CARD MADE. `main.py:250` is NOT the whole fix.
Measured on that exact change: LEG SAME-STEP clean, LEG STAGED still DISARMED -
the staged path stamps `schema_version` on the flat start and the roster start's
migration returns early before the name it is handed matters. DOCS.md now says
so, with the measurement.

B2. A FORGOTTEN FLAG WAS A CLEAN BILL OF HEALTH. Omitting `--live-entities`
printed "nothing orphaned" and exited 0, with `grep -ci` for the words returning
0 - while an UNREADABLE file exited 2. The asymmetry was backwards. Omitting it
now refuses (exit 2); `--no-live-entities` is the deliberate opt-out and carries
a standing banner in the header AND on the exit line, because a report is
skimmed from the bottom.

N1. THE REFERENCE DID NOT CARRY WHAT HA KEYS ON. It held entity_ids only, so
renaming `mqtt.BASE` (every unique_id moves) and changing `DEVICE["identifiers"]`
(HA sees a new device) both came back exit 0 - real orphanings with every
composed id byte-identical. The reference is now three columns
(`entity_id unique_id device`), is loaded on EVERY run including a degraded one,
and M7/M8 pin both. `object_id` is deliberately not a column and its rename is
required to SURVIVE, which is what bounds the four mqtt.py mutations.

Also found while fixing the above, and fixed with its own paired positive: the
VANISHED check did not carry the default/non-default rule the STRANDED check
does, so a pair of migrated DEFAULTS moving namespace read as two findings and
CONTROL-C went red on a CORRECT build. M9 drops non-default keys and must still
be a finding.

N2/N3. DOCS.md withdraws the unmeasured "not recoverable by advancing again" -
its own reasoning cut the other way and nothing here tested a re-advance; the
measured part (eight rows stop updating, history gaps, automations stop firing)
stands. And the byte-identical-input property is `test_rehearse.py`'s assertion,
not the tool's.

FRAMING. SAFETY-44 is ALREADY RECORDED on the merge base - `test_maintenance.py`
section 13, with the "guard is defeated by the very key the defect wrote"
sentence, in a file this card does not touch. DOCS.md now says this is
independent re-confirmation through a second instrument, reachable from an
owner-supplied state file rather than only from a fixture, and not a discovery.

app/ is still untouched. 59 checks in test_rehearse.py, 9 mutations red, 2
required survivors, 3 clean controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:21:34 +02:00

1084 lines
49 KiB
Python

"""ARCH-02: rehearse the flat -> roster switch against a COPY of real state.
python3 tools/rehearse_roster.py --state <path> \
--before <flat.yaml> --after <roster.yaml>
WHAT THIS IS FOR, in one sentence: on the day inverter B is commissioned the
add-on stops reading the flat single-inverter options and starts reading a
declared `inverters:` roster, and two things happen at that restart that are
ONE-WAY and INVISIBLE UNTIL AFTERWARDS - the store migration renames the keys
that hold a safety latch and a month of maintenance history, and the entity ids
Home Assistant composes change shape.
THE STORE. /data/state.json on the live box was written by a flat-config
controller. A latch that migrates to a name nothing reads is a
SAFETY GUARD THAT COMES UP DISARMED on an install whose state
file says it is protected (store.py's own header, and SAFETY-02:
"a latch that a restart clears is not a latch"). MAINT-05 had to
fix exactly this shape once already.
THE ENTITIES. Home Assistant keeps the entity_id it composed at FIRST
discovery. An id that stops being announced is not deleted - it
is ORPHANED: history stays behind on the dead id, and every
automation whose trigger names it SILENTLY STOPS FIRING. An
automation with a vanished trigger entity does not error. It
never runs again.
⚠️ THIS TOOL RUNS THE REAL `Controller`, NOT A MODEL OF IT. `snapshot()` builds
`app.main.Controller` over the real `app.store.Store`, so the migration under
test is the shipped one and the readers that are asked "can you still find your
latch" are the shipped readers. A rehearsal that re-implemented the migration
would be comparing a structure to itself, which is `MUTATION.md` habit 4 and
this repository's most-repeated defect.
⚠️ IT NEVER WRITES TO THE FILE IT IS GIVEN. `--state` is opened once, copied to
a scratch file made with `mkstemp()`, and every `Store.save()` in the rehearsal
lands on the copy. The scratch file is unique per invocation, which is habit 18.
⚠️ IT DOES NOT TOUCH THE LIVE BOX AND CANNOT. There is no SSH, no Supervisor
call and no HTTP in this file. Fetching the state file is an OWNER STEP and it
is written down in DOCS.md under "Rehearsing the roster switch (ARCH-02)".
EXIT CODES
0 the transition is clean: nothing vanished, nothing was stranded under a
name no machine reads, no entity was orphaned, no history was lost.
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
reachable: `test_rehearse.py` mutates the shipped migration five
different ways and requires this exit code for each.
2 the rehearsal could not be performed (bad file, unparseable config, the
entity-id model failed its own anchor). ⚠️ NOT A PASS.
"""
import argparse
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa: E402
from app.main import Controller # noqa: E402
from app import mqtt # noqa: E402
from app.p1 import is_enabled # noqa: E402
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
unit_key)
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
class Refused(Exception):
"""The rehearsal could not be performed. Exit 2, never exit 0."""
# --------------------------------------------------------------------------- #
# 1. THE CONFIGS
# --------------------------------------------------------------------------- #
# The live box's options blob is JSON (`/data/options.json`); the card's command
# line spells the two configs as `.yaml` because that is what an operator edits.
# Both are accepted. ⚠️ THE YAML READER REFUSES WHAT IT DOES NOT UNDERSTAND
# RATHER THAN GUESSING: a config silently mis-parsed here would produce a
# rehearsal of a configuration nobody is going to deploy, which is worse than no
# rehearsal at all. PyYAML is used when it is importable; the add-on's check
# image has no `yaml` module, so the subset below is what actually runs in CI.
def _scalar(text: str):
raw = text.strip()
if raw.startswith("#"):
raise Refused("comment where a value was expected")
if (raw.startswith('"') and raw.endswith('"') and len(raw) >= 2) or \
(raw.startswith("'") and raw.endswith("'") and len(raw) >= 2):
return raw[1:-1]
low = raw.lower()
if low in ("true", "yes", "on"):
return True
if low in ("false", "no", "off"):
return False
if low in ("null", "~", ""):
return None
if raw == "[]":
return []
if raw == "{}":
return {}
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
if raw.startswith("[") or raw.startswith("{"):
raise Refused(f"inline collection {raw!r} is not supported by the "
"built-in reader - install PyYAML or use JSON")
return raw
def _mini_yaml(text: str) -> dict:
"""The subset an add-on options file is written in, and nothing else.
Block mappings, and lists of block mappings (`inverters:`). Two indent
levels of nesting, which is all `config.yaml`'s option surface has. Every
other construct raises `Refused`.
"""
root: dict = {}
cur_list = None
cur_entry = None
list_indent = None
for lineno, line in enumerate(text.splitlines(), 1):
if not line.strip() or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
if "\t" in line[:indent]:
raise Refused(f"line {lineno}: tab in indentation")
body = line.strip()
try:
if body.startswith("- "):
if cur_list is None:
raise Refused(f"line {lineno}: list item outside a key")
if list_indent is None:
list_indent = indent
if indent != list_indent:
raise Refused(f"line {lineno}: ragged list indentation")
cur_entry = {}
cur_list.append(cur_entry)
item = body[2:].strip()
if item:
k, _, v = item.partition(":")
if not _:
raise Refused(f"line {lineno}: bare list scalar")
cur_entry[k.strip()] = _scalar(v)
continue
if ":" not in body:
raise Refused(f"line {lineno}: {body!r} is not `key: value`")
key, _, value = body.partition(":")
key, value = key.strip(), value.strip()
if cur_entry is not None and list_indent is not None \
and indent > list_indent:
cur_entry[key] = _scalar(value)
continue
cur_list = cur_entry = None
list_indent = None
if value == "":
cur_list = root[key] = []
continue
root[key] = _scalar(value)
except Refused:
raise
# A key written `foo:` with nothing under it is an EMPTY list here, which is
# exactly what `inverters: []` means to `roster_entries()`.
return root
def load_options(path: str) -> dict:
text = Path(path).read_text(encoding="utf-8")
try:
obj = json.loads(text)
except ValueError:
try:
import yaml # noqa: PLC0415
except ImportError:
obj = _mini_yaml(text)
else:
obj = yaml.safe_load(text)
if not isinstance(obj, dict):
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
return obj
# --------------------------------------------------------------------------- #
# 2. THE STORE, AND THE READ LOG THAT MAKES "NOTHING READS THIS" MEASURABLE
# --------------------------------------------------------------------------- #
class _ReadLog(dict):
"""`Store.data`, with every value-read remembered.
⚠️ THIS IS THE INSTRUMENT THE WHOLE SAFETY-44 QUESTION TURNS ON. "A key
nothing reads" cannot be established by reasoning about which name the
migration was handed; it is established by asking the SHIPPED READERS to
restore themselves and recording which keys they actually touched. A key in
the file that no reader touched is a key whose value is not information.
`__contains__` is deliberately NOT recorded: `_move_flat_keys()` tests
`target in data` to decide whether a namespaced value already exists, and
that is a write-side decision, not a machine reading its latch.
"""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.reads = set()
def get(self, key, default=None):
self.reads.add(key)
return super().get(key, default)
def __getitem__(self, key):
self.reads.add(key)
return super().__getitem__(key)
class RehearsalStore(Store):
"""The SHIPPED `Store`, pointed at a scratch copy of the operator's file.
⚠️ THE REAL CLASS, NOT A STAND-IN. `load()`, `save()`, `get_time()` and
`set_time()` are the ones the add-on runs, so the ISO-8601 round trip that
the module docstring says once took the controller down for hours is the
round trip under rehearsal. A `FakeStore` here would rehearse the fake.
"""
def __init__(self, path: str):
super().__init__(path)
self.data = _ReadLog(self.data)
self.data.reads.clear()
def _scratch_copy(state_path: str, tmpdir: str, tag: str) -> str:
"""A per-invocation copy. ⚠️ The operator's file is never opened for write."""
dest = os.path.join(tmpdir, f"state-{tag}.json")
if state_path is None:
Path(dest).write_text("{}", encoding="utf-8")
else:
shutil.copyfile(state_path, dest)
return dest
# --------------------------------------------------------------------------- #
# 3. THE ENTITY IDS
# --------------------------------------------------------------------------- #
# ⚠️ THE 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 composition would change both sides equally and this tool would report
# "no orphans" about a rename that orphaned everything - habit 4, written in one
# import. `test_mqtt.py` part 6 makes the same move for the same reason.
import re # noqa: E402
def compose(device_name, entity_name) -> str:
"""The entity_id Home Assistant composes, per `app/mqtt.py`'s own note."""
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s.strip("_")
# The ONE id verified against the live system (2026-08-26, recorded in
# `app/mqtt.py`) and subscribed to by the ESP32 ingest guard. If the model above
# cannot reproduce it, the model is wrong and every orphan claim below it is
# worthless - so the tool REFUSES rather than reporting.
ANCHOR_ID = "sensor.goodwe_p1_sample_age"
class Row:
"""One announced entity, in the three terms Home Assistant keys on.
⚠️ THE UNIQUE_ID AND THE DEVICE IDENTIFIER ARE NOT DECORATION, AND LEAVING
THEM OUT OF THE COMPARISON WAS THIS TOOL'S SECOND HABIT-4. HA's MQTT
integration keys its ENTITY registry on `unique_id` and its DEVICE registry
on the device identifier. Change either one and HA creates a NEW entity
beside the old - while the COMPOSED entity_id is byte-identical, so a
comparison over composed ids alone reports a clean transition about a change
that orphans the whole install. Measured: renaming `mqtt.BASE` and changing
`DEVICE["identifiers"]` both came back exit 0 before this class existed.
"""
__slots__ = ("entity_id", "unique_id", "device_id")
def __init__(self, entity_id, unique_id, device_id):
self.entity_id = entity_id
self.unique_id = unique_id
self.device_id = device_id
def __repr__(self):
return (f"{self.entity_id} (unique_id {self.unique_id}, device "
f"{self.device_id})")
def announced_rows(opts: dict) -> "dict[str, Row]":
"""`{entity_id: Row}` for every row this add-on announces on `opts`.
⚠️ `discovery_payloads()` IS THE SHIPPED ANNOUNCER and `roster_names()` is
the shipped naming rule, so the roster shape decision is not restated here.
"""
payloads = mqtt.discovery_payloads(
units=roster_names(opts),
omit=() if is_enabled(opts) else ("p1_age",))
out = {}
for _topic, cfg in payloads:
eid = compose(cfg["device"]["name"], cfg["name"])
out[eid] = Row(eid, cfg["unique_id"],
(cfg["device"].get("identifiers") or [""])[0])
return out
# The device name and entity name that composed it, recorded as LITERALS.
# ⚠️ LITERALS, NOT `mqtt.DEVICE["name"]`. Anchoring on the shipped dict would
# make this check pass for any renaming of the device - the model and the
# subject would move together and the anchor would be decoration. These two
# strings are what `release/1.0` had on the day the id was verified.
ANCHOR_DEVICE_NAME = "GoodWe"
ANCHOR_ENTITY_NAME = "P1 sample age"
def _check_anchor() -> None:
"""Is the MODEL right? Nothing about the shipped code is asked here."""
got = compose(ANCHOR_DEVICE_NAME, ANCHOR_ENTITY_NAME)
if got != ANCHOR_ID:
raise Refused(
f"the entity-id model composes {got!r} where app/mqtt.py records "
f"{ANCHOR_ID!r} as verified against the live system on 2026-08-26. "
"The model is wrong; no orphan claim from it can be believed.")
# Shipped beside this file, and LOADED ON EVERY RUN. See `load_reference()`.
DEFAULT_REFERENCE = (Path(__file__).resolve().parent / "rehearsal"
/ "announced-reference.txt")
def load_reference(path) -> "dict[str, Row]":
"""What this build is SUPPOSED to announce: `<entity_id> <unique_id> <dev>`.
⚠️ EXTERNAL DATA, AND THE TOOL IS BLIND WITHOUT IT - which is why it is
loaded unconditionally and a missing one is EXIT 2 rather than a quieter
run. The before/after diff is composed twice by the SAME build, so a change
to the composition itself moves both sides equally and the diff reports
nothing. Only a reference that does not move with the code can see it.
⚠️ THREE COLUMNS ARE REQUIRED AND A SHORT LINE IS REFUSED. A two-column
reference would silently stop grading device identifiers, which is exactly
the blind spot this file exists to close - and it would do it without
saying so, which is the failure mode the whole card is about.
"""
rows = {}
for lineno, line in enumerate(
Path(path).read_text(encoding="utf-8").splitlines(), 1):
line = line.split("#", 1)[0].strip()
if not line:
continue
parts = line.split()
if len(parts) != 3:
raise Refused(
f"{path} line {lineno}: expected `<entity_id> <unique_id> "
f"<device identifier>`, got {len(parts)} field(s). A short "
f"line would silently stop grading what HA keys on.")
rows[parts[0]] = Row(*parts)
if not rows:
raise Refused(f"{path}: no rows in it")
return rows
def load_entity_list(path: str) -> set:
"""The entity ids that EXIST ON THE INSTALL, one per line, `#` comments.
This is the owner's export from Home Assistant, and it answers a different
question from `load_reference()`: that one says what the BUILD announces,
this one says what the INSTALL has. An id the install has and the new shape
does not announce is an orphan whatever the build's own history says.
"""
out = set()
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
out.add(line.split()[0])
if not out:
raise Refused(f"{path}: no entity ids in it")
return out
# --------------------------------------------------------------------------- #
# 4. ONE CONTROLLER START, OBSERVED
# --------------------------------------------------------------------------- #
class Hass:
"""No I/O. `Controller.__init__` performs none; this only has to exist."""
async def number(self, entity, invert=False):
return None
async def limits(self, entity):
return (None, None, None)
async def set_number(self, entity, value):
return False
async def call(self, domain, service, data):
return False
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Snapshot:
"""What one `Controller` start over one state file looks like."""
def __init__(self, label, opts, store, controller):
self.label = label
self.opts = opts
self.store = store
self.units = [inv.name for inv in controller.inverters]
self.data = dict(store.data)
self.read = set(store.data.reads)
self.entities = announced_rows(opts)
self.latched = {inv.name: bool(inv.sign_wd.tripped)
for inv in controller.inverters}
self.latch_reason = {inv.name: inv.sign_wd.reason
for inv in controller.inverters}
self.flat_tripped = bool(store.data.get("sign_tripped", False))
self.history = {}
for m in controller.maints:
self.history[m.name] = {k: m.store.get(k) for k in MAINT_UNIT_KEYS}
self.store_migration = controller.store_migration
self.maint_migration = controller.maint_migration
self.machine_read = machine_reads(controller, store)
# Keys no machine reads, and keys that disappeared, whose value is the
# default `Store.load()` would have supplied. Reported, never a
# finding: see `diff()`.
self.stranded_at_default = []
self.vanished_at_default = []
@property
def unit_keys(self):
return sorted(k for k in self.data if k.startswith("unit/"))
def machine_reads(controller, store) -> set:
"""Every store key the CONSTRUCTED machines read, measured not transcribed.
⚠️ THE PROBE IS THE SHIPPED CONSTRUCTOR, RUN AGAIN. `Inverter.__init__` is
the code that restores a unit's SAFETY-02 and SAFETY-42 latches, so building
one more `Inverter` per roster entry against the post-migration store
replays EXACTLY the reads a restart performs - including any key this file
has never heard of. Writing out the key names here instead would be a second
transcription of `UNIT_STORE_KEYS`, and the copy that drifts is always the
one that reads (store.py, `unit_key()`).
The maintenance half iterates `MAINT_UNIT_KEYS`, which is the same tuple
`migrate_maint_keys()` moves and `maintenance.UnitHistory` indexes - so a
key renamed there cannot silently stop being probed here either.
"""
store.data.reads.clear()
for inv in controller.inverters:
Inverter(inv.name, store=store)
for m in controller.maints:
for key in MAINT_UNIT_KEYS:
# ⚠️ `get()` AND NOT ALSO `get_time()`. Both route through
# `store.data.get(self.key(name))`, so either one records the read
# this probe exists to record - but `get_time()` on `phase`, which
# holds "idle" and not a timestamp, makes `Store.get_time()` log
# "unparseable timestamp" at WARNING. A probe that manufactures
# alarming log lines about a healthy store teaches an operator to
# ignore this tool's stderr, which is where the controller's own
# real warnings appear.
m.store.get(key)
seen = set(store.data.reads)
store.data.reads.clear()
return seen
def snapshot(label: str, opts: dict, state_path: str, tmpdir: str) -> Snapshot:
store = RehearsalStore(_scratch_copy(state_path, tmpdir, label))
controller = Controller(dict(opts), Hass(), store, Pub(), stop=None)
return Snapshot(label, opts, store, controller)
def snapshot_over(label: str, opts: dict, prior: Snapshot) -> Snapshot:
"""A second start over the store the previous start LEFT BEHIND.
⚠️ THE SAME FILE, NOT A SECOND COPY OF THE ORIGINAL. This is the sequence a
real box performs: the add-on restarts against `/data/state.json` as its own
previous run wrote it.
"""
store = RehearsalStore(prior.store.path)
controller = Controller(dict(opts), Hass(), store, Pub(), stop=None)
return Snapshot(label, opts, store, controller)
# --------------------------------------------------------------------------- #
# 5. THE DIFF
# --------------------------------------------------------------------------- #
class Finding:
def __init__(self, kind, text):
self.kind = kind
self.text = text
def __str__(self):
return f"{self.kind}: {self.text}"
def diff(before: Snapshot, after: Snapshot) -> list:
"""Everything that changed, and which of those changes is a defect."""
findings = []
# -- store keys ---------------------------------------------------------
# ⚠️ THE SAME DEFAULT/NON-DEFAULT RULE THE STRANDED CHECK USES, AND FOR THE
# SAME REASON - `MigrationReport.lost`'s, in store.py: a dropped
# `sign_tripped = False` has lost the DEFAULT, which is the statement
# "absent" already makes, so nothing is gone; a dropped `True` has lost a
# LATCH. Applying it to STRANDED and not here was an inconsistency with a
# cost: a store whose flat start wrote the two SAFETY-02 defaults under one
# name and whose roster start writes them under another reported two
# VANISHED findings about a pair of defaults, and CONTROL-C went red on a
# build that was CORRECT.
for key in sorted(set(before.data) - set(after.data)):
value = before.data[key]
bare = key.rsplit("/", 1)[-1]
if value in (None, DEFAULTS.get(bare)):
after.vanished_at_default.append(f"{key} = {value!r}")
continue
findings.append(Finding(
"VANISHED STORE KEY",
f"{key} = {value!r} was in the store before the "
f"transition and is not there afterwards"))
# ⭐ THE ONE SAFETY-44 IS ABOUT. A key that is present, correct, verified by
# the migration's own read-back - and that no machine on the new roster will
# ever look under. `MigrationReport.misdirected` says the same thing about
# the keys the migration itself wrote; this says it about the WHOLE FILE,
# including keys written by a version that is no longer installed.
#
# ⚠️ ONLY WHERE THE VALUE IS NOT THE DEFAULT, WHICH IS `MigrationReport`'s
# OWN DISTINCTION AND NOT A SOFTENING OF THIS ONE. A stranded
# `sign_tripped = False` has lost the DEFAULT - the same statement "absent"
# already makes - so nothing is gone; a stranded `sign_tripped = True` has
# lost A LATCH. Reporting the first would make this tool red on a FRESH
# INSTALL, whose migration writes both defaults under `inverter-a` before
# any roster exists, and an alarm that fires on the harmless case is an
# alarm an operator learns to dismiss before the real one arrives - which is
# the argument `MigrationReport.lost` already carries in store.py.
for key in sorted(after.data):
if not key.startswith("unit/") or key in after.machine_read:
continue
value = after.data[key]
bare = key.rsplit("/", 1)[-1]
if value in (None, DEFAULTS.get(bare)):
after.stranded_at_default.append(key)
continue
findings.append(Finding(
"STRANDED STORE KEY",
f"{key} = {value!r} is in the store and NO machine on "
f"the roster {after.units!r} read it during a full start. Its "
f"value is not information."))
# -- the SAFETY-02 latch, by name, because SAFETY-44 asks for it by name --
for key in ("sign_tripped", "sign_reason"):
findings.extend(_latch_finding(key, before, after))
if before.flat_tripped and not any(after.latched.values()):
findings.append(Finding(
"LATCH CAME UP DISARMED",
"the state file carries a LATCHED SAFETY-02 sign-disagreement "
"failsafe under the pre-DEV-02 flat key `sign_tripped`, and NOT "
f"ONE inverter on the roster {after.units!r} came up tripped. "
"SAFETY-02: a latch that a restart clears is not a latch."))
# -- maintenance history -------------------------------------------------
findings.extend(_history_findings(before, after))
# -- entity ids ----------------------------------------------------------
orphans = sorted(set(before.entities) - set(after.entities))
for eid in orphans:
findings.append(Finding(
"ORPHANED ENTITY",
f"{eid} (unique_id {before.entities[eid]}) is announced before the "
"transition and NOT afterwards. Home Assistant does not delete it: "
"its history stops, and every automation whose trigger names it "
"silently stops firing - without an error."))
return findings
def _latch_finding(key, before, after):
"""Did the two SAFETY-44 keys land somewhere a machine reads?
⚠️ THE FIRST ROSTER ENTRY, AND ONLY THE FIRST. The flat pair belongs to
whichever inverter the single-inverter install was driving, which is
`inverters[0]` - that is what `migrate_maint_keys()` targets for the history
and what `Controller._grade_store_migration()` re-arms. Asking whether the
flat latch reached inverter B's namespace would report a finding on a
CORRECT migration, and an instrument that is red on the right answer is
worth nothing when it is red on the wrong one.
"""
out = []
if not after.units or key not in UNIT_STORE_KEYS:
return out
unit = after.units[0]
target = unit_key(unit, key)
was = before.data.get(key)
if target not in after.data and was not in (None, DEFAULTS.get(key)):
out.append(Finding(
"LATCH KEY NOT UNDER A ROSTER NAME",
f"the flat `{key}` = {was!r} did not reach {target}, which is the "
f"key `Inverter({unit!r})` restores from"))
elif (target in after.data and target not in after.machine_read
and after.data[target] not in (None, DEFAULTS.get(key))):
out.append(Finding(
"LATCH KEY NOT READ BY ANY MACHINE",
f"{target} = {after.data[target]!r} exists but no machine on the "
f"roster {after.units!r} read it during a full start"))
return out
# ⚠️ THE REFERENCE IS GRADED AT A FIXED, CANONICAL SHAPE AND NOT AT THE
# OPERATOR'S CONFIG. The reference records what `release/1.0` announces at
# `meter_source: firmware`, roster of one. Grading it against the BEFORE config
# instead asks two questions at once and gets a wrong answer to both: a site
# with `meter_source: off` legitimately omits `p1_age` (see `discovery_payloads`
# and `is_enabled`), so the tool reported "the reference records
# sensor.goodwe_p1_sample_age and this build does not announce it" about a
# perfectly correct build. The question here is ONLY "has this build's
# composition drifted", which is a property of the CODE, so it is asked of the
# code at the shape the reference was taken at.
REFERENCE_PROBE_OPTS = {"meter_source": "firmware"}
def reference_findings(reference: "dict[str, Row]") -> list:
"""Does this BUILD still announce what the reference records?
⚠️ RUN UNCONDITIONALLY, AND OVER ALL THREE TERMS HA KEYS ON. The
before/after diff cannot see a change to the composition, because both of
its sides are composed by the same build. This is the only check in the
tool whose reference does not move with the code, so it is the only one
that can - and it is graded on the entity_id, the `unique_id` AND the
device identifier, because a change to either of the latter two orphans an
install while leaving every composed entity_id byte-identical.
"""
out = []
built = announced_rows(REFERENCE_PROBE_OPTS)
for eid in sorted(set(built) - set(reference)):
out.append(Finding(
"COMPOSITION DRIFT",
f"this build announces {eid} and the reference does not record "
f"it. The composed entity_id has changed: Home Assistant keeps the "
f"id it composed at FIRST discovery, so what this creates is a NEW "
f"entity beside one that stops updating."))
for eid in sorted(set(reference) - set(built)):
out.append(Finding(
"COMPOSITION DRIFT",
f"the reference records {eid} and this build does NOT announce it "
f"at the shape the reference was taken at "
f"({REFERENCE_PROBE_OPTS})."))
for eid in sorted(set(reference) & set(built)):
want, got = reference[eid], built[eid]
if want.unique_id != got.unique_id:
out.append(Finding(
"UNIQUE_ID DRIFT",
f"{eid} is announced with unique_id {got.unique_id!r} where "
f"the reference records {want.unique_id!r}. ⚠️ THE COMPOSED "
f"ENTITY_ID IS UNCHANGED, WHICH IS WHY THIS NEEDS ITS OWN "
f"CHECK: HA's MQTT entity registry keys on `unique_id`, so a "
f"changed one is a NEW entity - and discovery is retained with "
f"no removal payload, so the old config stays on the broker "
f"and the old entity stays in the registry, named by every "
f"automation that referenced it and never updating again."))
if want.device_id != got.device_id:
out.append(Finding(
"DEVICE IDENTIFIER DRIFT",
f"{eid} is announced on device {got.device_id!r} where the "
f"reference records {want.device_id!r}. HA's device registry "
f"keys on the identifier, so this is a NEW device carrying new "
f"entities, beside the old device and its dead ones."))
return out
def install_findings(installed: set, before: Snapshot,
after: Snapshot) -> list:
"""Grade the transition against the ids the INSTALL actually has."""
out = []
owned = installed & set(before.entities)
for eid in sorted(owned - set(after.entities)):
out.append(Finding(
"ORPHANED ENTITY (against the install's own entity list)",
f"{eid} exists on the install and is announced in the BEFORE "
"shape, and the AFTER shape does not announce it."))
return out
def report_entity_list(installed: set, before: Snapshot,
after: Snapshot) -> None:
announced_before = set(before.entities)
foreign = sorted(installed - announced_before)
print()
print(" -- AGAINST THE INSTALL'S OWN ENTITY LIST -------------------------")
print(f" {len(installed)} ids on the install, "
f"{len(installed & announced_before)} of them announced by this "
f"add-on in the BEFORE shape")
print(f" {len(foreign)} on the install that this add-on announces in "
"NEITHER shape - the roster switch cannot orphan these, they belong "
"to the")
print(" ESPHome firmware, to template sensors or to helpers:")
for eid in foreign[:60]:
print(f" {eid}")
if len(foreign) > 60:
print(f" ... and {len(foreign) - 60} more")
def _history_findings(before, after):
"""MAINT-05 moved this once. A second move must not drop it."""
out = []
if not before.history or not after.history:
return out
was = list(before.history.values())[0]
now = list(after.history.values())[0]
inheritor = list(after.history)[0]
for key in ("phase", "last_completed"):
old, new = was.get(key), now.get(key)
if old in (None, DEFAULTS.get(key)):
continue
if old != new:
out.append(Finding(
"MAINTENANCE HISTORY LOST",
f"`{key}` read {old!r} through the first pack's own history "
f"before the transition and reads {new!r} through "
f"{inheritor!r}'s afterwards"))
return out
# --------------------------------------------------------------------------- #
# 6. THE REPORT
# --------------------------------------------------------------------------- #
RETREAT_WINDOW_COMPLETION = "2026-09-20T18:00:00+00:00"
def _recalibrate_while_retreated(retreated: Snapshot) -> str:
"""A maintenance cycle finishes while the install is rolled back to flat.
⚠️ WRITTEN THROUGH THE SHIPPED WRITER. `Maintenance.store` is a
`maintenance.UnitHistory`, which is the one object that decides which key a
pack's history lands under and whether the flat rollback view is mirrored.
A raw `store.set("last_completed", ...)` here would be this file's own
opinion about where a completion goes, and the round trip would then be
grading that opinion instead of the add-on.
"""
store = RehearsalStore(retreated.store.path)
controller = Controller(dict(retreated.opts), Hass(), store, Pub(), stop=None)
controller.maints[0].store.set("last_completed", RETREAT_WINDOW_COMPLETION)
return RETREAT_WINDOW_COMPLETION
def _round_trip_findings(completed: str, readvanced: Snapshot) -> list:
"""Can the re-advanced roster still see work done during the retreat?"""
out = []
if not readvanced.history:
return out
inheritor = list(readvanced.history)[0]
seen = readvanced.history[inheritor].get("last_completed")
if seen != completed:
out.append(Finding(
"RETREAT-WINDOW WORK DISCARDED",
f"a maintenance cycle completed at {completed} while the install "
f"was rolled back to the flat config, and after re-advancing to "
f"the roster {inheritor!r} reads `last_completed` = {seen!r}. The "
"pack will be recalibrated again on a schedule that does not know "
"it already was."))
return out
def report_transition(leg_id, name, before, after, findings) -> None:
"""One leg of the rehearsal.
⚠️ `LEG <id>` IS A MACHINE-PARSEABLE MARKER AND IT IS LOAD-BEARING. Every
finding is printed twice - once here and once in the closing summary - so
COUNTING a finding's text across the whole report cannot tell ONE red leg
from TWO. `test_rehearse.py` part 5 was written that way and reported `ok`
on a build whose failure was a single leg. The pin now splits the report on
these markers and asks each leg separately.
"""
print()
print("=" * 78)
print(f" LEG {leg_id}")
print(f" {name}")
print("=" * 78)
print(f" roster before : {before.units}")
print(f" roster after : {after.units}")
print(f" DEV-02 migration : {after.store_migration.explain()}")
print(f" MAINT-05 migration: {after.maint_migration.explain()}")
print()
print(" -- STORE KEYS ----------------------------------------------------")
keys = sorted(set(before.data) | set(after.data))
width = max([len(k) for k in keys] + [10])
print(f" {'key'.ljust(width)} {'before':<26} {'after':<26} status")
for key in keys:
inb, ina = key in before.data, key in after.data
b = repr(before.data[key]) if inb else "-"
a = repr(after.data[key]) if ina else "-"
if inb and not ina:
status = "VANISHED"
elif ina and not inb:
status = "appeared"
elif before.data[key] != after.data[key]:
status = "changed"
else:
status = ""
if key.startswith("unit/") and ina and key not in after.machine_read:
status = (status + " " if status else "") + "STRANDED-nothing-reads-it"
print(f" {key.ljust(width)} {b[:26]:<26} {a[:26]:<26} {status}")
print()
print(" keys the AFTER roster's machines actually read, measured by "
"replaying")
print(" the shipped constructors against the post-transition store:")
for key in sorted(after.machine_read):
mark = " " if key in after.data else " (absent, defaulted) "
print(f" {key}{mark}")
if after.stranded_at_default or after.vanished_at_default:
print()
print(" keys whose value IS the default `Store.load()` supplies, so "
"nothing is lost")
print(" by stranding or dropping them - reported rather than "
"hidden, not findings:")
for key in after.stranded_at_default:
print(f" stranded {key} = {after.data[key]!r}")
for text in after.vanished_at_default:
print(f" vanished {text}")
print()
print(" -- MAINTENANCE HISTORY (MAINT-05: a second move must not drop it) -")
for label, hist in (("before", before.history), ("after", after.history)):
for unit, vals in hist.items():
print(f" {label:<7} {unit:<12} "
+ " ".join(f"{k}={vals.get(k)!r}" for k in MAINT_UNIT_KEYS))
print()
print(" -- SAFETY-02 LATCH (SAFETY-44 asks about these two keys by name) -")
print(f" flat `sign_tripped` in the file : {before.flat_tripped}")
print(f" flat `sign_reason` in the file : "
f"{before.data.get('sign_reason')!r}")
print(f" before: which units came up latched : "
f"{[u for u, v in before.latched.items() if v] or 'none'}")
print(f" after : which units came up latched : "
f"{[u for u, v in after.latched.items() if v] or 'none'}")
for unit in after.units:
target = unit_key(unit, "sign_tripped")
print(f" {target} = {after.data.get(target, '<absent>')!r} "
f"(read by a machine: {target in after.machine_read})")
print()
print(" -- ENTITY IDS ----------------------------------------------------")
allids = sorted(set(before.entities) | set(after.entities))
print(f" {'entity_id':<44} {'unique_id':<30} {'device':<26} state")
for eid in allids:
row = after.entities.get(eid) or before.entities[eid]
inb, ina = eid in before.entities, eid in after.entities
mark = "both" if (inb and ina) else ("ORPHANED" if inb else "new")
print(f" {eid:<44} {row.unique_id:<30} {row.device_id:<26} {mark}")
print(f" {len(before.entities)} announced before, "
f"{len(after.entities)} after")
print()
print(f" -- FINDINGS in LEG {leg_id} "
+ "-" * max(0, 50 - len(str(leg_id))))
if not findings:
print(f" none in LEG {leg_id}: nothing vanished, nothing stranded, "
"nothing orphaned, no history lost")
for f in findings:
print(f" ⚠️ {f}")
# ⚠️ AN EXPLICIT CLOSE, AND IT IS NOT TIDINESS. Without it a reader that
# splits on the opening markers alone gives every line after the LAST one
# to that leg - including the closing summary, which repeats every finding
# from EVERY leg. The last leg then reads red whenever any leg is red, and
# a per-leg check built on that is the same defect it was written to fix,
# one level down. Measured: with only the opening marker, a build whose
# SAME-STEP leg was clean still reported SAME-STEP disarmed.
print(f" END LEG {leg_id}")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--state", required=True,
help="path to a COPY of /data/state.json. Never written to.")
ap.add_argument("--before", required=True,
help="the options in force now (flat single-inverter)")
ap.add_argument("--after", required=True,
help="the options being switched to (declared roster)")
ap.add_argument("--reverse", action="store_true",
help="also rehearse the RETREAT: roster -> flat")
ap.add_argument("--round-trip", action="store_true",
help="also rehearse RETREAT then RE-ADVANCE, with a "
"maintenance cycle completing while retreated. ⚠️ A "
"retreat is only a retreat if you can come back from "
"it, so the round trip is the leg that decides "
"whether the reverse direction is supported.")
ap.add_argument("--reference", default=str(DEFAULT_REFERENCE),
help="what this build is SUPPOSED to announce: "
"`<entity_id> <unique_id> <device identifier>` per "
"line. Loaded on EVERY run; a missing or malformed "
"one is exit 2, because it is the only reference in "
"this tool that does not move with the code.")
ap.add_argument("--live-entities", default=None,
help="a file of the entity ids the INSTALL actually has, "
"one per line - the owner's export from Home "
"Assistant. Orphans are graded against it.")
ap.add_argument("--no-live-entities", action="store_true",
help="⚠️ RUN WITHOUT THE INSTALL'S ENTITY LIST, ACCEPTING "
"THAT THE ORPHAN CHECK IS NOT GRADED AGAINST "
"REALITY. Required to omit --live-entities; the "
"report then carries a standing banner and the exit "
"line says so, because a forgotten flag must never "
"look like a clean bill of health.")
ap.add_argument("--paths", default="both", choices=("staged", "same-step",
"both"),
help="staged: the box runs the BEFORE config once, then "
"restarts on AFTER. same-step: the roster is declared "
"in the SAME step as the upgrade, so the BEFORE "
"controller never runs against this store. "
"Default: both.")
args = ap.parse_args(argv)
# ⚠️ THE ASYMMETRY THAT USED TO BE HERE WAS BACKWARDS AND IT IS THE WHOLE
# REASON THIS BLOCK EXISTS. A `--live-entities` file that could not be READ
# refused with exit 2; the flag simply not being PASSED produced "NO
# FINDINGS" and exit 0, with nothing in the report to say half the orphan
# check had not run - measured, `grep -ci` for the words returned 0. The
# owner runs this ONCE, at commissioning, under time pressure. A forgotten
# flag must not hand back a clean bill of health for the half of the
# transition that orphans entities. So omitting it is now a REFUSAL, and
# accepting the weaker run has to be said out loud.
try:
if not args.live_entities and not args.no_live_entities:
raise Refused(
"no --live-entities. The orphan check is graded against the "
"ids the INSTALL has, and without that file it is not graded "
"against reality at all. Export it (DOCS.md, 'Before you "
"commission inverter B'), or pass --no-live-entities to "
"accept the weaker run - which is then stated in the report "
"and on the exit line rather than left to be assumed.")
if args.live_entities and args.no_live_entities:
raise Refused("--live-entities and --no-live-entities are "
"contradictory; pass one.")
_check_anchor()
reference = load_reference(args.reference)
before_opts = load_options(args.before)
after_opts = load_options(args.after)
installed = (load_entity_list(args.live_entities)
if args.live_entities else None)
if not Path(args.state).is_file():
raise Refused(f"{args.state}: not a file")
except (OSError, Refused) as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
degraded = installed is None
print(__doc__.split("EXIT CODES")[0].rstrip())
print()
print(f" state file (copied, never written): {args.state}")
print(f" before config: {args.before}")
print(f" after config: {args.after}")
print(f" reference : {args.reference} ({len(reference)} rows, graded "
f"on entity_id + unique_id + device identifier)")
if degraded:
print()
print(" " + "!" * 74)
print(" !! DEGRADED RUN: --no-live-entities. The ORPHAN CHECK IS NOT "
"GRADED AGAINST")
print(" !! THE INSTALL. This run can still see the composition "
"drifting away from")
print(" !! the reference, and it can see the store migration - but it "
"cannot tell")
print(" !! you that an entity the install HAS stops being announced, "
"because it")
print(" !! has not been told what the install has. ⚠️ A CLEAN RESULT "
"BELOW IS NOT A")
print(" !! CLEAN BILL OF HEALTH FOR THE ENTITIES.")
print(" " + "!" * 74)
else:
print(f" install list : {args.live_entities} ({len(installed)} ids)")
findings = []
tmpdir = tempfile.mkdtemp(prefix="rehearse-roster-")
try:
if args.paths in ("staged", "both"):
b = snapshot("staged-before", before_opts, args.state, tmpdir)
a = snapshot_over("staged-after", after_opts, b)
f = diff(b, a) + reference_findings(reference)
if installed is not None:
f += install_findings(installed, b, a)
report_transition(
"STAGED",
"the box runs the flat config, then restarts on the roster",
b, a, f)
if installed is not None:
report_entity_list(installed, b, a)
findings += f
if args.paths in ("same-step", "both"):
# ⚠️ SAFETY-44's EXACT PATH. A store that ran pre-DEV-02 flat,
# upgraded straight to 0.7.0+, AND declared a multi-inverter roster
# in the same step - so nothing ever ran the flat config under a
# version that knew about namespaces.
b2 = snapshot("samestep-before", before_opts, args.state, tmpdir)
a2 = snapshot("samestep-after", after_opts, args.state, tmpdir)
f2 = diff(b2, a2) + reference_findings(reference)
if installed is not None:
f2 += install_findings(installed, b2, a2)
report_transition(
"SAME-STEP",
"SAFETY-44's path: a pre-DEV-02 flat store meets a declared "
"roster on its FIRST start under this version",
b2, a2, f2)
findings += f2
if args.reverse or args.round_trip:
r1 = snapshot("retreat-before", after_opts, args.state, tmpdir)
r2 = snapshot_over("retreat-after", before_opts, r1)
fr = diff(r1, r2)
if installed is not None:
fr += install_findings(installed, r1, r2)
report_transition(
"RETREAT",
"roster -> flat, which is the back-out if commissioning B "
"goes wrong", r1, r2, fr)
findings += fr
if args.round_trip:
# ⚠️ THE RETREAT WINDOW HAS TO CONTAIN WORK, OR THIS LEG IS HABIT 4.
# A round trip over a store that nothing changed in between compares
# a structure to itself and reports "clean" whatever the code does.
# So a maintenance cycle COMPLETES while the install is retreated -
# written through `maintenance.UnitHistory`, which is the shipped
# writer, not through a raw `store.set()` - and the question this
# leg asks is whether the pack can still see it after re-advancing.
q1 = snapshot("roundtrip-roster", after_opts, args.state, tmpdir)
q2 = snapshot_over("roundtrip-retreated", before_opts, q1)
worked = _recalibrate_while_retreated(q2)
q3 = snapshot_over("roundtrip-readvanced", after_opts, q2)
fq = diff(q2, q3) + _round_trip_findings(worked, q3)
report_transition(
"ROUND-TRIP",
f"roster -> flat -> roster, with a maintenance cycle "
f"completing at {worked!r} while retreated",
q2, q3, fq)
findings += fq
except Refused as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
print()
print("=" * 78)
if findings:
print(f" {len(findings)} FINDING(S) - this transition is not clean")
for f in findings:
print(f" ⚠️ {f}")
else:
print(" NO FINDINGS in the rehearsed transitions")
# ⚠️ ON THE EXIT LINE, NOT ONLY IN THE HEADER. A report is skimmed from the
# bottom.
if degraded:
print(" ⚠️ DEGRADED RUN (--no-live-entities): the orphan check was "
"NOT graded against")
print(" the install's own entity list. This result does not cover "
"orphaned entities.")
print("=" * 78)
return EXIT_FINDING if findings else EXIT_OK
if __name__ == "__main__":
sys.exit(main())