Files
goodwe-addon/goodwe_controller/app/hass.py
T
adminandClaude Opus 5.5 d94cb0c263
checks / unit (pull_request) Successful in 36m38s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-112: say plainly that control waits for Home Assistant to start
The Supervisor proxy answers 502 to every add-on read, write and websocket
until Core is RUNNING (proxy.py api()/websocket() -> check_api_state()), so
the add-on cannot control while a slow integration holds HA's start, even
with its entities live. Measured on a safety112 repro stack with the
2026.08.0 proxy code: first write 1.0-4.4 s after RUNNING, 238-300 s after
live readings. Not routed around (DECISIONS/SAFETY-112-...).

While the API does not answer, the first status row is red and is the
banner ("Waiting for Home Assistant to finish starting"), Home shows a
"waiting" state in EN/NL instead of "Needs you now", and the log adds a
"still waiting" line every 5 minutes and the wait's length at the end.
A safety stop, a stalled loop or shadow mode keeps its own headline.
Control, writes, heartbeat and the stand-down are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:07:51 +02:00

285 lines
12 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
from . import messages
_LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "")
# What the Supervisor's proxy answers while Home Assistant restarts.
UNAVAILABLE = (502, 503, 504)
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
class Outage:
"""UX-10: Home Assistant not answering, said in one plain line per outage.
A restart takes the REST API and the meter websocket down together, and
each used to log the library's own text for every entity and service
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
not str"). Now the first channel to fail says it once at WARNING, the raw
text goes to DEBUG, and the last channel to recover says so at INFO.
SAFETY-112: the Supervisor answers 502 for every read, write and
websocket until Home Assistant has finished starting, however live its
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
So an outage also says, every STILL_WAITING_S, that it is still on, and
`waiting()` puts it on the status page.
"""
def __init__(self):
self.down_on = set()
self.since = None # monotonic start of this outage
self._told = None # when the last "still waiting" line went
def down(self, channel: str, what: str, detail) -> None:
now = time.monotonic()
if not self.down_on:
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
"still starting. Control waits and resumes by itself.",
what)
self.since = self._told = now
elif now - self._told >= STILL_WAITING_S:
self._told = now
_LOG.info("Still waiting for Home Assistant to finish starting, "
"%s so far.", messages.duration(now - self.since))
self.down_on.add(channel)
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
def up(self, channel: str) -> None:
if channel in self.down_on:
self.down_on.discard(channel)
if not self.down_on:
_LOG.info("Home Assistant answers again after %s.",
messages.duration(time.monotonic() - self.since))
def waiting(self) -> bool:
"""True while Home Assistant's API does not answer. The API, not the
meter websocket: every battery reading and every write rides on it."""
return "api" in self.down_on
OUTAGE = Outage()
class ReadOnly(RuntimeError):
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
class HomeAssistant:
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
*, read_only: bool = False):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
# UX-15: setup-only mode reads Home Assistant and calls no service.
# Fixed at construction; `call()` refuses before it sends anything.
self._read_only = bool(read_only)
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 in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read {entity_id}: {resp.status}")
return None
OUTAGE.up("api")
if resp.status == 404:
return None
resp.raise_for_status()
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read {entity_id}: {err}")
return None
# 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 states(self) -> list | None:
"""Every entity's state object (`GET /api/states`), or None when Home
Assistant does not answer. A read only (UX-13 entity picker)."""
try:
async with self.session.get(
f"{CORE_API}/states", headers=self._headers,
timeout=STATES_TIMEOUT_S
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read all states: {resp.status}")
return None
OUTAGE.up("api")
resp.raise_for_status()
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read all states: {err}")
return None
# A timeout's own text is empty, so the line names the wait instead
# (UX-13 P1 test, c).
except TimeoutError:
self._moan("read:*", "Reading the entity list failed: no answer "
"within %s seconds.", STATES_TIMEOUT_S)
return None
except (aiohttp.ClientError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s",
str(err) or type(err).__name__)
return None
if not isinstance(obj, list):
self._moan("shape:*", "The entity list is not a list: %.80r", 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"))
@property
def read_only(self) -> bool:
return self._read_only
async def call(self, domain: str, service: str, data: dict) -> bool:
if self._read_only:
# Every service call, `number.set_value` included, goes through
# here, so setup-only mode has no way to command (DECISIONS UX-15).
_LOG.error("Setup-only mode called no service, because it may not "
"command anything.")
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
try:
async with self.session.post(
f"{CORE_API}/services/{domain}/{service}",
headers=self._headers, json=data, timeout=10,
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"service {domain}.{service}: {resp.status}")
return False
OUTAGE.up("api")
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.
# UX-10: plain words. While Home Assistant loads its
# entities it answers 400 with only "400: Bad Request",
# which says nothing, so only a body with more is quoted.
said = body.strip()[:200]
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
""):
said = "no details"
entity = (data.get("entity_id", "no entity")
if isinstance(data, dict) else "no entity")
self._moan(f"svc:{domain}.{service}",
"Home Assistant refused %s.%s for %s (error %s, "
"%s). The entity may still be loading, or the "
"value is out of range.", domain, service,
entity, resp.status, said)
return False
return True
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection",
f"service {domain}.{service}: {err}")
return False
# 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