From e56eeef8f0dc4dc1ace10d89aca2f5507b3b1f7a Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 7 Nov 2026 11:28:47 +0100 Subject: [PATCH] SMA-2: each inverter is reached through a driver; GoodWe behaviour unchanged 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 --- goodwe_controller/CHANGELOG.md | 1 + .../DECISIONS/SMA-2-driver-seam.md | 39 ++ goodwe_controller/app/drivers.py | 299 +++++++++ goodwe_controller/app/inverter.py | 71 +- goodwe_controller/app/main.py | 43 +- goodwe_controller/app/messages.py | 10 + goodwe_controller/app/selfconfig.py | 12 +- goodwe_controller/app/uilayout.py | 13 + goodwe_controller/app/validate.py | 76 ++- goodwe_controller/config.yaml | 21 +- goodwe_controller/test_freshness.py | 2 +- goodwe_controller/test_inverter.py | 16 +- goodwe_controller/test_sma2_driver.py | 626 ++++++++++++++++++ goodwe_controller/test_translations.py | 4 +- goodwe_controller/test_uilayout.py | 4 +- .../tools/supervisor_shape_probe.py | 36 +- goodwe_controller/translations/en.yaml | 15 + goodwe_controller/translations/nl.yaml | 15 + 18 files changed, 1238 insertions(+), 65 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SMA-2-driver-seam.md create mode 100644 goodwe_controller/app/drivers.py create mode 100644 goodwe_controller/test_sma2_driver.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 6ebdc4f..ef51a31 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,7 @@ ### Not in a release yet +- Each inverter is now reached through a driver, so another make can be added later. Nothing changes for a GoodWe site, and the inverter list accepts five new optional settings that nothing reads yet ([[SMA-2]]). - Home now draws today's charge from the grid for every hour the add-on will really charge, not only the run under way ([[UX-77]]). - When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]). - With a 5 s meter, readings 30 s late are now noticed within 100 seconds, not 3 minutes ([[SAFETY-129]]). diff --git a/goodwe_controller/DECISIONS/SMA-2-driver-seam.md b/goodwe_controller/DECISIONS/SMA-2-driver-seam.md new file mode 100644 index 0000000..8981c42 --- /dev/null +++ b/goodwe_controller/DECISIONS/SMA-2-driver-seam.md @@ -0,0 +1,39 @@ +# SMA-2 - each inverter is reached through a driver + +**Status: for review.** Tests: `test_sma2_driver.py`. Source: SMA-9 report +section 1.1. A pure refactor: no behaviour change for GoodWe, no change to a +command, a log line or a sentence the add-on shows. + +Every place that read or steered an inverter used the GoodWe ESPHome entities +directly, so a second make meant touching all of them. `app/drivers.py` now +holds one `Driver` per roster entry (`Inverter.driver`): `configured()`, +`actuator_id()`, `limits()`, `read()`, `write()`, `release()`, `probe()`, and +`link_stale_s`, `batt_bound`, `settle_s`, `brand`, `words`. `GoodWeEsphomeDriver` +makes the same calls in the same order as the code it replaced; its +`release()` is `write(0)`. + +**Moved to the driver:** the SoC and power read (`_read_unit`), the write +(`_write_unit`, still under the SAFETY-28 gate, the clamp, shadow mode and the +write budget), the device range (`run_control`, `amain`, the settings panel), +liveness and its window (`has_writer`, `is_live`, `link_note`), the freshness +bound, the status rows' "is a source set" test, rule 2's per-unit range check +and SAFETY-28's census. **Not moved:** the clamp, the gate, the heartbeat and +`command_site_zero()`; stop is still 0 W for every unit, and nothing calls +`release()` yet (SMA-10). `settle_s` and the control constants are not read +from the driver yet either: with only GoodWe they would change nothing. + +**The seam is not TCP-shaped.** The census asks each entry's driver for its +actuator id: the setpoint entity for GoodWe, `host:port:unit_id` for a driver +reached over a socket, so two units behind one gateway are two actuators and +one unit named twice is the SAFETY-28 refusal. The driver binds its Home +Assistant on first use by the Controller, so a unit added later is wired too. + +**Schema.** `setpoint_entity` is optional per entry (rule 9(d) still requires +it for GoodWe, with the same sentence), beside `driver` (only `goodwe_esphome`), +`host`, `port`, `unit_id`, `maintenance`. The last four are checked for shape +and not read. The Supervisor shape probe was re-run (section K). + +**Checked against 23ff9d0:** replay digests and a log of every Home Assistant +call are byte-identical at N=1 and N=2; the TEL-07 announce diff is 0/0/0; a +fake driver beside a GoodWe unit allocates, grades, drops and publishes per +unit; a call site that goes round the seam is caught. diff --git a/goodwe_controller/app/drivers.py b/goodwe_controller/app/drivers.py new file mode 100644 index 0000000..98d616e --- /dev/null +++ b/goodwe_controller/app/drivers.py @@ -0,0 +1,299 @@ +"""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 = "" + #: per-driver sentences (SMA-10 and later). Empty until a caller needs one. + 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.""" + 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)`: the board's own watchdog is what hands back. + """ + + 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) diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index 1816811..64a7003 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -124,9 +124,12 @@ from datetime import datetime from .clamp import ClampResult from .delivery import DeliveryConfig, DeliveryWatchdog from . import messages +# SMA-2. The driver seam: how this unit's inverter is read and steered. +from .drivers import (GOODWE_LINK_STALE_S, GoodWeEsphomeDriver, + build_driver) # SAFETY-37. The freshness policy, and the ONE place its bounds are written. # freshness.py imports nothing from this package, so the direction is one-way. -from .freshness import BOUNDS, Freshness +from .freshness import Freshness from .idle import IdleReading # CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported # rather than retyped: the flat-keys path below needs `bms_soc_floor`'s @@ -149,7 +152,11 @@ from .watchdog import SignWatchdog, SignWatchdogConfig # decided it will not. No config key: CFG-03 tracks three places a default has # to be kept in step and this does not earn a fourth - it is derived from a # constant the firmware and validate.py already share. -LINK_STALE_S = 30.0 +# +# SMA-2. This is the GoodWe driver's `link_stale_s` (drivers.py), kept under its +# old name for the checks and for main.py. `is_live()` and `link_note()` now ask +# each unit's DRIVER for its window; a driver for another make states its own. +LINK_STALE_S = GOODWE_LINK_STALE_S # The name of the inverter a SINGLE-INVERTER config describes. ⚠️ A NAME, NOT A # CONFIGURATION - it exists so that a per-inverter claim has an actuator to be @@ -195,12 +202,14 @@ class Inverter: def __init__(self, name, *, setpoint_entity="", soc_entity="", batt_entity="", batt_invert=False, max_w=2000.0, bms_soc_floor=None, capacity_kwh=None, store=None, - sign_cfg=None, delivery_cfg=None): + sign_cfg=None, delivery_cfg=None, driver=None): self.name = str(name) - 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) + # SMA-2. HOW THIS UNIT IS READ AND STEERED. The entity keywords above + # are the GoodWe driver's, kept so every existing caller builds the + # same unit; a roster entry that selects another driver passes + # `driver` and the entity keywords stay empty. + self.driver = driver if driver is not None else GoodWeEsphomeDriver( + setpoint_entity, soc_entity, batt_entity, batt_invert) # The supervised cap on what may be written to THIS setpoint entity. # See the module docstring on why this is a number and not a `Tuning`. self.max_w = float(max_w) @@ -256,7 +265,7 @@ class Inverter: # constructed here: SoC's obligation term is energy through the pack, not # command travel, and the constant it needs has not been measured. That # is TEL-08's, and freshness.py's docstring says exactly what it owes. - self.batt_fresh = Freshness(BOUNDS["batt"]) + self.batt_fresh = Freshness(self.driver.batt_bound) # --- what this unit was last told, and what it last accepted --------- # `target` is THIS unit's share of the site target from the last @@ -338,6 +347,25 @@ class Inverter: def idle_w(self) -> float: return self.idle.w + # -- the GoodWe driver's entities, read through for the callers that name + # them (the panel's range check, the status rows, the checks). A driver + # without such an entity answers "" - the same as "not set". + @property + def setpoint_entity(self) -> str: + return getattr(self.driver, "setpoint_entity", "") + + @property + def soc_entity(self) -> str: + return getattr(self.driver, "soc_entity", "") + + @property + def batt_entity(self) -> str: + return getattr(self.driver, "batt_entity", "") + + @property + def batt_invert(self) -> bool: + return bool(getattr(self.driver, "batt_invert", False)) + # -- store --------------------------------------------------------------- def key(self, name: str) -> str: """This unit's namespaced Store key for `name`. See store.unit_key().""" @@ -387,7 +415,7 @@ class Inverter: return out def has_writer(self) -> bool: - return bool(self.setpoint_entity) + return self.driver.configured() # -- liveness ------------------------------------------------------------ def note_write(self, ok: bool, now: datetime) -> None: @@ -398,7 +426,7 @@ class Inverter: elif self.rejected_since is None: self.rejected_since = now - def is_live(self, now: datetime, stale_s: float = LINK_STALE_S): + def is_live(self, now: datetime, stale_s: float | None = None): """`(live, why_not)` - may this controller command this unit THIS TICK? Read the module docstring before widening this - in particular on why a @@ -411,7 +439,11 @@ class Inverter: ⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a link goes stale between one cycle and the next. + + `stale_s` is the driver's `link_stale_s` unless a caller passes one. """ + if stale_s is None: + stale_s = self.driver.link_stale_s if self.rejected_since is not None: held = (now - self.rejected_since).total_seconds() if held >= float(stale_s): @@ -451,9 +483,11 @@ class Inverter: held = (0.0 if self.rejected_since is None else (now - self.rejected_since).total_seconds()) if not live: - return messages.row_not_controllable(self.name, held, LINK_STALE_S) + return messages.row_not_controllable(self.name, held, + self.driver.link_stale_s) if self.rejected_since is not None: - return messages.row_commands_refused(self.name, held, LINK_STALE_S) + return messages.row_commands_refused(self.name, held, + self.driver.link_stale_s) return "" def __repr__(self) -> str: @@ -556,10 +590,8 @@ def build_roster(opts: dict, store, sign_cfg=None, # identically, which is AC 3. return [Inverter( UNIT_A, - setpoint_entity=opts.get("setpoint_entity", ""), - soc_entity=opts.get("soc_entity", ""), - batt_entity=opts.get("batt_entity", ""), - batt_invert=bool(opts.get("batt_invert")), + # SMA-2. The flat keys are the GoodWe shape and only that. + driver=GoodWeEsphomeDriver.from_entry(opts), max_w=_flat_max_w(opts), # SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape # - the one pack the flat options describe is the one pack whose @@ -588,10 +620,9 @@ def build_roster(opts: dict, store, sign_cfg=None, entry = entry if isinstance(entry, dict) else {} roster.append(Inverter( name, - 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")), + # SMA-2. The entry's `driver` (default goodwe_esphome) picks the + # class; the entry itself says where its entities are. + driver=build_driver(entry), # ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did # not state is not the flat one, it is a rating rule 9 refuses. max_w=_entry_max_w(entry), diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 0fc87fb..eb14dd7 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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 diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 831da93..d6d1bbe 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -1683,6 +1683,11 @@ LABELS = { "capacity_kwh": "Usable battery capacity", "inverters": "Inverter list", "name": "Name", + "driver": "Inverter type", + "host": "Inverter address", + "port": "Inverter port", + "unit_id": "Inverter unit number", + "maintenance": "Battery maintenance", "meter_source": "Where the meter reading comes from", "meter_phases": "Number of phases", "p1_host": "HomeWizard P1 address", @@ -1764,6 +1769,11 @@ LABELS_NL = { "capacity_kwh": "Bruikbare capaciteit", "inverters": "Lijst met omvormers", "name": "Naam", + "driver": "Type omvormer", + "host": "Adres van de omvormer", + "port": "Poort van de omvormer", + "unit_id": "Eenheidsnummer van de omvormer", + "maintenance": "Onderhoud van de batterij", "meter_source": "Waar de meterstand vandaan komt", "meter_phases": "Aantal fasen", "p1_host": "Adres van de HomeWizard P1", diff --git a/goodwe_controller/app/selfconfig.py b/goodwe_controller/app/selfconfig.py index 5458253..d5b0498 100644 --- a/goodwe_controller/app/selfconfig.py +++ b/goodwe_controller/app/selfconfig.py @@ -343,10 +343,12 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None): f"disagreement between the two and should be reported."] return found, [] if hass is not None: + # SMA-2. Each unit's driver, bound to the panel's own (read-only) + # Home Assistant: there is no Controller here. async def _limits(unit): - if unit.setpoint_entity: - unit.dev_min, unit.dev_max, _ = await hass.limits( - unit.setpoint_entity) + if unit.driver.configured(): + unit.dev_min, unit.dev_max = await unit.driver.bind( + hass).limits() await asyncio.gather(*(_limits(unit) for unit in units)) dev_min = dev_max = None @@ -357,8 +359,8 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None): problems = list(validate_startup_config(opts, dev_min, dev_max, notes=notes)) problems += validate_unit_ranges(units, skip_entity=flat) - unread = [unit.setpoint_entity for unit in units - if unit.setpoint_entity and unit.dev_min is None + unread = [unit.driver.actuator_id() for unit in units + if unit.driver.configured() and unit.dev_min is None and unit.dev_max is None] return problems, unread diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index 421dac7..d19bf9f 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -302,6 +302,19 @@ DERIVED = { HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"} +# SMA-2. Per-entry keys of the inverter list the page never shows. They pick and +# address an inverter driver, only `goodwe_esphome` is selectable, and nothing +# reads the other four yet. An entry's keys the page does not draw travel +# through a save as stored (uimodel._roster_decode keeps the whole entry). +ROSTER_HIDDEN: dict = { + "driver": "only goodwe_esphome for now; empty means that", + "host": "not read yet", + "port": "not read yet", + "unit_id": "not read yet", + "maintenance": "not read yet", +} + + def fields(): """Every field once, in page order, roster fields after `inverters`.""" for group in GROUPS: diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 659bc05..d9e1371 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -334,6 +334,7 @@ from typing import NamedTuple from . import dayplan, messages, pv, solarfc from .arbitrage import enabled as arbitrage_on from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S +from . import drivers from .custom_formula import ( EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in, parse_export_formula, parse_price_formula, stale_card) @@ -457,12 +458,18 @@ def _collect_writers(node, path: str, out: list) -> None: however deeply nested and under whatever container. """ if isinstance(node, dict): + # SMA-2. THE DRIVER NAMES THE ACTUATOR. An entry's `driver` (default + # goodwe_esphome) says which key declares its writer - `setpoint_entity` + # for GoodWe, `host` for a driver reached over a socket - and what that + # entry's `actuator_id()` is. The walk is as structural as before: it + # still finds an entry wherever the options keep it. + cls = drivers.driver_class(node) for k, v in node.items(): here = f"{path}.{k}" if path else str(k) - if k in ACTUATOR_WRITE_KEYS: - entity = str(v or "").strip() - if entity: - out.append((here, entity)) + if k == cls.writer_key: + actuator = cls.actuator_id_of(node) + if actuator: + out.append((here, actuator)) continue _collect_writers(v, here, out) elif isinstance(node, (list, tuple)): @@ -498,7 +505,7 @@ def actuator_writers(opts: dict) -> dict: _collect_writers(opts, "", found) census: dict = {} for where, entity in found: - census.setdefault(entity.lower(), []).append(where) + census.setdefault(drivers.census_key(entity), []).append(where) return census @@ -729,11 +736,16 @@ def _roster_problems(opts: dict) -> list[str]: else: seen[name] = where who = f"{where} ({name})" if name else where - if not str(entry.get("setpoint_entity") or "").strip(): + # SMA-2. WHETHER THE ENTRY REACHES ITS INVERTER IS THE DRIVER'S TO SAY. + # For goodwe_esphome that is a setpoint entity, and the sentence is the + # one it always was. An unknown driver is refused by (h) below. + _cls = drivers.driver_class(entry) + if _cls is not drivers.UnavailableDriver and not _cls.actuator_id_of(entry): problems.append( f"{who} has no {S('setpoint_entity')}, so the add-on cannot " "control it. Set its ESPHome number entity, or remove the " "entry.") + problems += _driver_problems(entry, who) if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0: problems.append( f"{who} has no usable {S('max_w')}: it is " @@ -775,6 +787,47 @@ def _roster_problems(opts: dict) -> list[str]: return problems +def _whole(raw, lo: int, hi: int): + """`raw` as an int in [lo, hi], or None. A bool or a fraction is not one.""" + if isinstance(raw, bool): + return None + try: + val = float(raw) + except (TypeError, ValueError): + return None + return int(val) if val == int(val) and lo <= val <= hi else None + + +def _driver_problems(entry: dict, who: str) -> list[str]: + """Rule 9(h) (SMA-2): the keys that choose and address an inverter driver. + + Only `goodwe_esphome` is selectable for now. `host`, `port`, `unit_id` and + `maintenance` are accepted and checked for shape, and nothing reads them + yet: a later driver's entry must not be refused for carrying them. + """ + out: list[str] = [] + raw = entry.get("driver") + if raw not in (None, "") and str(raw).strip() not in drivers.SELECTABLE: + out.append( + f"{who} has {S('driver')} {messages.shown(raw)}. Use " + f"{', '.join(drivers.SELECTABLE)}, or leave it empty.") + host = entry.get("host") + if host not in (None, "") and (not isinstance(host, str) + or re.search(r"\s", host.strip())): + out.append(f"{who} has {S('host')} {messages.shown(host)}, which is " + "not an address. Use a name or an IP address.") + for key, lo, hi in (("port", 1, 65535), ("unit_id", 0, 247)): + val = entry.get(key) + if val not in (None, "") and _whole(val, lo, hi) is None: + out.append(f"{who} has {S(key)} {messages.shown(val)}. Use a " + f"whole number from {lo} to {hi}.") + maint = entry.get("maintenance") + if maint is not None and not isinstance(maint, bool): + out.append(f"{who} has {S('maintenance')} {messages.shown(maint)}. " + "Use on or off.") + return out + + def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]: """Rule 2, per inverter, against each unit's OWN reported device range. @@ -787,8 +840,8 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]: 2's own failure mode restored by a config shape: the operator's stated ceiling is not the ceiling and nothing says which won. - `units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`, - `.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not + `units` is the roster (anything with `.name`, `.driver` or `.setpoint_entity`, + `.max_w`, `.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not guessed at, for the reason in this module's docstring: ESPHome is often not up when the add-on boots and (None, None) means "not read", not "zero". @@ -800,7 +853,12 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]: problems: list[str] = [] skip = str(skip_entity or "").strip().lower() for unit in units: - entity = str(getattr(unit, "setpoint_entity", "") or "").strip() + # SMA-2. THE DRIVER'S ACTUATOR, not a GoodWe key: a unit reached some + # other way is graded too. A bare object with only `.setpoint_entity` + # (the checks pass some) is read as it always was. + drv = getattr(unit, "driver", None) + entity = str((drv.actuator_id() if drv is not None + else getattr(unit, "setpoint_entity", "")) or "").strip() if not entity or entity.lower() == skip: continue cap = float(getattr(unit, "max_w", 0.0) or 0.0) diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index b02e072..1754991 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -942,11 +942,17 @@ schema: # batteries, whereas an absent capacity has an honest answer - no opinion # about the split, every unit weighs 1.0, which is what shipped. # - # ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with + # SMA-2. `driver` empty means goodwe_esphome, the only choice for now. + # `host`, `port`, `unit_id` and `maintenance` are accepted and checked for + # shape; nothing reads them yet (DECISIONS/SMA-2-driver-seam.md). + # + # ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with # no `name` has no Store namespace and would share another unit's SAFETY-02 - # latch; one with no `setpoint_entity` is a unit nothing can command; one with - # no `max_w` would have to borrow a rating, which is the sibling-grading - # defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above). + # latch; one with no `max_w` would have to borrow a rating, which is the + # sibling-grading defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's + # (options block above). `setpoint_entity` WAS A FOURTH; since SMA-2 it is the + # GoodWe driver's own key, so a unit nothing can command is refused by + # validate.py rule 9(d) per driver rather than by this schema. # The optional keys are the sensors, whose absence honestly means "this unit # reports none", and `capacity_kwh`. # @@ -958,13 +964,18 @@ schema: # test_inverter.py part 12 asserts the ordering is still hostile. inverters: - name: str - setpoint_entity: str + setpoint_entity: str? max_w: int(100,5000) bms_soc_floor: int(0,50) soc_entity: str? batt_entity: str? batt_invert: bool? capacity_kwh: float(0.1,200)? + driver: list(goodwe_esphome)? + host: str? + port: port? + unit_id: int(0,247)? + maintenance: bool? estop_fitted: bool battery_modes_enabled: bool diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py index 620da86..ab3e301 100644 --- a/goodwe_controller/test_freshness.py +++ b/goodwe_controller/test_freshness.py @@ -790,7 +790,7 @@ print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}") check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody " "checks is a comment; this is the row `batt` claims and the module that " "claims it", - BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"]) + BOUNDS["batt"].wired is True and _uses_batt == ["drivers.py"]) check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is " "STATED here rather than wired, because its obligation term is energy " "through the pack and the constant that needs has never been measured on " diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 4b7281e..1fe17cb 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -1536,8 +1536,9 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1) for ln in _entry9.splitlines()[1:] if ": " in ln) print(f" the shipped per-entry schema: {_decl9}") check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, " - "`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no " - "trailing `?` - so the Supervisor refuses a half-written entry before the " + "`max_w` and `bms_soc_floor` are REQUIRED - no " + "trailing `?` (SMA-2: `setpoint_entity` is the GoodWe driver's own key " + "and optional here; rule 9(d) requires it per driver) - so the Supervisor refuses a half-written entry before the " "add-on sees it, and an entry can never fall back on a sibling's rating " "or on a sibling's BMS cut-out because there is no entry without one. " "⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS " @@ -1553,13 +1554,14 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, " "refuses every options blob written before it existed", set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor", "soc_entity", "batt_entity", "batt_invert", - "capacity_kwh"} + "capacity_kwh", "driver", "host", "port", "unit_id", + "maintenance"} and not any(_decl9[k].endswith("?") - for k in ("name", "setpoint_entity", "max_w", - "bms_soc_floor")) + for k in ("name", "max_w", "bms_soc_floor")) and all(_decl9[k].endswith("?") - for k in ("soc_entity", "batt_entity", "batt_invert", - "capacity_kwh"))) + for k in ("setpoint_entity", "soc_entity", "batt_entity", + "batt_invert", "capacity_kwh", "driver", "host", + "port", "unit_id", "maintenance"))) # ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE. # ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK. diff --git a/goodwe_controller/test_sma2_driver.py b/goodwe_controller/test_sma2_driver.py new file mode 100644 index 0000000..7629308 --- /dev/null +++ b/goodwe_controller/test_sma2_driver.py @@ -0,0 +1,626 @@ +"""SMA-2: the inverter driver seam. `python3 test_sma2_driver.py` + +Part 1 grades the GoodWe driver against a Home Assistant that logs every call: +it must make exactly the calls the code it replaced made, in the same order. +Part 2 grades what `Inverter` reads from its driver. Part 3 drives a REAL +Controller with a roster of one fake-driver unit and one GoodWe unit and +checks that it writes, reads, limits, grades, drops and publishes each by its +own driver, with a Home Assistant that records every entity it is asked about +so a call site that goes round the seam is seen. Part 4 is the SAFETY-28 +census and rule 9 on the new keys. Part 5 is the schema. + +Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). +The seam mutants this file was run against are listed at the end. +""" + +import asyncio +import io +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app import drivers +from app.drivers import (DEFAULT_DRIVER, REGISTRY, SELECTABLE, Driver, + DriverNotBound, GoodWeEsphomeDriver, Probe, + UnavailableDriver, build_driver, census_key) +from app.freshness import BOUNDS, Bound +from app.inverter import LINK_STALE_S, Inverter, build_roster +from app.main import Controller +from app.store import DEFAULTS +from app.validate import (actuator_writers, validate_startup_config, + validate_unit_ranges) + +fails = [] +HERE = Path(__file__).resolve().parent + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +class MemStore: + def __init__(self): + self.data = dict(DEFAULTS) + + def set(self, k, v): + self.data[k] = v + + def get_time(self, *a): + return None + + def set_time(self, *a): + pass + + +class Pub: + """Keeps the last published values, like test_mqtt.py's.""" + enabled = False + + def __init__(self): + self.last = {} + + def publish(self, values): + self.last = dict(values) + + def close(self): + pass + + +class LogHass: + """Home Assistant for the GoodWe unit. EVERY call is logged with its entity, + so a path that talks to Home Assistant about anything else is on record.""" + + def __init__(self, numbers=None, limits=None, accept=True): + self.numbers = dict(numbers or {}) + self.limits_by = dict(limits or {}) + self.accept = accept + self.log = [] # (method, entity, extra) + + async def number(self, entity, invert=False): + self.log.append(("number", entity, invert)) + v = self.numbers.get(entity) + return None if v is None else (-v if invert else v) + + async def limits(self, entity): + self.log.append(("limits", entity, None)) + return self.limits_by.get(entity, (None, None, None)) + + async def set_number(self, entity, value): + self.log.append(("set_number", entity, value)) + if self.accept: + self.numbers[entity.replace("number.sp", "sensor.batt")] = value + return self.accept + + async def state(self, entity): + self.log.append(("state", entity, None)) + return None + + async def call(self, *a): + self.log.append(("call", repr(a), None)) + return True + + def entities(self): + """Every entity name Home Assistant was asked about (service calls + like notifications name none).""" + return {e for m, e, _x in self.log if m != "call"} + + +def run(coro): + return asyncio.run(coro) + + +def raises(exc, make): + """Does `run(make())` raise `exc`? `make` builds the coroutine.""" + try: + run(make()) + except exc: + return True + except Exception: + return False + return False + + +# --------------------------------------------------------------------------- +# a fake driver: a unit reached by host:port:unit, no Home Assistant at all +# --------------------------------------------------------------------------- +FAKE_BOUND = Bound(signal="fake", unchanged_s=11.0, move_w=100.0, + release_s=5.0, wired=True, derivation="test") + + +class FakeDriver(Driver): + name = "fake" + writer_key = "host" + brand = "Fake" + link_stale_s = 7.0 + batt_bound = FAKE_BOUND + settle_s = 3.0 + words = {"idle": "handed back"} + + def __init__(self, host="", port=502, unit_id=1, rating=1000.0, soc=60.0): + self.host, self.port, self.unit_id = host, port, unit_id + self.rating, self.soc, self.power = rating, soc, 0.0 + self.accept = True + self.calls = [] # (what, arg) + + @classmethod + def from_entry(cls, entry): + return cls(entry.get("host", ""), entry.get("port", 502), + entry.get("unit_id", 1), rating=entry.get("_rating", 1000.0)) + + @classmethod + def actuator_id_of(cls, entry): + host = str(entry.get("host") or "").strip() + return (f"{host}:{entry.get('port', 502)}:{entry.get('unit_id', 1)}" + if host else "") + + def configured(self): + return bool(self.host) + + def actuator_id(self): + return f"{self.host}:{self.port}:{self.unit_id}" + + def soc_source(self): + return "fake-soc" + + def batt_source(self): + return "fake-power" + + async def limits(self): + self.calls.append(("limits", None)) + return -self.rating, self.rating + + async def read(self): + self.calls.append(("read", None)) + return self.soc, self.power, {"state": "ok"} + + async def write(self, w): + self.calls.append(("write", w)) + if self.accept: + self.power = w # the battery follows its command + return self.accept + + async def release(self): + self.calls.append(("release", None)) + return True + + async def probe(self): + self.calls.append(("probe", None)) + return Probe(-self.rating, self.rating, self.soc, self.power, {}) + + +def calls(drv, what): + return [a for w, a in drv.calls if w == what] + + +print("SMA-2 - the inverter driver seam") +print() +# ============================================================================== +print("1. the GoodWe driver makes the calls the code it replaced made") + +GW = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a", + batt_entity="sensor.batt_a") +h1 = LogHass({"sensor.soc_a": 55.0, "sensor.batt_a": 700.0}, + {"number.sp_a": (-5000.0, 4000.0, 1.0)}) +d1 = GoodWeEsphomeDriver(**GW).bind(h1) +check("a driver built from an entry names its three entities and is configured", + GoodWeEsphomeDriver.from_entry(dict(GW)).setpoint_entity == "number.sp_a" + and d1.configured() and d1.actuator_id() == "number.sp_a" + and (d1.soc_source(), d1.batt_source()) + == ("sensor.soc_a", "sensor.batt_a")) +check("...and one with no setpoint entity is not", + not GoodWeEsphomeDriver().configured() + and not GoodWeEsphomeDriver(soc_entity="sensor.soc_a").configured()) +check("limits() is hass.limits(setpoint) and hands back (min, max)", + run(d1.limits()) == (-5000.0, 4000.0) + and h1.log == [("limits", "number.sp_a", None)]) +h1.log.clear() +check("read() asks for the SoC first, then the power, with the invert flag, " + "and nothing else", + run(d1.read()) == (55.0, 700.0, {}) + and h1.log == [("number", "sensor.soc_a", False), + ("number", "sensor.batt_a", False)]) +h1.log.clear() +d1i = GoodWeEsphomeDriver(batt_invert=True, **GW).bind(h1) +check("...and a reversed power sensor is reversed by hass.number, as before", + run(d1i.read()) == (55.0, -700.0, {}) + and h1.log[1] == ("number", "sensor.batt_a", True)) +h1.log.clear() +check("write(w) is hass.set_number(setpoint, w) and says whether it took it", + run(d1.write(-250.0)) is True and h1.log == [("set_number", "number.sp_a", + -250.0)]) +h1.log.clear() +h1.accept = False +check("...and False when Home Assistant refuses", + run(d1.write(10.0)) is False) +h1.accept = True +h1.log.clear() +check("release() is write(0): GoodWe is handed back by its board's own watchdog", + run(d1.release()) is True + and h1.log == [("set_number", "number.sp_a", 0.0)]) +h1.log.clear() +h1.numbers.update({"sensor.soc_a": 55.0, "sensor.batt_a": 700.0}) +pr = run(d1.probe()) +check("probe() reads the range, the SoC and the power and WRITES NOTHING", + pr == Probe(-5000.0, 4000.0, 55.0, 700.0, {}) + and all(m != "set_number" for m, _e, _x in h1.log)) +check("a driver with no Home Assistant is loud, not a silent 'no answer'", + raises(DriverNotBound, GoodWeEsphomeDriver(**GW).limits) + and raises(DriverNotBound, GoodWeEsphomeDriver(**GW).read) + and raises(DriverNotBound, lambda: GoodWeEsphomeDriver(**GW).write(1.0)) + and not GoodWeEsphomeDriver(**GW).bound()) +_swap = {"h": h1} +d1g = GoodWeEsphomeDriver(**GW).bind_getter(lambda: _swap["h"]) +h1b = LogHass({"sensor.soc_a": 1.0, "sensor.batt_a": 2.0}) +_swap["h"] = h1b +check("bind_getter() looks Home Assistant up at each use, so a Controller whose " + ".hass is replaced afterwards is followed", + d1g.bound() and run(d1g.read()) == (1.0, 2.0, {}) and h1b.log) +check("its numbers are the ones the code it replaced used", + (d1.link_stale_s, d1.batt_bound, d1.brand) + == (LINK_STALE_S, BOUNDS["batt"], "GoodWe") and LINK_STALE_S == 30.0 + and d1.settle_s == 18.0 and d1.words == {}) + +# ============================================================================== +print() +print("2. what Inverter reads from its driver") + +NOW = datetime(2026, 11, 7, 12, 0, tzinfo=timezone.utc) +fk = FakeDriver(host="10.9.9.9", unit_id=3) +inv_f = Inverter("fake-a", driver=fk, max_w=2000.0) +inv_g = Inverter("inverter-b", max_w=3000.0, **{ + "setpoint_entity": "number.sp_b", "soc_entity": "sensor.soc_b", + "batt_entity": "sensor.batt_b"}) +check("an Inverter built from the old keywords gets the GoodWe driver, with " + "the same entities on the same attributes", + isinstance(inv_g.driver, GoodWeEsphomeDriver) + and (inv_g.setpoint_entity, inv_g.soc_entity, inv_g.batt_entity, + inv_g.batt_invert) == ("number.sp_b", "sensor.soc_b", + "sensor.batt_b", False)) +check("...and one given another driver has none of those entities", + (inv_f.setpoint_entity, inv_f.soc_entity, inv_f.batt_entity) == ("", "", "")) +check("has_writer() is the driver's configured()", + inv_f.has_writer() and inv_g.has_writer() + and not Inverter("x", driver=FakeDriver(host="")).has_writer() + and not Inverter("y").has_writer()) +check("the freshness gate is built on the DRIVER's bound", + inv_f.batt_fresh.bound is FAKE_BOUND + and inv_g.batt_fresh.bound is BOUNDS["batt"]) +for u in (inv_f, inv_g): + u.note_write(False, NOW - timedelta(seconds=10)) +check("is_live() takes its window from the driver: 10 s of rejected writes " + "drops a 7 s unit and not a GoodWe one", + inv_f.is_live(NOW)[0] is False and inv_g.is_live(NOW)[0] is True) +check("...and an explicit window still wins", + inv_f.is_live(NOW, stale_s=60.0)[0] is True + and inv_g.is_live(NOW, stale_s=5.0)[0] is False) +inv_f5 = Inverter("fake-5", driver=FakeDriver(host="h"), max_w=2000.0) +inv_f5.note_write(False, NOW - timedelta(seconds=5)) +check("link_note() says the driver's window: 'after 7 seconds' for the fake " + "unit, 'after 30 seconds' for the GoodWe one, and nothing for a healthy unit", + "after 7 seconds" in inv_f5.link_note(NOW) + and "after 30 seconds" in inv_g.link_note(NOW) + and "cannot be controlled" in inv_f.link_note(NOW) + and Inverter("z", driver=FakeDriver(host="h")).link_note(NOW) == "") +check("an unconfigured unit gets the no-setpoint row, as before", + Inverter("x", driver=FakeDriver(host="")).link_note(NOW).startswith("x: ") + and Inverter("x", driver=FakeDriver(host="")).link_note(NOW) + == Inverter("x").link_note(NOW) != "") +check("an entry that names a driver this build lacks is an unconfigured unit, " + "not a crash and not GoodWe", + isinstance(build_driver({"driver": "nope", "setpoint_entity": "number.x"}), + UnavailableDriver) + and not build_driver({"driver": "nope"}).configured() + and build_roster({"inverters": [{"name": "n", "driver": "nope", + "setpoint_entity": "number.x", + "max_w": 1000}]}, None)[0].has_writer() + is False) + +# ============================================================================== +print() +print("3. a roster of one fake unit and one GoodWe unit, in a real Controller") + +REGISTRY["fake"] = FakeDriver +OPTS = dict(meter_source="off", meter_entity="sensor.grid", max_w=5000, + gain=0.6, slew_w=0, deadband_w=15, target_grid_w=0, step_w=10, + heartbeat_s=10, stale_input_s=15, shadow_mode="off", + inverters=[ + dict(name="fake-a", driver="fake", host="10.9.9.9", port=502, + unit_id=3, max_w=2000, bms_soc_floor=10), + dict(name="inverter-b", setpoint_entity="number.sp_b", + soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", + max_w=3000, bms_soc_floor=15)]) +B_ENT = {"number.sp_b", "sensor.soc_b", "sensor.batt_b"} + + +def site(rating_f=1000.0, **over): + hass = LogHass({"sensor.grid": 3000.0, "sensor.soc_b": 50.0, + "sensor.batt_b": 0.0}, + {"number.sp_b": (-3000.0, 3000.0, 1.0)}) + pub = Pub() + c = Controller(dict(OPTS, **over), hass, MemStore(), pub, + stop=asyncio.Event()) + f = c.inverters[0].driver + f.rating = rating_f + c.inverters[0].dev_min, c.inverters[0].dev_max = -rating_f, rating_f + c.inverters[1].dev_min, c.inverters[1].dev_max = -3000.0, 3000.0 + c.auto = True + return c, hass, pub, f + + +async def cycles(c, n=6): + for _ in range(n): + await c.read_inputs() + await c.cycle() + + +c3, h3, p3, f3 = site() +check("the roster is built through the registry: a fake unit and a GoodWe unit", + isinstance(c3.inverters[0].driver, FakeDriver) + and isinstance(c3.inverters[1].driver, GoodWeEsphomeDriver) + and f3.actuator_id() == "10.9.9.9:502:3") +run(cycles(c3)) +fa, fb = c3.inverters +print(f" after 6 cycles: fake-a wrote {calls(f3, 'write')}, GoodWe wrote " + f"{[x for m, e, x in h3.log if m == 'set_number']}, shares " + f"{c3.allocation.shares if c3.allocation else None}") +check("READ: the unit's SoC and power come from its driver, the GoodWe unit's " + "from its entities", + calls(f3, "read") and fa.soc == 60.0 and fb.soc == 50.0 + and ("number", "sensor.soc_b", False) in h3.log) +check("WRITE: the fake unit's share went to ITS driver and the GoodWe unit's to " + "ITS entity, each non-zero, the same direction", + calls(f3, "write") and calls(f3, "write")[-1] > 0 + and [x for m, e, x in h3.log if m == "set_number" and e == "number.sp_b"][-1] > 0) +check("ALLOCATE: each unit holds a share inside its own rating, and the shares " + "are the site's (3000 W of grid, fake capped at 1000 W by its driver)", + c3.allocation is not None + and set(c3.allocation.shares) == {"fake-a", "inverter-b"} + and 0 < c3.allocation.shares["fake-a"] <= 1000.0 + and 0 < c3.allocation.shares["inverter-b"] <= 3000.0 + and calls(f3, "write")[-1] == c3.allocation.shares["fake-a"]) +check("GRADE: the fake unit's rating is the driver's range (1000 W), the " + "GoodWe unit's is its entry's (3000 W), each from its own source", + (fa.rating_w(), fb.rating_w()) == (1000.0, 3000.0)) +check("the fake's power follows its command, so the freshness gate (the fake's " + "own bound) lets the reading through", + fa.batt == calls(f3, "write")[-1] and fa.batt_fresh.bound is FAKE_BOUND) +run(c3.send_heartbeats(10_000.0, 10.0)) +n_before = len(calls(f3, "write")) +check("HEARTBEAT: it re-emits the fake unit's standing command through its driver", + n_before >= 2 and calls(f3, "write")[-1] == calls(f3, "write")[-2]) +run(c3.command_site_zero()) +check("STOP: command_site_zero() reaches the fake unit through its driver, as " + "0 W (the hand-back is SMA-10's, and release() has no caller yet)", + calls(f3, "write")[-1] == 0.0 and not calls(f3, "release")) +c3.publish() +print(f" published unit rows: " + f"{sorted(k for k in p3.last if k.startswith('unit/'))}") +check("PUBLISH: one set of rows per unit, with that unit's own numbers", + p3.last.get("unit/fake_a/soc") == 60.0 + and p3.last.get("unit/inverter_b/soc") == 50.0 + and "unit/fake_a/battery" in p3.last and "unit/inverter_b/battery" in p3.last) +rows = c3.checks() +check("STATUS: the fake unit's sensor rows are not 'unset' (its driver has a " + "source), and it has the control row", + not [r for r in rows if r.get("id") == "sensor" and not r["ok"] + and "fake" in r["text"]]) +# liveness: the fake's 7 s window, the GoodWe unit's 30 s +c4, h4, p4, f4 = site() +run(cycles(c4, 2)) +f4.accept = False # the fake's link is down; GoodWe's is fine +_now = datetime.now(timezone.utc) +for u in c4.inverters: + u.note_write(False, _now - timedelta(seconds=10)) +check("LIVENESS: after 10 s of rejected writes the fake unit is left out of the " + "split and the GoodWe unit stays in it, per its driver's window", + c4.live_units() == ("inverter-b",)) +run(cycles(c4, 4)) +check("...and the survivor carries the site: the allocation names only it", + c4.allocation is not None and set(c4.allocation.shares) == {"inverter-b"} + and c4.allocation.shares["inverter-b"] > 1000.0) +# the tripwire: nothing in the world was asked about the fake unit +asked = h3.entities() | h4.entities() +print(f" Home Assistant was asked about: {sorted(asked)}") +check("TRIPWIRE: Home Assistant was only ever asked about the GoodWe unit's " + "entities and the meter - no empty name, no host - on read, write, " + "heartbeat, stop and limits alike", + asked <= B_ENT | {"sensor.grid"} and "" not in asked + and not any("10.9.9.9" in e or "fake" in e for e in asked)) +check("...and the GoodWe unit was never touched through the fake", + not [1 for w, a in f3.calls + f4.calls + if isinstance(a, str) and "number.sp_b" in a]) + +# limits and the per-unit range check, through run_control() +async def drive(c, secs): + try: + await asyncio.wait_for(c.run_control(), timeout=secs) + except asyncio.TimeoutError: + pass + + +c5, h5, p5, f5 = site(rating_f=2500.0) +f5.rating = 2500.0 +c5.inverters[0].dev_min = c5.inverters[0].dev_max = None +c5.inverters[1].dev_min = c5.inverters[1].dev_max = None +run(drive(c5, 3.0)) +check("LIMITS: run_control() reads the fake unit's range from its driver and " + "the GoodWe unit's from its entity", + calls(f5, "limits") + and (c5.inverters[0].dev_min, c5.inverters[0].dev_max) == (-2500.0, 2500.0) + and (c5.inverters[1].dev_min, c5.inverters[1].dev_max) == (-3000.0, 3000.0) + and [e for m, e, x in h5.log if m == "limits"] == ["number.sp_b"]) +c6, h6, p6, f6 = site() +f6.rating = 1500.0 # the entry says 2000 W +c6.inverters[0].dev_min = c6.inverters[0].dev_max = None +run(drive(c6, 10.0)) +check("RULE 2 PER UNIT: a fake unit rated above what its driver says it accepts " + "is refused by name, as a GoodWe one is", + c6.refused and any("fake-a" in p and "10.9.9.9:502:3" in p + for p in c6.refused) + and not calls(f6, "write")) + +# the settings panel has no Controller: it binds each unit's driver itself +from app import selfconfig # noqa: E402 +hs = LogHass({}, {"number.sp_b": (-3000.0, 3000.0, 1.0)}) +_ps, _unread = run(selfconfig.problems_and_ranges( + dict(OPTS, inverters=[dict(OPTS["inverters"][0], _rating=1500.0), + OPTS["inverters"][1]]), hs)) +check("PANEL: the settings panel reads the fake unit's range from its driver " + "and the GoodWe unit's from its entity, and grades the fake unit by name", + any("fake-a" in p and "10.9.9.9:502:3 accepts at most 1500" in p + for p in _ps) + and [e for m, e, x in hs.log if m == "limits"] == ["number.sp_b"] + and _unread == []) + +# the seam is the only way in: a fake-only roster touches Home Assistant for +# nothing but the meter +hz = LogHass({"sensor.grid": 800.0}) +cz = Controller(dict(OPTS, inverters=[OPTS["inverters"][0]]), hz, MemStore(), + Pub(), stop=asyncio.Event()) +cz.inverters[0].dev_min, cz.inverters[0].dev_max = -1000.0, 1000.0 +cz.auto = True +run(cycles(cz, 3)) +check("A FAKE-ONLY ROSTER never asks Home Assistant for anything but the " + "meter, and still reads and writes (nothing on the control path is " + "keyed on a GoodWe entity)", + hz.entities() <= {"sensor.grid"} and calls(cz.inverters[0].driver, "read") + and calls(cz.inverters[0].driver, "write")) + +# ============================================================================== +print() +print("4. SAFETY-28's census counts actuator ids; rule 9 on the new keys") + +def entry(**kw): + return dict(name="u", max_w=3000, bms_soc_floor=10, **kw) + + +check("GoodWe entries are counted as before: one entity named twice is one " + "actuator with two writers, whatever its case", + {k: len(v) for k, v in actuator_writers({"inverters": [ + entry(setpoint_entity="number.X"), + entry(setpoint_entity=" number.x ")]}).items()} == {"number.x": 2} + and actuator_writers({"setpoint_entity": "number.a", "inverters": [ + entry(setpoint_entity="number.a")]})["number.a"] + == ["setpoint_entity", "inverters[0].setpoint_entity"]) +cen = actuator_writers({"inverters": [ + entry(driver="fake", host="gw", port=502, unit_id=1), + entry(driver="fake", host="gw", port=502, unit_id=2), + entry(driver="fake", host="gw", port=503, unit_id=1), + entry(driver="fake", host="GW", port=502, unit_id=1)]}) +print(f" census {cen}") +check("another driver's actuator is host:port:unit: two units behind one " + "gateway are two actuators, and one unit named twice is one", + set(cen) == {"gw:502:1", "gw:502:2", "gw:503:1"} + and cen["gw:502:1"] == ["inverters[0].host", "inverters[3].host"]) +check("a GoodWe key on a fake entry declares no writer there, and a `host` on " + "a GoodWe entry declares none", + actuator_writers({"inverters": [entry(driver="fake", host="gw", + setpoint_entity="number.x")]}) + == {"gw:502:1": ["inverters[0].host"]} + and actuator_writers({"inverters": [entry(setpoint_entity="number.x", + host="gw")]}) + == {"number.x": ["inverters[0].setpoint_entity"]}) +check("an entry naming a driver this build lacks still counts its setpoint " + "entity - refusing too much is the safe direction", + actuator_writers({"inverters": [entry(driver="nope", + setpoint_entity="number.q")]}) + == {"number.q": ["inverters[0].setpoint_entity"]}) +check("census_key() folds case and space", + census_key(" Number.X ") == "number.x") + +GOOD = dict(entry(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a", + batt_entity="sensor.batt_a")) +BASEOPTS = {"meter_source": "off", "meter_entity": "sensor.grid"} +base_problems = validate_startup_config(dict(BASEOPTS, inverters=[dict(GOOD)])) +print(f" baseline problems for a plain GoodWe roster: {base_problems}") + + +def prob(**kw): + return validate_startup_config(dict(BASEOPTS, inverters=[dict(GOOD, **kw)])) + + +check("an existing entry (no new key) is graded exactly as before", + prob() == base_problems) +check("...and so is one that says driver goodwe_esphome, or carries every new " + "key in a valid shape", + prob(driver="goodwe_esphome") == base_problems + and prob(driver="", host="", maintenance=False) == base_problems + and prob(host="inv.local", port=502, unit_id=3, maintenance=True) + == base_problems) +def named(problems, key): + return [p for p in problems if f"inverters.{key}" in p or f"({key})" in p] + + +check("only goodwe_esphome can be selected for now", + named(prob(driver="sma_modbus"), "driver") + and named(prob(driver="fake"), "driver") + and SELECTABLE == (DEFAULT_DRIVER,)) +check("a host with a space, a bad port or unit number and a non-boolean " + "maintenance are each refused by key; the right shapes are not", + named(prob(host="a b"), "host") + and named(prob(port=0), "port") and named(prob(port=70000), "port") + and named(prob(port="x"), "port") and named(prob(port=True), "port") + and named(prob(unit_id=248), "unit_id") + and named(prob(unit_id=1.5), "unit_id") + and named(prob(maintenance="yes"), "maintenance") + and not named(prob(port=65535, unit_id=0, host="10.0.0.1"), "port")) +check("a GoodWe entry with no setpoint entity is refused with the same sentence " + "as before", + any("has no" in p and "setpoint entity" in p.lower() + for p in validate_startup_config(dict(BASEOPTS, inverters=[ + dict(GOOD, setpoint_entity="")])))) +fu = FakeDriver(host="gw") +check("rule 2 grades any unit by its driver's actuator id, so a unit with no " + "setpoint entity is no longer skipped", + validate_unit_ranges([Inverter("a", driver=fu, max_w=2000.0)]) + == [] and validate_unit_ranges([_u := Inverter("a", driver=fu, + max_w=2000.0)]) == [] + and (setattr(_u, "dev_max", 1000.0) or True) + and len(validate_unit_ranges([_u])) == 1 + and "gw:502:1" in validate_unit_ranges([_u])[0]) + +# ============================================================================== +print() +print("5. the schema") +cfg = io.open(HERE / "config.yaml", encoding="utf-8").read() +sch = cfg[cfg.index("\nschema:"):] +ent = sch[sch.index("\n inverters:"):] +ent = ent[:ent.index("\n\n")] +decl = dict(ln.strip().lstrip("- ").split(": ", 1) + for ln in ent.splitlines()[1:] if ": " in ln) +check("setpoint_entity is optional per entry, beside driver, host, port, " + "unit_id and maintenance; name, max_w and bms_soc_floor stay required", + decl["setpoint_entity"] == "str?" and decl["driver"] == "list(goodwe_esphome)?" + and decl["host"] == "str?" and decl["port"] == "port?" + and decl["unit_id"] == "int(0,247)?" and decl["maintenance"] == "bool?" + and not any(decl[k].endswith("?") for k in ("name", "max_w", + "bms_soc_floor"))) +check("config.yaml lists exactly the selectable drivers", + decl["driver"] == f"list({'|'.join(SELECTABLE)})?") + +del REGISTRY["fake"] + +# THE SEAM MUTANTS THIS FILE WAS RUN AGAINST (twelve, each one edit to a copy of +# the tree; all twelve turn it red): +# write_unit calls hass.set_number itself -> part 3 WRITE (and the tripwire) +# _read_unit calls hass.number(soc/batt entity) -> part 3 READ +# run_control's limits keyed on setpoint_entity -> part 3 LIMITS +# the panel's range check keyed on setpoint_entity -> part 3 PANEL +# has_writer() back to bool(setpoint_entity) -> part 2 has_writer +# is_live() window back to LINK_STALE_S -> part 2 is_live +# link_note() window back to LINK_STALE_S -> part 2 link_note +# Freshness built on BOUNDS["batt"] -> part 2 freshness bound +# _write_unit's guard keyed on setpoint_entity -> part 3 WRITE +# the census back to the GoodWe key only -> part 4 census +# rule 2 per unit back to .setpoint_entity -> part 3 RULE 2 PER UNIT +# the status rows' source test back to the entities -> part 3 STATUS +# NOT COVERED: the call in amain() that reads the flat setpoint entity's range +# before any Controller exists. It runs only inside amain(), which no check +# starts; it is two lines and the GoodWe driver's own limits(), graded in part 1. +print() +if fails: + print(f"{len(fails)} FAILED") + sys.exit(1) +print("all passed") diff --git a/goodwe_controller/test_translations.py b/goodwe_controller/test_translations.py index 47e1db8..a05f169 100644 --- a/goodwe_controller/test_translations.py +++ b/goodwe_controller/test_translations.py @@ -75,7 +75,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero", check("...and the repeatable `inverters:` section's per-entry keys were read " "too, which is the half a top-level-only reader would miss", NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"] - and len(NESTED["inverters"]) == 8) + and len(NESTED["inverters"]) == 13) # SMA-2: +5 driver keys print("\n2. AC: every option and every per-entry key is translated, in BOTH " "languages") @@ -281,7 +281,7 @@ check("...and the accepted subset really does round-trip the shipped files - " f"per-entry keys came back out of each, which is what says the " f"strictness above is not just refusing everything", all(len(TR[lg]["configuration"]) == len(ORDER) - and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8 + and len(TR[lg]["configuration"]["inverters"]["fields"]) == 13 for lg in LANGS)) print(" ⚠️ the licence for this hand parser is NOT this file. " "tools/supervisor_shape_probe.py section F loads the same two files with " diff --git a/goodwe_controller/test_uilayout.py b/goodwe_controller/test_uilayout.py index 70c4bdb..ab68084 100644 --- a/goodwe_controller/test_uilayout.py +++ b/goodwe_controller/test_uilayout.py @@ -51,8 +51,10 @@ def coverage_problems(order, nested): out += [f"{k} is not in config.yaml" for k in set(placed) | set(L.DERIVED) | set(L.HIDDEN) if k not in order] sub = [k.split(".", 1)[1] for k in KEYS if k.startswith("inverters.")] + # SMA-2: a per-entry key may instead be listed in L.ROSTER_HIDDEN (the + # driver keys the page does not draw), and then it must not also be a field. out += [f"inverters.{k} has no card field" for k in nested.get("inverters", []) - if sub.count(k) != 1] + if sub.count(k) + (k in L.ROSTER_HIDDEN) != 1] out += [f"inverters.{k} is not in config.yaml" for k in sub if k not in nested.get("inverters", [])] return out diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 426d2a2..c370844 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -243,8 +243,6 @@ show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere " "else to come from", ok, json.dumps(out.get("inverters")) if ok else out) for title, entry, expect in ( ("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"), - ("no setpoint_entity", - {k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"), ("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"), ("no bms_soc_floor (SAFETY-32)", {k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"), @@ -257,6 +255,40 @@ for title, entry, expect in ( ok, out = validate(dict(D_BASE, inverters="[]")) show("a JSON STRING where the list belongs is refused", not ok, out) +print("\n== K. SMA-2: the driver keys on an inverter entry ==") +# `setpoint_entity` is OPTIONAL per entry since SMA-2 (it is the GoodWe driver's +# own key; validate.py rule 9(d) requires it for GoodWe), beside `driver`, +# `host`, `port`, `unit_id` and `maintenance`. Same method as §D: an ACCEPTED +# baseline, ONE value varied, the refusal asserted on the KEY'S NAME. +K_FULL = dict(D_FULL, driver="goodwe_esphome", host="inverter.local", port=502, + unit_id=1, maintenance=False) +ok, out = validate(dict(D_BASE, inverters=[K_FULL])) +show("an entry carrying every new key is ACCEPTED, and the keys survive", + ok and all(out["inverters"][0].get(k) == K_FULL[k] + for k in ("driver", "host", "port", "unit_id", "maintenance")), + json.dumps(out.get("inverters")) if ok else out) +ok, out = validate(dict(D_BASE, inverters=[D_FULL])) +show("an entry from before SMA-2 (none of the new keys) is ACCEPTED unchanged", + ok and out["inverters"] == [D_FULL], out) +ok, out = validate(dict(D_BASE, inverters=[ + {k: v for k, v in D_FULL.items() if k != "setpoint_entity"}])) +show("an entry with NO setpoint_entity is now accepted by the schema " + "(rule 9(d) refuses it for GoodWe, at startup)", ok, out) +# ⚠️ THE SUPERVISOR'S REFUSAL NAMES THE VALUE'S RULE, NOT THE KEY, FOR A NESTED +# ENTRY (§D's "at most 5000"), so the expectation is that rule's own words. +for title, entry, expect in ( + ("driver other than goodwe_esphome", dict(K_FULL, driver="sma"), + "one of ['goodwe_esphome']"), + ("port 0", dict(K_FULL, port=0), "at least 1"), + ("port 70000", dict(K_FULL, port=70000), "at most 65535"), + ("unit_id 248", dict(K_FULL, unit_id=248), "at most 247"), + ("host a number", dict(K_FULL, host=5), "expected str"), + ("maintenance a word that is not a boolean", + dict(K_FULL, maintenance="banana"), "boolean")): + ok, out = validate(dict(D_BASE, inverters=[entry])) + show(f"an entry with {title} is refused, with the rule's own words", + (not ok) and expect in str(out), out) + print("\n== E. the ROLLBACK direction, and the operator's UI ==") # The old (flat) schema, i.e. what an operator would be running after a diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index d390d74..a23daba 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -79,6 +79,21 @@ configuration: capacity_kwh: name: "Usable battery capacity (kWh) (optional)" description: "This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left." + driver: + name: "Inverter type (optional)" + description: "The kind of inverter. Leave it empty for a GoodWe." + host: + name: "Inverter address (optional)" + description: "Not used yet. Leave it empty." + port: + name: "Inverter port (optional)" + description: "Not used yet. Leave it empty." + unit_id: + name: "Inverter unit number (optional)" + description: "Not used yet. Leave it empty." + maintenance: + name: "Battery maintenance (optional)" + description: "Not used yet. Leave it empty." estop_fitted: name: "Hardware · RS485 emergency stop is fitted" description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index 3db9bd5..0eecb3b 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -60,6 +60,21 @@ configuration: capacity_kwh: name: "Bruikbare capaciteit (kWh) (optioneel)" description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen. Dan verdeelt de add-on het werk volgens de energie in elke batterij." + driver: + name: "Type omvormer (optioneel)" + description: "Het soort omvormer. Laat leeg voor een GoodWe." + host: + name: "Adres van de omvormer (optioneel)" + description: "Nog niet in gebruik. Laat leeg." + port: + name: "Poort van de omvormer (optioneel)" + description: "Nog niet in gebruik. Laat leeg." + unit_id: + name: "Eenheidsnummer van de omvormer (optioneel)" + description: "Nog niet in gebruik. Laat leeg." + maintenance: + name: "Onderhoud van de batterij (optioneel)" + description: "Nog niet in gebruik. Laat leeg." estop_fitted: name: "Hardware · RS485-noodstop is geplaatst" description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."