checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.
* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
and `_verify_moves()`, not restated - on its own marker, because unlike
DEV-02's it runs only where the roster has more than one pack. The flat keys
are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
the packs' own stored phases, never persisted, so a restart mid-drain
re-derives it. Several packs due in the same hour: the slot goes to the one
that has gone longest without a completed cycle; roster order breaks a
numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
stamp `last_start_attempt` and does not move the phase, so neither the 12 h
drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
the roster now, as its own comment said it must once `maint_unit` stopped
being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
migration does not run, the four cycle events are the shipped strings, the
banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
claim, so below the pin the allocator refuses the split and the site parks at
0 W. Pre-existing at 2d025ee. Pinned as a running check.
Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
473 lines
24 KiB
Python
473 lines
24 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,
|
|
"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")
|
|
|
|
|
|
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 = []
|
|
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
|
self.skipped = ""
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
"""Nothing that CARRIED STATE failed to land."""
|
|
return not self.lost
|
|
|
|
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"store already at schema "
|
|
f"{self.from_version}; no "
|
|
f"per-inverter migration needed")
|
|
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
|
f"namespaces: " + ", ".join(sorted(self.moved)))
|
|
if self.kept:
|
|
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
|
|
if self.problems:
|
|
out += " - PROBLEMS: " + "; ".join(self.problems)
|
|
if self.lost:
|
|
out += " - STATE LOST: " + "; ".join(self.lost)
|
|
return out
|
|
|
|
|
|
def migrate_unit_keys(store, unit: str) -> 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.
|
|
|
|
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)
|
|
|
|
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("per-inverter store migration LOST STATE and has not been "
|
|
"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) -> 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.
|
|
"""
|
|
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)
|
|
|
|
|
|
def migrate_maint_keys(store, unit: str, n_units: int) -> 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.
|
|
|
|
⚠️ `n_units <= 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".
|
|
|
|
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", {})
|
|
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
|
if n_units <= 1:
|
|
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
|
|
"history and are read in place, so there is nothing to "
|
|
"move and the store layout does not change")
|
|
return rep
|
|
if rep.from_version >= MAINT_SCHEMA_VERSION:
|
|
rep.skipped = (f"maintenance history already at schema "
|
|
f"{rep.from_version}; written by a multi-inverter "
|
|
f"version and not touched")
|
|
return rep
|
|
|
|
rep.ran = True
|
|
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
|
|
_verify_moves(store, rep)
|
|
|
|
if rep.ok:
|
|
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
|
else:
|
|
_LOG.error("per-pack maintenance-history migration LOST STATE and has "
|
|
"not been marked done: %s", rep.explain())
|
|
return rep
|
|
|
|
|
|
class Store:
|
|
def __init__(self, path: str = "/data/state.json"):
|
|
self.path = path
|
|
self.data = dict(DEFAULTS)
|
|
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:
|
|
_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()
|