Files
goodwe-addon/goodwe_controller/test_sma2_driver.py
T
glenn schrooyenandClaude e56eeef8f0
checks / unit (pull_request) Successful in 23m14s
infra-pin / fresh (pull_request) Successful in 2s
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 <noreply@anthropic.com>
2026-11-07 11:28:47 +01:00

627 lines
28 KiB
Python

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