|
|
|
@@ -0,0 +1,840 @@
|
|
|
|
|
"""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)
|
|
|
|
|
|
|
|
|
|
@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.
|
|
|
|
|
for key in sorted(after.data):
|
|
|
|
|
if not key.startswith("unit/"):
|
|
|
|
|
continue
|
|
|
|
|
if key in after.machine_read:
|
|
|
|
|
continue
|
|
|
|
|
findings.append(Finding(
|
|
|
|
|
"STRANDED STORE KEY",
|
|
|
|
|
f"{key} = {after.data[key]!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:
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
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())
|