A store the add-on has never written has no latch and no history. The DEV-02 migration still runs on it - `schema_version` is absent, so `from_version` is 0 - and it writes `sign_tripped = False` and `sign_reason = ""` under `inverter-a` before `build_roster()` exists. On a declared roster nothing reads either of them, so the first draft called both STRANDED and exited 1. Every fresh install that grows a second inverter would have been reported as a failed transition. ⚠️ THAT IS A DEFECT IN THE ALARM, NOT A CASE TO SUPPRESS, and store.py already carries the distinction it needed: `MigrationReport.lost` reports a dropped `sign_tripped=True` and NOT a dropped `sign_tripped=False`, because "absent" and "never tripped" are the same statement - and because an alarm that fires on the harmless case is one an operator learns to dismiss before the real one arrives. The stranded check now makes the same split, on the VALUE and not on the key name: a key stranded at its default is printed under its own heading, visibly, and is not a finding; the same key stranded at a non-default still is. SAFETY-44 turns on `sign_tripped = True`, so it is untouched, and a check pins both halves against each other so the softening cannot widen into a hole. CONTROL-C is the fresh install, and it is a third clean control rather than a line in a comment: the shipped code is right about it and the harness must say nothing. It is the control that found this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
867 lines
37 KiB
Python
867 lines
37 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"
|
|
|
|
|
|
def entity_ids(opts: dict) -> "dict[str, str]":
|
|
"""`{entity_id: unique_id}` 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:
|
|
out[compose(cfg["device"]["name"], cfg["name"])] = cfg["unique_id"]
|
|
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.")
|
|
|
|
|
|
def load_entity_list(path: str) -> set:
|
|
"""The entity ids that exist on the install, one per line, `#` comments.
|
|
|
|
⚠️ EXTERNAL DATA, AND THE TOOL IS BLIND WITHOUT IT. The before/after diff is
|
|
composed twice by the SAME build, so a change to the id composition itself -
|
|
a renamed MQTT device, a renamed sensor - moves both sides equally and the
|
|
diff reports nothing. `tools/rehearsal/live-entities-recorded.txt` carries
|
|
the recorded list and DOCS.md's owner step replaces it with a real export.
|
|
"""
|
|
out = set()
|
|
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if line:
|
|
out.add(line)
|
|
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 = entity_ids(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 whose value is the default `Store.load()` would
|
|
# have supplied. Reported, never a finding: see `diff()`.
|
|
self.stranded_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:
|
|
m.store.get(key)
|
|
m.store.get_time(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 ---------------------------------------------------------
|
|
gone = sorted(set(before.data) - set(after.data))
|
|
for key in gone:
|
|
findings.append(Finding(
|
|
"VANISHED STORE KEY",
|
|
f"{key} = {before.data[key]!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
|
|
|
|
|
|
def entity_list_findings(recorded: set, before: Snapshot,
|
|
after: Snapshot) -> list:
|
|
"""Grade both snapshots against a list that does NOT move with the code."""
|
|
out = []
|
|
announced_before = set(before.entities)
|
|
announced_after = set(after.entities)
|
|
for eid in sorted(announced_before - recorded):
|
|
out.append(Finding(
|
|
"COMPOSITION DRIFT",
|
|
f"this build announces {eid} in the BEFORE shape, and that id is "
|
|
"not on the recorded list of ids the install has. The id "
|
|
"composition has changed under the install: Home Assistant keeps "
|
|
"the id it composed at first discovery, so what this actually "
|
|
"creates is a NEW entity beside a dead one."))
|
|
owned = recorded & announced_before
|
|
for eid in sorted(owned - announced_after):
|
|
out.append(Finding(
|
|
"ORPHANED ENTITY (against the recorded 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(recorded: set, before: Snapshot, after: Snapshot) -> None:
|
|
announced_before = set(before.entities)
|
|
foreign = sorted(recorded - announced_before)
|
|
print()
|
|
print(" -- AGAINST THE RECORDED ENTITY LIST ------------------------------")
|
|
print(f" {len(recorded)} ids on the list, "
|
|
f"{len(recorded & announced_before)} of them announced by this "
|
|
f"add-on in the BEFORE shape")
|
|
print(f" {len(foreign)} on the list 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(name, before, after, findings) -> None:
|
|
print()
|
|
print("=" * 78)
|
|
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:
|
|
print()
|
|
print(" keys no machine reads whose value IS the default, so "
|
|
"nothing is lost by")
|
|
print(" stranding them - reported rather than hidden, not counted "
|
|
"as findings:")
|
|
for key in after.stranded_at_default:
|
|
print(f" {key} = {after.data[key]!r}")
|
|
|
|
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))
|
|
for eid in allids:
|
|
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:<48} {mark}")
|
|
print(f" {len(before.entities)} announced before, "
|
|
f"{len(after.entities)} after")
|
|
|
|
print()
|
|
print(" -- FINDINGS ------------------------------------------------------")
|
|
if not findings:
|
|
print(" none: nothing vanished, nothing stranded, nothing orphaned, "
|
|
"no history lost")
|
|
for f in findings:
|
|
print(f" ⚠️ {f}")
|
|
|
|
|
|
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("--live-entities", default=None,
|
|
help="a file of entity ids the install actually has, one "
|
|
"per line. ⚠️ WITHOUT IT THE TOOL CANNOT SEE A CHANGE "
|
|
"TO THE ID COMPOSITION, because both sides of its own "
|
|
"diff are composed by the same build. "
|
|
"tools/rehearsal/live-entities-recorded.txt is the "
|
|
"recorded default; DOCS.md's owner step replaces it "
|
|
"with a real export.")
|
|
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("--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)
|
|
|
|
try:
|
|
_check_anchor()
|
|
before_opts = load_options(args.before)
|
|
after_opts = load_options(args.after)
|
|
if not Path(args.state).is_file():
|
|
raise Refused(f"{args.state}: not a file")
|
|
except Refused as err:
|
|
print(f"REFUSED: {err}", file=sys.stderr)
|
|
return EXIT_COULD_NOT_RUN
|
|
|
|
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}")
|
|
|
|
findings = []
|
|
recorded = None
|
|
try:
|
|
if args.live_entities:
|
|
recorded = load_entity_list(args.live_entities)
|
|
except (OSError, Refused) as err:
|
|
print(f"REFUSED: {err}", file=sys.stderr)
|
|
return EXIT_COULD_NOT_RUN
|
|
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)
|
|
if recorded:
|
|
f += entity_list_findings(recorded, b, a)
|
|
report_transition(
|
|
"STAGED: the box runs the flat config, then restarts on the "
|
|
"roster", b, a, f)
|
|
if recorded:
|
|
report_entity_list(recorded, b, a)
|
|
print(" (findings from the recorded list are in the list "
|
|
"above)")
|
|
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)
|
|
if recorded:
|
|
f2 += entity_list_findings(recorded, b2, a2)
|
|
report_transition(
|
|
"SAME-STEP (SAFETY-44): 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)
|
|
report_transition(
|
|
"THE 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(
|
|
"THE ROUND TRIP: roster -> flat -> roster, with a maintenance "
|
|
f"cycle 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}")
|
|
print("=" * 78)
|
|
return EXIT_FINDING
|
|
print(" NO FINDINGS in the rehearsed transitions")
|
|
print("=" * 78)
|
|
return EXIT_OK
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|