SMA-2: each inverter is reached through a driver; GoodWe behaviour unchanged
checks / unit (pull_request) Successful in 23m14s
infra-pin / fresh (pull_request) Successful in 2s

app/drivers.py holds one Driver per roster entry (configured, actuator_id,
limits, read, write, release, probe, link_stale_s, batt_bound, settle_s, brand,
words). GoodWeEsphomeDriver makes the calls the code it replaced made, in the
same order. Inverter gets driver; has_writer, is_live and link_note read it. The
SoC/power read, the write, the device range (run_control, amain, the settings
panel), rule 2 per unit and the SAFETY-28 census go through it.

setpoint_entity is optional per entry beside driver, host, port, unit_id and
maintenance; only goodwe_esphome is selectable, the rest are checked and unused.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-07 11:28:47 +01:00
co-authored by Claude
parent 23ff9d0920
commit e56eeef8f0
18 changed files with 1238 additions and 65 deletions
+30 -13
View File
@@ -71,6 +71,7 @@ from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .drivers import GoodWeEsphomeDriver
from .hass import CORE_API, OUTAGE, HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
@@ -3774,6 +3775,18 @@ class Controller:
elif self.inputs_bad_since is None:
self.inputs_bad_since = datetime.now(timezone.utc)
def _driver(self, inv):
"""`inv`'s driver, wired to this Controller's Home Assistant on first use.
SMA-2. Bound HERE rather than when the roster is built, so a unit added
to `self.inverters` afterwards is wired too, and the driver follows
whatever `self.hass` is at the time (a check may swap it).
"""
drv = inv.driver
if not drv.bound():
drv.bind_getter(lambda: self.hass)
return drv
async def _read_unit(self, inv, now_mono: float) -> None:
"""One inverter's own SoC and battery power, from ITS OWN entities.
@@ -3793,8 +3806,10 @@ class Controller:
second spelling of "what is standing" would let the two disagree about
the number they are both reasoning about.
"""
inv.soc = await self.hass.number(inv.soc_entity)
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
# SMA-2. The driver reads, in the order this method always did: SoC,
# then power. `extra` is whatever else the driver learned; nothing
# consumes it yet.
inv.soc, raw, _extra = await self._driver(inv).read()
was_stale = inv.batt_fresh.stale
was_releases = inv.batt_fresh.releases
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
@@ -4029,9 +4044,9 @@ class Controller:
for inv in targets))
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
entity = inv.setpoint_entity
if not entity:
if not inv.driver.configured():
return
entity = inv.driver.actuator_id() # for the log line below
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
# Every write in this add-on comes through here - the cycle, the
@@ -4154,7 +4169,7 @@ class Controller:
# tick by itself: `last_write` is updated only on an ACCEPTED write, so
# `changed` is still true.
try:
ok = await asyncio.wait_for(self.hass.set_number(entity, value),
ok = await asyncio.wait_for(self._driver(inv).write(value),
timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
@@ -6730,9 +6745,8 @@ class Controller:
# Concurrently, for `read_inputs()`'s reason: a slow ESPHome on one unit
# must not delay the controller's start on every other.
async def _limits(inv):
if inv.setpoint_entity:
inv.dev_min, inv.dev_max, _ = await self.hass.limits(
inv.setpoint_entity)
if inv.driver.configured():
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
entity = self.o.get("setpoint_entity", "")
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
@@ -7316,9 +7330,10 @@ class Controller:
rows = []
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
rows.append((messages.LABEL_SOC, unit, inv.soc, inv.soc_entity))
rows.append((messages.LABEL_SOC, unit, inv.soc,
inv.driver.soc_source()))
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
inv.batt_entity))
inv.driver.batt_source()))
if not self.p1_enabled:
# In P1 mode the check above replaces this one; leaving both in
# would report "no entity configured" for a meter_entity that is
@@ -7360,9 +7375,8 @@ class Controller:
"text": messages.row_sensor(label, value, unit)})
for inv in self.inverters:
sp = inv.setpoint_entity
unit = None if len(self.inverters) == 1 else inv.name
if not sp:
if not inv.driver.configured():
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_unset(unit)})
elif inv.dev_max is None:
@@ -8205,10 +8219,13 @@ async def amain() -> None:
# validator cannot do for itself; (None, None) when ESPHome has not come
# up yet is not a refusal - run_control() re-checks once the real rating
# is known. See validate.py for where the line is drawn and why.
# SMA-2. The flat options are the one-inverter shape, so it is the
# GoodWe driver's, asked without a Controller (none exists yet).
_sp = str(opts.get("setpoint_entity", "") or "").strip()
_drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass)
_dev_min = _dev_max = None
if _sp:
_dev_min, _dev_max, _ = await hass.limits(_sp)
_dev_min, _dev_max = await _drv.limits()
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
_problems = validate_startup_config(opts, _dev_min, _dev_max)
# UX-15: a new install names no inverter. When that is the ONLY