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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-07 11:28:47 +01:00
co-authored by Claude
parent 23ff9d0920
commit e56eeef8f0
18 changed files with 1238 additions and 65 deletions
@@ -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