"""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") DEFAULTS = { "phase": "idle", "phase_started": None, "last_completed": None, "last_start_attempt": None, "auto": False, } 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", self.data.get("phase"), self.data.get("last_completed")) 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()