Files
adminandClaude Opus 5 594f8f9fc9 Six silent failures found by installing this on a live system
Migrated the reference site off the YAML packages and onto the add-on. Every
bug below presented identically: the add-on starts, logs "started", serves its
UI, and cannot do its job.

- run.sh needs #!/usr/bin/with-contenv sh. s6-overlay sanitises the environment
  for services, so a plain shebang means SUPERVISOR_TOKEN is absent and every
  Core API call is 401 - while homeassistant_api: true makes permissions look
  granted. Startup now prints the token length and probes the API.
- Supervisor keys the image by config.yaml `version`, so rebuilding without a
  bump reuses the old image. Two fixes appeared not to work because of it.
- Alpine is musl and has no aiohttp wheel on PyPI; deps now come from apk so
  nothing compiles on a client's Pi.
- Alpine ships paho-mqtt 1.x, which has no CallbackAPIVersion. That raised at
  construction and took the control loop down with it - so MQTT setup is now
  wrapped too. Observability must never be able to stop the controller.
- MQTT discovery is published from on_connect: paho silently drops QoS-0
  publishes issued before the CONNACK, so the previous code announced nothing
  while logging "MQTT connected".
- Repeated failures now log once a minute. Six warnings a second rolled the log
  buffer and destroyed the startup diagnostics needed to find the 401.
- auto_start could never fire, because the store's defaults always supplied
  auto: False for the fallback to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:25:58 +02:00

87 lines
3.3 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.
DEFAULTS = {
"phase": "idle",
"phase_started": None,
"last_completed": None,
"last_start_attempt": None,
}
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()