Files
glenn schrooyenandClaude Opus 5.5 71d2d4a37a
checks / unit (pull_request) Successful in 8m22s
SAFETY-61: the control task's death ends the process, after the wind-down
A done-callback on run_control()'s task logs the traceback at the death and
sets `stop`; amain() runs the ordinary wind-down (shutdown() ->
command_site_zero()) and main() exits 70 (EX_SOFTWARE). Normal stops and
run_control()'s refusal path are ignored by the callback (still 0 / 78).

hass.py: a malformed JSON body, a JSON non-object, and an undecodable error
body are an absence / a failed write, not an exception into the loop.

checks() gains a first "control loop" row, red past FIRMWARE_INGEST_VETO_S.

Why exit rather than restart in-process, and what the Supervisor actually
does on exit (restarts only with the per-install Watchdog toggle on, default
off): DECISIONS/SAFETY-61-control-task-death.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:52:42 +02:00

149 lines
6.2 KiB
Python

"""Talking to Home Assistant through the Supervisor proxy.
Add-ons authenticate with SUPERVISOR_TOKEN against http://supervisor/core/api,
so there is no long-lived token to create, store, paste into a config file, or
leak at a client site. That is one of the main reasons this is an add-on.
"""
import logging
import os
import time
import aiohttp
_LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "")
class HomeAssistant:
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
self._last_moan: dict[str, float] = {}
if not self.token:
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
def _moan(self, key: str, msg: str, *args) -> None:
"""Log a recurring failure at most once a minute.
⚠️ The control loop retries every second, so an unthrottled warning here
writes six lines a second forever - which rolls the add-on's log buffer
and destroys exactly the startup diagnostics an installer needs. A fault
that repeats is not more informative for being repeated.
"""
now = time.monotonic()
if now - self._last_moan.get(key, -999) >= 60:
self._last_moan[key] = now
_LOG.warning(msg, *args)
@property
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
async def state(self, entity_id: str) -> dict | None:
"""Full state object, or None if it does not exist.
⚠️ A non-existent entity is not an error anywhere in Home Assistant - it
simply never produces a value. On the reference install two safety
alarms pointed at entity ids that did not exist and were therefore dead
for a day while looking perfectly healthy. So: None here is always
surfaced to the operator, never treated as zero.
"""
if not entity_id:
return None
try:
async with self.session.get(
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
) as resp:
if resp.status == 404:
return None
resp.raise_for_status()
obj = await resp.json()
# SAFETY-61: an undecodable body is an absence like any other failed
# read, never an exception into the control loop.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"read:{entity_id}", "read %s failed: %s", entity_id, err)
return None
if not isinstance(obj, dict):
self._moan(f"shape:{entity_id}", "read %s: not a state object: %.80r",
entity_id, obj)
return None
return obj
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
obj = await self.state(entity_id)
if obj is None:
return None
raw = str(obj.get("state", "")).strip().lower()
if raw in BAD_STATES:
return None
try:
value = float(raw)
except ValueError:
self._moan(f"nan:{entity_id}", "%s is not numeric: %r", entity_id, raw)
return None
return -value if invert else value
async def limits(self, entity_id: str) -> tuple[float | None, float | None, float | None]:
"""(min, max, step) of a number entity, so we never send out of range.
⚠️ Worth the extra call. On the reference install the control range and
the firmware clamp disagreed (±1500 asked, ±500 enforced) and the result
was silent: Home Assistant reported one value while the wire carried
another, with no error anywhere.
"""
obj = await self.state(entity_id)
if obj is None:
return (None, None, None)
attrs = obj.get("attributes", {})
def _f(key):
try:
return float(attrs[key])
except (KeyError, TypeError, ValueError):
return None
return (_f("min"), _f("max"), _f("step"))
async def call(self, domain: str, service: str, data: dict) -> bool:
try:
async with self.session.post(
f"{CORE_API}/services/{domain}/{service}",
headers=self._headers, json=data, timeout=10,
) as resp:
if resp.status >= 400:
body = await resp.text()
# 400 here usually means out-of-range for the entity - the
# value is rejected outright, not clamped. Always log it:
# silently dropped commands are how a controller ends up
# believing something the hardware never did.
self._moan(f"svc:{domain}.{service}",
"service %s.%s rejected (%s): %s",
domain, service, resp.status, body[:200])
return False
return True
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
domain, service, err)
return False
async def set_number(self, entity_id: str, value: float) -> bool:
return await self.call("number", "set_value",
{"entity_id": entity_id, "value": value})
async def mqtt_service(self) -> dict | None:
"""Broker details from the Supervisor, if an MQTT service is available."""
try:
async with self.session.get(
f"{SUPERVISOR_API}/services/mqtt", headers=self._headers, timeout=10
) as resp:
if resp.status != 200:
return None
payload = await resp.json()
return payload.get("data")
except (aiohttp.ClientError, TimeoutError):
return None