checks / unit (pull_request) Successful in 4m15s
Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.
WHAT THE SCALARS WERE SILENTLY ASSERTING
dev_min/dev_max one pair, read from one setpoint_entity. A second
inverter's rating was NEVER READ AT ALL, so its clamp, its
freeze rail and the peak guard's achievable_w were the
first one's numbers.
soc one reading into the maintenance FSM, SAFETY-24's pack
floor and arbitrage's bounds. An average lets the emptier
pack be driven below its own floor.
sign_wd ONE SignWatchdog, whose own docstring says "one instance
per actuator", latched under FLAT store keys - so a trip
caused by B latched A across restarts and one operator
press cleared both.
last_beat one deadline and one serialised hass.call(), so a hung
write to A ate B's 30 s layer-1 budget.
actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:
gain one unit's power the site sum
0.2 510 W 0 W
0.4 250 W 0 W
0.6 50 W 0 W
That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.
THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.
HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.
live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.
AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.
EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.
INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
282 lines
13 KiB
Python
282 lines
13 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. They live on here as the pre-DEV-02 layout
|
|
# that `migrate_unit_keys()` reads ONCE and never reads again. Nothing in the
|
|
# add-on writes them any more; see SCHEMA_VERSION.
|
|
DEFAULTS = {
|
|
"phase": "idle",
|
|
"phase_started": None,
|
|
"last_completed": None,
|
|
"last_start_attempt": None,
|
|
"sign_tripped": False,
|
|
"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 NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
|
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
|
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
|
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
|
# its own, not something to infer from a key rename. Moving them here would
|
|
# quietly re-home an install's whole maintenance history under a unit name; they
|
|
# stay where they are, untouched, which is the other half of "migrates without
|
|
# losing its latch OR ITS HISTORY".
|
|
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
|
|
|
|
|
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 = []
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
"""Nothing that CARRIED STATE failed to land."""
|
|
return not self.lost
|
|
|
|
def explain(self) -> str:
|
|
if not self.ran:
|
|
return (f"store already at schema {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 the
|
|
live latch - the flat one is the fossil. Preferring the fossil would
|
|
resurrect a trip the operator has already cleared.
|
|
2. THE FLAT KEYS ARE LEFT IN THE FILE. 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. They go
|
|
stale from this moment - nothing writes them again - and the direction
|
|
that staleness fails in is a SPURIOUS latch (a 0 W hold an operator
|
|
clears), never a missing one.
|
|
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
|
|
for legacy in LEGACY_UNIT_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.
|
|
value = data.get(legacy, DEFAULTS.get(legacy))
|
|
store.set(target, value)
|
|
rep.moved[target] = value
|
|
|
|
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
|
# is the difference between this function 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", through the same `unit_key()` the watchdog's constructor
|
|
# reads through, and a `False` there is a guard that is off on an install
|
|
# whose state file says it is on.
|
|
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 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
|
|
|
|
|
|
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()
|