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>
285 lines
12 KiB
Python
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
|