Files
goodwe-addon/goodwe_controller/app/drivers.py
T
glenn schrooyenandClaude a1be9c48b3
checks / unit (pull_request) Successful in 24m9s
infra-pin / fresh (pull_request) Successful in 1s
SMA-10: two idle states, hold 0 W or hand the inverter back
A driver now says whether its release() hands the inverter back
(Driver.hands_back; GoodWe is False, so release() stays write(0) and the
Controller never calls it). command_site_release() sits next to
command_site_zero(): the Stop button and shutdown() (so SIGTERM, restart,
control task death and a late config refusal) hand a hand-back unit back,
once, and zero every other unit as before. Safety stops and refusals still
hold 0 W for every unit. Shadow mode releases a hand-back unit. After a
release nothing is written to the unit (heartbeat and cycle included) until
control starts again. release() is bounded by RELEASE_BUDGET_S (= the write
budget), runs concurrently per unit, and a raising or hanging release on one
unit does not stop the others.

The "set to 0 W" sentences for Stop, restart, shadow mode and the stopped
banner are built from the roster (app/handback.py, EN and NL, words from the
driver); a GoodWe-only roster keeps every old literal. A handed-back unit
shows "Back under <its control>" on Home.

Existing test edited: test_messages.py (two classification entries for the
CRITICAL line that moved into a roster builder, and the new release bound).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 18:21:28 +01:00

313 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 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)