Files
goodwe-addon/goodwe_controller/app/hass.py
T
Claude c9ab48d6fc UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
  app/uilayout.py through the new app/uipage.py: search, six collapsible
  groups with "n to fix / n to check / n changed" badges and an "applies
  after a restart" chip, radio cards, segmented choices, sliders with units
  (the peak limit in kW), entity pickers with search and the live reading,
  inverter cards, solar rows, locked measured values behind "Change", and
  the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
  config/save; uimodel turns it into options before the shipped validator,
  so nothing it refuses is saved. No route added. A value equal to the
  stored one is written as stored, so a save that changed nothing writes an
  identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
  as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
  is 0 W; (b) the trade blocks and the price line name the feed's own points
  ("Lowest published price"), the hour bars say "hour average"; (c) the entity
  list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 02:49:58 +02:00

243 lines
10 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", "")
# 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
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.
"""
def __init__(self):
self.down_on = set()
def down(self, channel: str, what: str, detail) -> None:
if not self.down_on:
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
"restarting. The add-on keeps trying.", what)
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.")
OUTAGE = Outage()
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 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"))
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 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