"""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//...`. 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, "<>") 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()