Packaged as a Home Assistant add-on, with a field guide

Turns the reference RS485 controller into something a technician can install
at a client site: a typed config form instead of YAML, an ingress UI that
names misconfiguration in words, and persistent state that cannot be broken by
a timezone.

Why an add-on rather than YAML packages or blueprints:

- Blueprints cannot create helpers, and the maintenance cycle is a state
  machine whose phase and completion date must survive restarts.
- YAML packages need filesystem access, a configuration.yaml edit and a
  restart - none of which belong in a client install.
- Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token
  to generate, store or leak on someone else's machine.
- Requires HA OS/Supervised. Container and Core installs cannot run add-ons at
  all, which is a market decision, not an oversight.

The control law and the maintenance machine are pure functions with no Home
Assistant imports, and both ship with runnable checks (22 and 22 assertions).
Every assertion corresponds to a rule whose absence caused an observed failure
on hardware - the saturation duration term, the clamp-before-slew ordering, the
deadband, the sign convention.

One behaviour deliberately differs from the implementation it replaces: when
its inputs go missing this commands 0 W rather than replaying the last
setpoint. The reference version kept replaying, which the hardware watchdog
cannot catch - from the ESP32's side, Home Assistant is still talking to it.

Includes the ESPHome firmware (now parameterised: node name, inverter rating,
watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the
commissioning gates, all judged on the wire rather than on how Home Assistant
looks, plus the written statement a site without an e-stop needs signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
This commit is contained in:
2026-08-23 01:15:25 +02:00
co-authored by Claude Opus 5
commit 0a1e61dbc9
24 changed files with 2737 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
"""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 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", "")
if not self.token:
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
@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()
return await resp.json()
except (aiohttp.ClientError, TimeoutError) as err:
_LOG.warning("read %s failed: %s", entity_id, err)
return None
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:
_LOG.warning("%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.
_LOG.error("service %s.%s rejected (%s): %s",
domain, service, resp.status, body[:200])
return False
return True
except (aiohttp.ClientError, TimeoutError) as err:
_LOG.warning("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