Files
goodwe-addon/goodwe_controller/app/drivers.py
T
glenn schrooyenandClaude d26cc573fe
checks / unit (pull_request) Successful in 23m37s
infra-pin / fresh (pull_request) Successful in 2s
SMA-10 round 2: a failed release sends 0 W once, retries at heartbeat pace
When release() fails (False, raises or hangs past RELEASE_BUDGET_S) the unit
gets one bounded write(0) through the new Driver.release_fallback() hook
(default write(0)), then nothing more, so the inverter's own fallback timer is
not reset. The release is retried once per heartbeat, not on every idle
write. One error line and one action-log event per failure streak, one line on
recovery. Dutch default "terug naar de eigen sturing". DECISIONS says what a
unit is left at.

test_messages.py: the RELEASE_BUDGET_S data entry is gone again (the line
that needed it is gone).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 20:17:01 +01:00

321 lines
12 KiB
Python

"""The inverter driver seam (SMA-2): how this add-on reads and steers ONE inverter.
Before this, every place that read or steered an inverter talked to the GoodWe
ESPHome entities itself (`hass.limits`, `hass.number`, `hass.set_number`, the
SAFETY-28 census, selfconfig, validate), so a second brand meant touching all of
them. Now each `Inverter` holds a `Driver`, and those places ask it. This card
is a pure refactor: `GoodWeEsphomeDriver` does exactly what the code it replaced
did, in the same order, with the same arguments. DECISIONS/SMA-2-driver-seam.md.
THE SEAM IS NOT TCP-SHAPED. Nothing here assumes a connection, or one connection
per host: a later FoxESS unit is RTU framing over a TCP socket, and its
`actuator_id()` is host:port:unit_id, so two units behind one gateway are two
actuators and one unit named twice is the SAFETY-28 refusal.
WHAT STAYS OUTSIDE THE DRIVER, on purpose. The gate (SAFETY-28), the clamp
(SAFETY-03), shadow mode and the write budget all sit above `write()` in
`Controller._write_unit`, and the freshness gate sits above `read()`; a driver
never sees a value those did not pass. A driver moves one number to or from one
inverter and says whether it worked.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .control import PLANT_SETTLE_S
from .freshness import BOUNDS
# How long every write to one GoodWe inverter must have been REJECTED before
# this controller stops claiming it can command it. Layer 1's own window: the
# ESP32 commands 0 W after ~30 s without a refresh (`validate.
# FIRMWARE_INGEST_VETO_S`). `inverter.LINK_STALE_S` is this number, re-exported.
GOODWE_LINK_STALE_S = 30.0
# `driver` when an entry does not say: every install that exists today.
DEFAULT_DRIVER = "goodwe_esphome"
# The `driver` values the options accept. Only the GoodWe one for now; the
# registry below may hold more (a test registers a fake), but this tuple is
# what validate.py rule 9 lets through and what config.yaml's schema lists.
SELECTABLE = (DEFAULT_DRIVER,)
class DriverNotBound(RuntimeError):
"""A driver was asked to talk to the world before it was given a Home
Assistant. A wiring bug, so it is loud rather than a quiet "no answer"."""
@dataclass(frozen=True)
class Probe:
"""What a read-only look at one inverter found. None means "did not answer"."""
dev_min: float | None = None
dev_max: float | None = None
soc: float | None = None
batt_w: float | None = None
extra: dict = field(default_factory=dict)
def census_key(actuator_id: str) -> str:
"""The form SAFETY-28 compares: stripped and lowercase, so a config cannot
name one actuator twice by changing its case (`Number.X` vs `number.x`)."""
return str(actuator_id or "").strip().lower()
class Driver:
"""One inverter's I/O. Subclasses set the class attributes and the methods.
Constructed once per roster entry, with no Home Assistant: `bind()` gives
it one (the Controller does, and so does the settings panel, which has no
Controller). Everything that talks to the world is a coroutine.
"""
#: the `driver` option value that selects this class
name = ""
#: the option key that names the actuator in an entry (the census reads it)
writer_key = ""
#: the brand word the per-driver sentences and device names use
brand = ""
#: SMA-10. Does `release()` give the inverter BACK to something that steers
#: it by itself (SMA energy management)? False: `release()` is `write(0)`,
#: the add-on goes on holding 0 W, and the Controller never calls it.
hands_back = False
#: per-driver sentences. A driver that hands back says whose control the
#: unit returns to: `{"back": "SMA control", "back_nl": "de sturing van SMA"}`
#: ("goes back to SMA control"). Missing words fall back to a plain phrase.
words: dict = {}
#: how long all-rejected writes last before the unit stops being live
link_stale_s = GOODWE_LINK_STALE_S
#: this driver's `freshness.Bound` for the battery power reading
batt_bound = BOUNDS["batt"]
#: the plant's settling time after a step, in s (control.PLANT_SETTLE_S)
settle_s = PLANT_SETTLE_S
# -- from the options ----------------------------------------------------
@classmethod
def from_entry(cls, entry: dict) -> "Driver":
raise NotImplementedError
@classmethod
def actuator_id_of(cls, entry: dict) -> str:
"""The actuator an options entry names, or "" for none. Pure: the
SAFETY-28 census runs on options, before any driver exists."""
raise NotImplementedError
# -- wiring --------------------------------------------------------------
def bind(self, hass) -> "Driver":
"""Give this driver its Home Assistant (or whatever it talks through)."""
return self
def bind_getter(self, getter) -> "Driver":
"""Like `bind()`, but looked up at each use: the Controller passes
`lambda: self.hass`, so replacing `controller.hass` is seen at once."""
return self
def bound(self) -> bool:
"""Has it been given what it talks through? A driver that talks
through nothing the Controller owns is always bound."""
return True
# -- what it is ----------------------------------------------------------
def configured(self) -> bool:
"""Is there any way to reach this inverter at all?"""
raise NotImplementedError
def actuator_id(self) -> str:
"""What this driver writes to. Two units with one id are one actuator."""
raise NotImplementedError
def soc_source(self) -> str:
"""Where the pack level is read from; "" when nowhere. Status rows only."""
return ""
def batt_source(self) -> str:
"""Where the battery power is read from; "" when nowhere."""
return ""
# -- what it does --------------------------------------------------------
async def limits(self) -> tuple:
"""`(dev_min, dev_max)` in W the device accepts; (None, None) unread."""
raise NotImplementedError
async def read(self) -> tuple:
"""`(soc, batt_w, extra)`. A value that did not arrive is None. `batt_w`
is the RAW power, in the sign the controller uses; the freshness gate
is the caller's."""
raise NotImplementedError
async def write(self, w: float) -> bool:
"""Put `w` on the inverter. True only when it took it."""
raise NotImplementedError
async def release(self) -> bool:
"""Stop steering this inverter. For a driver with nothing better, 0 W.
SMA-10. Only a driver with `hands_back = True` has this called by the
Controller, and only when the add-on goes away (Stop, shutdown, restart,
task death) or sits in shadow mode. After it the Controller writes
nothing more to the unit until control starts again. A safety stop is
never a release: that is `write(0)`."""
return await self.write(0.0)
async def release_fallback(self) -> bool:
"""SMA-10 round 2. What to send, ONCE, when `release()` has failed
(False, raised, or hung past the bound), so the unit does not carry its
last command while nothing else steers it. Default: `write(0)`. It is
not repeated: a repeat would keep resetting the inverter's own fallback
timer. A driver for which 0 W is not always a safe hold overrides this."""
return await self.write(0.0)
async def probe(self) -> Probe:
"""A read-only look, usable without a Controller (panel, wizard)."""
raise NotImplementedError
class GoodWeEsphomeDriver(Driver):
"""The GoodWe behind the ESP32: three Home Assistant entities.
A transcription of what `main.py` and `selfconfig.py` did inline: `limits()`
is `hass.limits(setpoint)`, `read()` is `hass.number(soc)` then
`hass.number(batt, invert)`, `write()` is `hass.set_number(setpoint, w)`.
`release()` is `write(0)` and `hands_back` is False: the board's own watchdog
is what hands back, so after Stop the unit keeps its 0 W write and heartbeat.
"""
name = DEFAULT_DRIVER
writer_key = "setpoint_entity"
brand = "GoodWe"
def __init__(self, setpoint_entity="", soc_entity="", batt_entity="",
batt_invert=False):
self.setpoint_entity = str(setpoint_entity or "")
self.soc_entity = str(soc_entity or "")
self.batt_entity = str(batt_entity or "")
self.batt_invert = bool(batt_invert)
self._hass = None
self._getter = None
@classmethod
def from_entry(cls, entry: dict) -> "GoodWeEsphomeDriver":
return cls(setpoint_entity=entry.get("setpoint_entity", ""),
soc_entity=entry.get("soc_entity", ""),
batt_entity=entry.get("batt_entity", ""),
batt_invert=bool(entry.get("batt_invert")))
@classmethod
def actuator_id_of(cls, entry: dict) -> str:
return str(entry.get("setpoint_entity") or "").strip()
def bind(self, hass) -> "GoodWeEsphomeDriver":
self._hass, self._getter = hass, None
return self
def bind_getter(self, getter) -> "GoodWeEsphomeDriver":
self._hass, self._getter = None, getter
return self
def bound(self) -> bool:
return self._hass is not None or self._getter is not None
def _h(self):
hass = self._getter() if self._getter is not None else self._hass
if hass is None:
raise DriverNotBound(f"{self.brand} driver for "
f"{self.setpoint_entity!r} has no Home Assistant")
return hass
def configured(self) -> bool:
return bool(self.setpoint_entity)
def actuator_id(self) -> str:
return self.setpoint_entity
def soc_source(self) -> str:
return self.soc_entity
def batt_source(self) -> str:
return self.batt_entity
async def limits(self) -> tuple:
dev_min, dev_max, _step = await self._h().limits(self.setpoint_entity)
return dev_min, dev_max
async def read(self) -> tuple:
hass = self._h()
soc = await hass.number(self.soc_entity)
raw = await hass.number(self.batt_entity, self.batt_invert)
return soc, raw, {}
async def write(self, w: float) -> bool:
return await self._h().set_number(self.setpoint_entity, w)
async def probe(self) -> Probe:
dev_min, dev_max = await self.limits()
soc, raw, extra = await self.read()
return Probe(dev_min, dev_max, soc, raw, extra)
class UnavailableDriver(Driver):
"""What an entry naming a driver this build does not have gets. Validation
refuses such an entry before a Controller exists; this is only what
`build_roster()` (which refuses nothing) builds meanwhile: a unit that is
not configured, so the refusal branch names it and commands 0 W."""
name = ""
# The census still counts a `setpoint_entity` on such an entry: refusing
# too much is the safe direction for SAFETY-28.
writer_key = "setpoint_entity"
brand = ""
def __init__(self, wanted=""):
self.wanted = str(wanted or "")
@classmethod
def from_entry(cls, entry: dict) -> "UnavailableDriver":
return cls(entry.get("driver"))
@classmethod
def actuator_id_of(cls, entry: dict) -> str:
return str(entry.get("setpoint_entity") or "").strip()
def configured(self) -> bool:
return False
def actuator_id(self) -> str:
return ""
async def limits(self) -> tuple:
return None, None
async def read(self) -> tuple:
return None, None, {}
async def write(self, w: float) -> bool:
return False
async def probe(self) -> Probe:
return Probe()
# name -> class. The options' `driver` value picks one. A test may add a fake;
# `SELECTABLE` (not this) is what the options accept.
REGISTRY = {GoodWeEsphomeDriver.name: GoodWeEsphomeDriver}
def driver_name(entry) -> str:
"""The `driver` an options entry selects; blank or absent is the default."""
raw = entry.get("driver") if isinstance(entry, dict) else None
return str(raw).strip() if raw not in (None, "") else DEFAULT_DRIVER
def driver_class(entry):
"""The class an options entry selects, `UnavailableDriver` if none."""
return REGISTRY.get(driver_name(entry), UnavailableDriver)
def build_driver(entry) -> Driver:
"""The driver for ONE options entry (or the flat options, for one inverter)."""
entry = entry if isinstance(entry, dict) else {}
return driver_class(entry).from_entry(entry)