A charge is complete at SoC >= target, or at >= target - 3 once the SoC has made no new high for 60 min of allowed charging, or when the charging time runs out at >= target - 3. The 36 h timeout counts only the minutes the price gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that fails (stuck drain, charge below target - 3) backs off 7 days, tells the operator once (EN + NL), and no cycle starts on that pack in that time unless the operator forces one. State survives a restart; old state files load. No option added. DECISIONS/MAINT-13-full-pack-is-complete.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
648 lines
35 KiB
Python
648 lines
35 KiB
Python
"""Persistent state, in /data (the add-on's only durable volume).
|
|
|
|
The maintenance cycle is month-scale state: it MUST survive add-on restarts,
|
|
HA restarts and power cuts, or the battery quietly stops being maintained and
|
|
nothing says so.
|
|
|
|
Times are stored as ISO-8601 with an explicit UTC offset and parsed back to
|
|
aware datetimes. That is not fussiness: the YAML implementation this replaces
|
|
was disabled for hours by exactly one naive-vs-aware subtraction, and it failed
|
|
silently - the automation never even recorded itself as triggered.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
|
|
_LOG = logging.getLogger("goodwe.store")
|
|
|
|
# ⚠️ "auto" is deliberately NOT in here. Controller falls back to the add-on's
|
|
# `auto_start` option only when the key is absent - if a default supplied False,
|
|
# the fallback could never fire and auto_start would silently do nothing on
|
|
# every fresh install.
|
|
#
|
|
# ⚠️ `sign_tripped` IS in here, and the difference from `auto` is the whole
|
|
# reason both decisions are written down. `auto` has an option to fall back to
|
|
# (`auto_start`), so a stored default would mask a fresh install. A latched
|
|
# failsafe has no such fallback: absent means "never tripped", which is exactly
|
|
# what a default of False says.
|
|
#
|
|
# It is persisted for the reason this module's docstring gives for maintenance
|
|
# state, applied to something with a much shorter fuse. SAFETY-02's latch is the
|
|
# only thing standing between a controller that has been caught driving the wrong
|
|
# way and its own restart, and "restart the add-on" is the FIRST thing an operator
|
|
# looking at a FAILSAFE banner will try. Held only in memory it came back armed,
|
|
# into the fault, unattended - measured: after reconstructing Controller against
|
|
# the same store, `tripped` was False, the banner read NOT READY, and it resumed
|
|
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
|
|
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
|
|
# power at a time, forever.
|
|
#
|
|
# ⚠️ AND SINCE DEV-02 THE TWO SAFETY-02 KEYS ARE THE *LEGACY* SPELLING. They are
|
|
# FLAT - one `sign_tripped` for a controller that now holds a LIST of inverters -
|
|
# so a trip caused by inverter B would latch inverter A across restarts, and one
|
|
# operator action would clear both. The LIVE latch is per inverter, under
|
|
# `unit/<name>/...`, and that is the one the watchdog is restored from.
|
|
#
|
|
# ⚠️ THE FLAT PAIR IS NOT DEAD, THOUGH: it is the ROLLBACK VIEW. It is written
|
|
# alongside the namespaced keys on a trip and cleared alongside them on the
|
|
# operator's clear, so an add-on that is DOWNGRADED past this schema still finds
|
|
# the latch it should - and so that a trip taken while the install was rolled
|
|
# back is still visible to the version that comes back up. Rule 2 of
|
|
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
|
|
# argument; the short version is that a latch is only worth persisting if it
|
|
# survives the version change an operator makes while staring at the banner.
|
|
#
|
|
# ⚠️ AND SAFETY-31's PAIR IS FLAT ON PURPOSE, WHICH IS THE OPPOSITE DECISION TO
|
|
# THE ONE ABOVE AND NEEDS ITS OWN SENTENCE. `circ_tripped` / `circ_reason` are
|
|
# NOT in `LEGACY_UNIT_KEYS` and must never be moved into a unit namespace.
|
|
# Circulating power is `sum(|B_i|) - |sum(B_i)|` - a quantity DEFINED over the
|
|
# whole roster, which is identically zero for any single unit. There is no fact
|
|
# of the matter about which of two opposing inverters is the offender: a unit
|
|
# ignoring its share and a sibling faithfully executing a share computed to sum
|
|
# with it read identically. Namespacing this latch would mean inventing an
|
|
# attribution the measurement cannot support, and would then let an operator
|
|
# clear "inverter B's" half of a site-wide loop. One site, one latch.
|
|
#
|
|
# It is persisted for SAFETY-34's reason, which this add-on has already paid
|
|
# for once: `persistent_notification` is IN MEMORY. Home Assistant restarts and
|
|
# the card is gone while the fault runs on - exactly how the 2026-09-02 outage
|
|
# went unseen for 6 h 25 m with the detector correct throughout. main.py
|
|
# re-raises the notification from this key on every start.
|
|
#
|
|
# ⚠️ AND SAFETY-30's PAIR IS FLAT FOR THE SAME REASON SAFETY-31's IS, WHICH IS
|
|
# NOT THE SAME REASON THE ONES ABOVE ARE NAMESPACED. `site_sign_tripped` /
|
|
# `site_sign_reason` hold the SITE sign-disagreement latch: the SUM of every
|
|
# standing command opposed the meter, continuously. That is a statement about
|
|
# the grid connection - the offending number is the site target, which no single
|
|
# inverter owns - so there is no unit to hang it on, and hanging it on one would
|
|
# let an operator clear "inverter B's half" of a fault that has no halves.
|
|
# `unit/<name>/sign_tripped` above stays what it always was and now means what
|
|
# it says: THAT unit's own command against THAT unit's own measured power.
|
|
#
|
|
# ⚠️ AND THEY ARE NOT IN `LEGACY_UNIT_KEYS`, MUST NEVER BE, AND ARE NOT WRITTEN
|
|
# AT A ROSTER OF ONE AT ALL. At N=1 the site comparison and the unit comparison
|
|
# are the same sentence and `Controller.cycle()` arms only the per-unit
|
|
# detector, so a one-inverter install's state file is byte-identical to what it
|
|
# was before this card - these two keys sit at their defaults and nothing reads
|
|
# them. A rollback therefore finds exactly the flat `sign_tripped` mirror it
|
|
# always found.
|
|
DEFAULTS = {
|
|
"phase": "idle",
|
|
"phase_started": None,
|
|
"last_completed": None,
|
|
"last_start_attempt": None,
|
|
"last_failed": None,
|
|
"sign_tripped": False,
|
|
"sign_reason": "",
|
|
"circ_tripped": False,
|
|
"circ_reason": "",
|
|
"site_sign_tripped": False,
|
|
"site_sign_reason": "",
|
|
}
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# DEV-02: PER-INVERTER KEYS, AND THE MIGRATION IS THE RISKY HALF
|
|
#
|
|
# ⚠️ A LATCH THAT SILENTLY FAILS TO MIGRATE IS A DISARMED SAFETY GUARD ON AN
|
|
# INSTALL THAT BELIEVES IT IS PROTECTED. SAFETY-02's whole reason for being
|
|
# persisted is that "restart the add-on" is the FIRST thing an operator staring
|
|
# at a FAILSAFE banner tries, and a latch held only in memory comes back armed,
|
|
# into the fault, unattended. A latch that is written down under a key nothing
|
|
# reads any more is exactly the same failure with a longer paper trail: the file
|
|
# still says `sign_tripped: true`, the banner is green, and the controller
|
|
# resumes driving the wrong way.
|
|
#
|
|
# So this is a MIGRATION WITH A POSTCONDITION, not a copy. `migrate_unit_keys()`
|
|
# reports what it did AND reads the result back; `Controller.__init__` grades
|
|
# that report and refuses to come up quietly if the latch did not survive. The
|
|
# test that grades it BREAKS the migration and watches the guard fire.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
# 0 = the flat, one-inverter layout that shipped before DEV-02.
|
|
# 1 = per-inverter keys under `unit/<name>/...`.
|
|
SCHEMA_VERSION = 1
|
|
SCHEMA_KEY = "schema_version"
|
|
|
|
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
|
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
|
|
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
|
|
# `Maintenance` per roster entry needs one history per roster entry - but they
|
|
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
|
|
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
|
|
# folding them into this tuple would either drag them across on a
|
|
# single-inverter install (which is the byte-identical store layout MAINT-05
|
|
# must not change) or hold the sign latch hostage to a roster count it has
|
|
# nothing to do with. `MAINT_UNIT_KEYS` below carries them.
|
|
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# MAINT-05: THE SECOND MIGRATION, AND WHY IT IS A SECOND ONE
|
|
#
|
|
# 0 = the flat, one-history layout every install has had since the add-on
|
|
# shipped: `phase` / `phase_started` / `last_completed` /
|
|
# `last_start_attempt` describe THE pack, because there was one FSM.
|
|
# 1 = one history per roster entry, under `unit/<name>/...`.
|
|
#
|
|
# ⚠️ ITS OWN MARKER, NOT A BUMP OF `SCHEMA_VERSION`, AND THE REASON IS THAT THE
|
|
# TWO MIGRATIONS DO NOT HAVE THE SAME PRECONDITION. DEV-02's runs on every
|
|
# install exactly once and is monotone in the version number. This one runs ONLY
|
|
# WHERE THE ROSTER HAS MORE THAN ONE PACK, because at N=1 §6.2's identity holds
|
|
# and the flat keys ARE that pack's namespace - moving them would add four keys
|
|
# and a version marker to a store MAINT-05's own acceptance criterion requires
|
|
# to be byte-identical. A single monotone counter cannot express "migrated on
|
|
# this site, deliberately not migrated on that one" without lying to the next
|
|
# version that reads it: a one-inverter store would have to be left at the old
|
|
# number forever, and then a genuine v2 migration could never be told apart from
|
|
# a site that simply never grew.
|
|
#
|
|
# ⚠️ AND IT IS TWO MARKERS, NOT TWO COPIES OF THE THREE RULES.
|
|
# `_move_flat_keys()` and `_verify_moves()` below are shared verbatim by both
|
|
# migrations, so rules 1 and 3 and the READ-BACK POSTCONDITION are one
|
|
# implementation. A second transcription of a migration is a second opinion
|
|
# about what "it landed" means.
|
|
MAINT_SCHEMA_VERSION = 1
|
|
MAINT_SCHEMA_KEY = "maint_schema_version"
|
|
|
|
# The flat maintenance-history keys, in the order they are moved.
|
|
# ⚠️ THE VALUES ARE THE FLAT SPELLINGS AND THE MODULE THAT READS THEM INDEXES
|
|
# THIS SAME TUPLE (`maintenance.UnitHistory`), so a key renamed here cannot
|
|
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
|
# SAFETY-02 pair.
|
|
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
|
"last_start_attempt", "last_failed")
|
|
|
|
|
|
def unit_key(unit: str, key: str) -> str:
|
|
"""The namespaced Store key for `key` on inverter `unit`.
|
|
|
|
⚠️ ONE SPELLING, IN ONE PLACE. `Inverter.key()` calls this and so does the
|
|
migration, so a check can assert that what was migrated is what is read.
|
|
Two transcriptions of a key name is one copy plus a future disagreement
|
|
about which one holds the latch - and the copy that drifts is always the one
|
|
that reads.
|
|
"""
|
|
return f"unit/{unit}/{key}"
|
|
|
|
|
|
class MigrationReport:
|
|
"""What `migrate_unit_keys()` did, and whether it can be believed.
|
|
|
|
⚠️ IT CARRIES `verified`, WHICH IS READ BACK FROM THE STORE RATHER THAN
|
|
ASSUMED FROM THE ASSIGNMENT. That is the difference between this and a
|
|
two-line copy: the caller is able to ask "is the latch that was in the file
|
|
before now under the key the watchdog reads", and a `False` there is a
|
|
disarmed guard, which is an ERROR and not a log line nobody sees.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.ran = False # did anything need moving
|
|
self.from_version = 0
|
|
self.moved = {} # new key -> value written
|
|
self.kept = [] # keys already namespaced, left alone
|
|
self.verified = True # every move read back equal
|
|
self.problems = [] # human-readable, one per failed move
|
|
# ⚠️ THE SUBSET THAT MATTERS, AND THE DISTINCTION IS NOT PEDANTRY. A
|
|
# store that drops a write of `sign_tripped=False` has lost the
|
|
# DEFAULT - the same statement "absent" already makes, so nothing is
|
|
# gone. One that drops a write of `True` has lost A LATCH. Only the
|
|
# second is a disarmed safety guard, and only the second may be allowed
|
|
# to shout: an alarm that fires on the harmless case is an alarm an
|
|
# operator learns to dismiss before the real one arrives.
|
|
self.lost = []
|
|
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
|
|
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
|
|
# dropped it". This is "the store took the write, kept it perfectly, and
|
|
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
|
|
# is not on the roster, so every machine looks under a different key and
|
|
# finds nothing. There is no harmless-default version of this the way
|
|
# there is for `lost`: a write nobody reads is not information, whatever
|
|
# its value, so anything in here fails `ok` on its own.
|
|
self.misdirected = []
|
|
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
|
self.skipped = ""
|
|
# The inverter list the moves were graded against, for `explain()`.
|
|
self.roster = []
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
|
|
MACHINES LOOK."""
|
|
return not self.lost and not self.misdirected
|
|
|
|
def explain(self) -> str:
|
|
if not self.ran:
|
|
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
|
|
# MAINT-05 there are two reasons a migration does not run - the
|
|
# store is already at this schema, or the roster has one pack and
|
|
# the flat keys ARE its namespace - and an operator reading the
|
|
# startup log needs to be told which, because only one of them
|
|
# changes when a second inverter is declared.
|
|
return self.skipped or (f"the saved state is already at version "
|
|
f"{self.from_version}, so nothing moves")
|
|
# UX-10: plain sentences (test_messages.py part 6). Each problem is
|
|
# named by its key and what went wrong, and `problems`, `lost` and
|
|
# `misdirected` keep their full notes for the checks that read them.
|
|
n = len(self.moved)
|
|
out = (f"moved {n} saved {'value' if n == 1 else 'values'} to one part "
|
|
f"per inverter: " + ", ".join(sorted(self.moved)) if n
|
|
else "moved nothing")
|
|
if self.kept:
|
|
out += ". Already in place: " + ", ".join(sorted(self.kept))
|
|
def keys(notes):
|
|
return ", ".join(note.split(" ", 1)[0] for note in notes)
|
|
other = [n for n in self.problems
|
|
if n not in self.lost and n not in self.misdirected]
|
|
parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
|
|
[f"moved under an inverter not in the list {self.roster!r}: "
|
|
f"{keys(self.misdirected)}"] if self.misdirected else []) + (
|
|
[f"read back differently {keys(other)}"] if other else [])
|
|
if parts:
|
|
out += ". Problems: " + ", and ".join(parts)
|
|
return out
|
|
|
|
|
|
def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
|
|
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
|
|
|
|
`unit` is the FIRST inverter in the roster, which on every install that can
|
|
possibly have a flat key is the only one it ever had. A store written by a
|
|
version that knew about more than one inverter is already at
|
|
SCHEMA_VERSION and is not touched.
|
|
|
|
⚠️ `roster` IS THE NAMES THE MACHINES READ, AND IT IS SAFETY-44. This
|
|
function shipped WITHOUT it, taking a hardcoded `inverter.UNIT_A` at its one
|
|
call site - which is, exactly, the MAINT-05 defect `_verify_moves()` spends
|
|
a paragraph describing, still live in the sibling that paragraph was written
|
|
beside. On a store that ran pre-DEV-02 flat and declares
|
|
`inverters: [garage, shed]`, the latch moved to `unit/inverter-a/
|
|
sign_tripped`, was read back under that same name, was SATISFIED, and
|
|
stamped the marker - so the next start skipped the migration too and NO
|
|
inverter came up latched on an install whose state file says `sign_tripped:
|
|
true`. A disarmed SAFETY-02 guard, every symptom of a clean migration.
|
|
|
|
`roster_names(opts)` answers this BEFORE any `Inverter` exists - it is
|
|
defined to, for `MqttPublisher`, which is constructed before the roster for
|
|
the same reason - so the "there is no roster yet" that kept this argument
|
|
out was never true. On the flat shape it returns `[UNIT_A]`, so the upgrade
|
|
every install runs today passes `unit == roster[0] == UNIT_A` and is
|
|
byte-identical.
|
|
|
|
THE THREE RULES, each one a case that would otherwise be a wrong answer:
|
|
|
|
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
|
|
spellings has been written by this version, and the namespaced one is
|
|
where the WATCHDOG is restored from. Preferring the flat one HERE would
|
|
resurrect a trip the operator has already cleared, because a migration
|
|
runs before anything can tell the two apart. Deciding between them is
|
|
main.py's job and it has the one rule that can: the flat key carries new
|
|
information only when NOTHING namespaced is latched.
|
|
2. THE FLAT KEYS ARE LEFT IN THE FILE, AND THEY ARE KEPT LIVE. Deleting
|
|
them buys tidiness and costs a rollback: an operator who downgrades the
|
|
add-on after a migration would find no latch at all, which is the
|
|
disarmed guard again in the other direction. Left in place, a downgrade
|
|
finds the trip it had.
|
|
|
|
⚠️ AND "LEFT IN PLACE" IS NOT ENOUGH ON ITS OWN, WHICH IS WHAT THIS RULE
|
|
USED TO SAY. The sentence here read "they go stale from this moment -
|
|
nothing writes them again - and the direction that staleness fails in is
|
|
a SPURIOUS latch, never a missing one". That was true of the latch this
|
|
migration MOVES and false of every trip that happens AFTERWARDS: nothing
|
|
flat was written for those, so a rollback found `sign_tripped: false` and
|
|
came up DRIVING. So main.py mirrors both keys on a trip and clears both
|
|
on the operator's clear. The flat pair is the ROLLBACK VIEW of the latch,
|
|
maintained, not a fossil - which is also what lets main.py's
|
|
`_grade_store_migration()` run on every start and catch a trip that
|
|
happened while the install was rolled back (see its docstring: the flat
|
|
key saying tripped while no inverter came up tripped is the one
|
|
disagreement that can occur, and it is a DOWNGRADE-WINDOW LATCH).
|
|
3. THE VERSION MARKER IS WRITTEN LAST. A crash between the copies and the
|
|
marker leaves the migration to run again, and rule 1 makes running it
|
|
again a no-op. A marker written first would leave a half-migrated store
|
|
looking finished.
|
|
"""
|
|
rep = MigrationReport()
|
|
data = getattr(store, "data", {})
|
|
rep.from_version = int(data.get(SCHEMA_KEY, 0) or 0)
|
|
if rep.from_version >= SCHEMA_VERSION:
|
|
return rep
|
|
|
|
rep.ran = True
|
|
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
|
|
_verify_moves(store, rep, roster)
|
|
|
|
if rep.ok:
|
|
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
|
else:
|
|
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
|
# next start rather than be recorded as finished - the one thing worse
|
|
# than a failed migration is a failed migration a later version skips.
|
|
_LOG.error("Moving the saved state to one part per inverter lost "
|
|
"data, so it is not marked done: %s", rep.explain())
|
|
return rep
|
|
|
|
|
|
def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
|
|
"""Rule 1, once, for both migrations.
|
|
|
|
Copies each flat `key` to `unit/<unit>/<key>` UNLESS the namespaced spelling
|
|
is already present - a store that has both has been written by a version
|
|
that knew about the namespace, and that value is the live one. Rule 2 is the
|
|
absence of a delete: the flat keys are left exactly where they were.
|
|
"""
|
|
data = getattr(store, "data", {})
|
|
for legacy in keys:
|
|
target = unit_key(unit, legacy)
|
|
if target in data:
|
|
rep.kept.append(target)
|
|
continue
|
|
# ⚠️ `DEFAULTS[legacy]` RATHER THAN A LITERAL. An install that never
|
|
# tripped has no `sign_tripped` in its file at all, and the value it
|
|
# then lands on has to be the one `Store.load()` would have supplied -
|
|
# otherwise "absent" and "never tripped" stop being the same statement,
|
|
# which is the distinction store.py's own header is about. The same
|
|
# sentence holds for `last_completed`: absent means "never recalibrated"
|
|
# and a literal here would be a second opinion about that.
|
|
value = data.get(legacy, DEFAULTS.get(legacy))
|
|
store.set(target, value)
|
|
rep.moved[target] = value
|
|
|
|
|
|
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
|
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
|
|
|
|
This is the difference between these functions and a two-line copy: the
|
|
caller can ask "is the latch that was in the file before now under the key
|
|
the watchdog reads" - and, since MAINT-05, "is the history that was in the
|
|
file before now under the key THIS PACK's state machine reads" - through the
|
|
same `unit_key()` those readers read through. A `False` here is a guard that
|
|
is off, or a month of coulomb-count history gone, on an install whose state
|
|
file says otherwise.
|
|
|
|
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
|
|
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
|
|
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
|
|
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
|
|
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
|
|
install's whole maintenance history into `unit/inverter-a/...` on a roster of
|
|
`["garage", "shed"]`, read all four keys back under that same name, was
|
|
SATISFIED, and stamped the schema marker - so the next start skipped the
|
|
migration too and both packs came up believing they had never been
|
|
recalibrated. Every symptom of a clean migration, and the history gone.
|
|
`build_roster()` names a declared roster's entries from the config and falls
|
|
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
|
|
there; there is no roster on which that write could have been read.
|
|
|
|
So the namespace is graded against the one the MACHINES read - the roster -
|
|
rather than against the argument this function was handed, which is the
|
|
thing under test. A unit that is not on the roster is `misdirected`, which
|
|
fails `ok`, which means the caller does NOT stamp the version marker and the
|
|
next start runs the migration again. A re-run is recoverable, by rule 1; a
|
|
silent skip is the state store.py's header says must never happen.
|
|
|
|
⚠️ AND THE GRADED SET IS `moved` UNION `kept`, WHICH IS WHAT MAKES THAT
|
|
PARAGRAPH TRUE ON THE SECOND START AS WELL AS THE FIRST. Graded over `moved`
|
|
alone the refusal lasted exactly one start: the wrong call site is still
|
|
wrong next time, finds the keys it wrote already present, and rule 1 routes
|
|
every one of them to `kept` - so `moved` is empty, this loop sees nothing,
|
|
`ok` comes back True and the marker is stamped on a migration that has
|
|
reached no readable key. The measurement is in the comment on the loop
|
|
itself. `test_maintenance.py` section 9c calls this twice with the SAME
|
|
wrong `unit` over one store, which is the shape nothing in the suite had.
|
|
|
|
`roster=None` still means "not graded", and SAFETY-44 is what it cost. This
|
|
paragraph used to end here, saying that the one caller passing it was
|
|
`migrate_unit_keys()`, "which runs BEFORE `build_roster()` ... so the keys
|
|
have to be in place before there is a roster to check against", and calling
|
|
that gap real. THE FIRST HALF IS TRUE AND THE CONCLUSION IS NOT. The keys do
|
|
have to be written before the `Inverter`s restore from them - but the NAMES
|
|
do not come from the `Inverter`s. `roster_names(opts)` reads them straight
|
|
off the options, before anything is constructed, which is the whole reason
|
|
it exists (`MqttPublisher` is built before the roster too). So the argument
|
|
that could have graded that write was available at the call site the entire
|
|
time, and the sibling function three screens down had already been fixed by
|
|
passing it.
|
|
|
|
⚠️ SO NO CALLER PASSES `None` TODAY, and this branch is kept for one reason:
|
|
it is what a NEW caller gets by DEFAULT. A future migration that forgets the
|
|
roster gets the ungraded read-back and stamps its marker over a namespace no
|
|
machine reads - silently, exactly as this one did. That is the shape of both
|
|
defects, and the gap is recorded as a running check rather than left to a
|
|
comment: test_maintenance.py section 13, and test_rehearse.py part 5, which
|
|
drives it end-to-end through the harness an operator actually runs.
|
|
"""
|
|
for target, value in rep.moved.items():
|
|
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
|
if got == value:
|
|
continue
|
|
rep.verified = False
|
|
note = f"{target} was written as {value!r} and reads back as {got!r}"
|
|
rep.problems.append(note)
|
|
legacy = target.rsplit("/", 1)[-1]
|
|
if value != DEFAULTS.get(legacy):
|
|
rep.lost.append(note)
|
|
|
|
if roster is None:
|
|
return
|
|
names = [str(n) for n in roster]
|
|
rep.roster = names
|
|
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
|
|
# through the same spelling the writer used rather than re-deriving it.
|
|
#
|
|
# ⚠️ `moved` UNION `kept`, AND THE UNION IS THE WHOLE OF THE SECOND FIX
|
|
# ROUND. Grading only `moved` made the refusal last EXACTLY ONE START.
|
|
# Measured at Controller level over one persisted store, roster
|
|
# ["garage", "shed"], flat `last_completed` 2026-08-07, with the call site
|
|
# still handing `inverter-a` on both starts:
|
|
#
|
|
# construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
|
|
# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
|
|
#
|
|
# On start 2 the four keys the WRONG start 1 wrote are already present, so
|
|
# rule 1 routes every one of them to `rep.kept`, `rep.moved` is EMPTY, this
|
|
# loop iterates over nothing, `misdirected` is empty and `ok` is True - so
|
|
# the caller stamps the marker and start 3 takes the `from_version >=
|
|
# SCHEMA` early return FOREVER, including after the call site is fixed.
|
|
# Loud-and-recoverable became silent-and-permanent after one restart: the
|
|
# exact state this module's header says must never happen, reached by the
|
|
# fix that was written to prevent it.
|
|
#
|
|
# The union is sound by construction rather than by hope: every entry in
|
|
# both `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from
|
|
# the SAME `unit` argument, so grading `kept` grades the same namespace
|
|
# decision `moved` does. A correct call site puts its `kept` entries under a
|
|
# roster name and they pass; a wrong one cannot launder its namespace by
|
|
# having already written it.
|
|
for target in sorted(set(rep.moved) | set(rep.kept)):
|
|
parts = target.split("/")
|
|
unit = parts[1] if len(parts) > 2 else ""
|
|
if unit in names:
|
|
continue
|
|
rep.verified = False
|
|
note = (f"{target} was moved under {unit!r}, which is not in the "
|
|
f"inverter list {names!r}, so nothing reads it")
|
|
rep.problems.append(note)
|
|
rep.misdirected.append(note)
|
|
|
|
|
|
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
|
"""Move the flat maintenance history into the FIRST pack's namespace.
|
|
|
|
MAINT-05. Until this card there was one `Maintenance` and one history, and
|
|
`store.py`'s own header argued the keys should stay flat because per-pack
|
|
scheduling was a card of its own. This is that card: there is now one FSM
|
|
per roster entry, so there is one history per roster entry, and the flat one
|
|
belongs to the pack that was being recalibrated - `inverters[0]`, which is
|
|
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
|
|
against.
|
|
|
|
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
|
|
flat keys ARE that pack's namespace (§6.2's identity, the same one
|
|
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
|
|
them directly, and a migration would add four keys plus a marker to a store
|
|
that must come out byte-identical. The migration happens on the day the
|
|
roster grows, which is the day the flat spelling stops being able to answer
|
|
"which pack".
|
|
|
|
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
|
|
the second is the one MAINT-05 was rejected over.
|
|
|
|
The small one: `n_units` was `len(self.inverters)` at the only call site, so
|
|
the roster carries it - `len(roster)` - and a count passed beside the names
|
|
is a second opinion about how many packs there are.
|
|
|
|
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
|
|
pack the flat keys belonged to and that is genuinely a decision - the first
|
|
roster entry, because that is the pack the one FSM was driving - but a
|
|
decision the caller can get WRONG, and until this signature existed nothing
|
|
could tell. `_verify_moves()` read back the namespace it was handed, so a
|
|
migration into a name no machine reads verified clean and stamped the
|
|
marker. Handing the roster in means the read-back is graded against the
|
|
keys the MACHINES read, and a `unit` that is not among these names comes
|
|
back `misdirected`: not `ok`, marker NOT written, migration runs again next
|
|
start - and again the start after that, because `_verify_moves()` grades
|
|
`kept` as well as `moved`. See `_verify_moves()` for the install this was
|
|
measured on, and for what grading `moved` alone cost on start 2.
|
|
|
|
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
|
|
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
|
|
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
|
|
read-back postcondition, and the marker is written LAST so a crash before it
|
|
leaves the migration to run again - which rule 1 makes a no-op.
|
|
|
|
⚠️ AND RULE 2 IS KEPT LIVE HERE TOO, WHICH IS THE HALF DEV-02 HAD TO ADD
|
|
LATER. "Left in place" alone makes the flat history a fossil dated the day
|
|
the second inverter was declared: an operator who rolls back to a
|
|
single-FSM add-on would find a `last_completed` months old and recalibrate a
|
|
pack that does not need it, or - worse - a stale `phase` and a
|
|
`phase_started` old enough to abort on the 12 h timeout the moment it comes
|
|
up. So `maintenance.UnitHistory` MIRRORS the first pack's writes back onto
|
|
the flat keys for as long as the roster has more than one entry. The flat
|
|
pair is the ROLLBACK VIEW of the history, maintained, exactly as
|
|
`sign_tripped` is the rollback view of the latch.
|
|
"""
|
|
rep = MigrationReport()
|
|
data = getattr(store, "data", {})
|
|
names = [str(n) for n in roster]
|
|
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
|
if len(names) <= 1:
|
|
rep.skipped = ("one inverter, so it is read where it is and nothing "
|
|
"moves")
|
|
return rep
|
|
if rep.from_version >= MAINT_SCHEMA_VERSION:
|
|
rep.skipped = (f"already at version {rep.from_version}, written for "
|
|
f"several inverters, so it is left as it is")
|
|
return rep
|
|
|
|
rep.ran = True
|
|
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
|
|
_verify_moves(store, rep, roster=names)
|
|
|
|
# ⚠️ `rep.ok` AND NOTHING ELSE - NOT `ok or kept`, NOT `not lost`, NOT
|
|
# `not misdirected`. BOTH guards gate this line and each one is pinned by a
|
|
# check that the other cannot satisfy: section 9c refuses a store whose
|
|
# failure is `misdirected` with `lost` empty, and section 9d refuses one
|
|
# whose failure is `lost` with `misdirected` empty AND `kept` non-empty -
|
|
# the mutation `if rep.ok or rep.kept:` stamps the marker on a migration
|
|
# that lost a month of history, and until section 9d existed all 19 suites
|
|
# were green on it.
|
|
if rep.ok:
|
|
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
|
else:
|
|
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
|
|
# `migrate_unit_keys()` carries: the one thing worse than a failed
|
|
# migration is a failed migration a later version skips. Rule 1 makes
|
|
# the re-run a no-op where the keys did land, so refusing the marker
|
|
# costs one repeated pass and buys the difference between a migration
|
|
# that can still be fixed and one the store says already happened.
|
|
_LOG.error("Moving the maintenance history to one part per inverter "
|
|
"did not work, so it is not marked done: %s", rep.explain())
|
|
return rep
|
|
|
|
|
|
STATE_PATH = "/data/state.json"
|
|
|
|
|
|
class Store:
|
|
def __init__(self, path: str | None = None):
|
|
self.path = path or STATE_PATH
|
|
self.data = dict(DEFAULTS)
|
|
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
|
|
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
|
|
self.fresh = False
|
|
self.load()
|
|
|
|
def load(self) -> None:
|
|
try:
|
|
with open(self.path, "r", encoding="utf-8") as fh:
|
|
loaded = json.load(fh)
|
|
if isinstance(loaded, dict):
|
|
self.data = {**DEFAULTS, **loaded}
|
|
_LOG.info("state restored: phase=%s last_completed=%s "
|
|
"sign_tripped=%s",
|
|
self.data.get("phase"), self.data.get("last_completed"),
|
|
self.data.get("sign_tripped"))
|
|
except FileNotFoundError:
|
|
self.fresh = True
|
|
_LOG.info("no saved state, starting fresh")
|
|
except (json.JSONDecodeError, OSError) as err:
|
|
# A corrupt state file must not stop the controller: losing the
|
|
# maintenance history is recoverable, refusing to run is not.
|
|
_LOG.warning("state unreadable (%s) - starting fresh", err)
|
|
|
|
def save(self) -> None:
|
|
# Atomic replace: a power cut mid-write must not leave a truncated file
|
|
# that reads as "no maintenance ever ran".
|
|
try:
|
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
json.dump(self.data, fh, indent=1)
|
|
os.replace(tmp, self.path)
|
|
except OSError as err:
|
|
_LOG.error("could not persist state: %s", err)
|
|
|
|
# -- typed helpers ------------------------------------------------------
|
|
def get_time(self, key: str):
|
|
raw = self.data.get(key)
|
|
if not raw:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(raw)
|
|
except (TypeError, ValueError):
|
|
_LOG.warning("unparseable timestamp for %s: %r", key, raw)
|
|
return None
|
|
# Anything that ever escaped without an offset is treated as UTC rather
|
|
# than raising later in an arithmetic expression.
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
|
|
def set_time(self, key: str, when: datetime | None) -> None:
|
|
self.data[key] = when.astimezone(timezone.utc).isoformat() if when else None
|
|
self.save()
|
|
|
|
def set(self, key: str, value) -> None:
|
|
self.data[key] = value
|
|
self.save()
|