SMA-2 round 2: census keeps ACTUATOR_WRITE_KEYS, setters, NaN/inf port refused, tests

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-07 12:20:56 +01:00
co-authored by Claude
parent e56eeef8f0
commit 0b20f53cf8
5 changed files with 185 additions and 9 deletions
+132 -3
View File
@@ -514,11 +514,13 @@ 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",
check("a GoodWe key on a fake entry STILL declares a writer (the pre-SMA-2 "
"census kept, the driver's actuator added to it), 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"]}
== {"number.x": ["inverters[0].setpoint_entity"],
"gw:502:1": ["inverters[0].host"]}
and actuator_writers({"inverters": [entry(setpoint_entity="number.x",
host="gw")]})
== {"number.x": ["inverters[0].setpoint_entity"]})
@@ -600,6 +602,133 @@ check("setpoint_entity is optional per entry, beside driver, host, port, "
check("config.yaml lists exactly the selectable drivers",
decl["driver"] == f"list({'|'.join(SELECTABLE)})?")
# ==============================================================================
print()
print("6. round 2: census parity, setters, rule 9(d), NaN ports, hidden keys")
import copy, itertools, math # noqa: E402
from app import uilayout as L, uimodel # noqa: E402
from app.validate import ACTUATOR_WRITE_KEYS # noqa: E402
def old_census(opts):
"""23ff9d0's census, copied: every ACTUATOR_WRITE_KEYS key, wherever it is."""
found = []
def walk(node, path):
if isinstance(node, dict):
for k, v in node.items():
here = f"{path}.{k}" if path else str(k)
if k in ACTUATOR_WRITE_KEYS:
e = str(v or "").strip()
if e:
found.append((here, e))
continue
walk(v, here)
elif isinstance(node, (list, tuple)):
for i, v in enumerate(node):
walk(v, f"{path}[{i}]")
walk(opts, "")
cen = {}
for where, e in found:
cen.setdefault(e.lower(), []).append(where)
return cen
check("ACTUATOR_WRITE_KEYS is exactly ('setpoint_entity',): the parity below "
"is about that key", tuple(ACTUATOR_WRITE_KEYS) == ("setpoint_entity",))
SP = ["number.a", "Number.A", " number.b ", "", None, 5, ["x"]]
shapes = []
for flat, e0, e1 in itertools.product(SP, SP, SP):
shapes.append({"setpoint_entity": flat, "inverters": [
{"name": "a", "setpoint_entity": e0}, {"name": "b", "setpoint_entity": e1}]})
shapes += [{"x": {"y": [{"setpoint_entity": "number.z"}]}, "setpoint_entity": "n.q"},
{"inverters": "junk"}, {"inverters": [{"driver": "nope",
"setpoint_entity": "n.k"}]},
{"inverters": [{"driver": "goodwe_esphome", "setpoint_entity": "n.k"}]},
{}, {"inverters": []}]
bad = [i for i, o in enumerate(shapes) if actuator_writers(o) != old_census(o)]
check(f"CENSUS PARITY: on {len(shapes)} flat/roster/odd option shapes the "
"census equals 23ff9d0's, keys, order and all", not bad)
mixed = {"setpoint_entity": "number.a", "inverters": [
{"name": "a", "setpoint_entity": "number.a"},
{"name": "f", "driver": "fake", "host": "gw", "port": 502, "unit_id": 1,
"setpoint_entity": "number.ignored"},
{"name": "g", "setpoint_entity": "number.g"}]}
new_c, old_c = actuator_writers(mixed), old_census(mixed)
check("...and on a mixed config (a fake-driver entry too) the census is a "
"SUPERSET of the pre-SMA-2 one: every old writer is still counted, "
"plus the driver's own actuator",
all(new_c.get(k) and set(v) <= set(new_c[k]) for k, v in old_c.items())
and "gw:502:1" in new_c)
gi = Inverter("s", setpoint_entity="number.s", soc_entity="sensor.a",
batt_entity="sensor.b")
gi.setpoint_entity, gi.soc_entity, gi.batt_entity, gi.batt_invert = (
"number.t", "sensor.c", "sensor.d", True)
check("SETTERS: assigning setpoint_entity/soc_entity/batt_entity/batt_invert "
"on an Inverter writes through to its GoodWe driver, as it did before",
(gi.driver.setpoint_entity, gi.driver.soc_entity, gi.driver.batt_entity,
gi.driver.batt_invert) == ("number.t", "sensor.c", "sensor.d", True)
and gi.has_writer() and gi.driver.actuator_id() == "number.t")
def _assign():
inv_f.setpoint_entity = "x"
try:
_assign()
_refused = False
except AttributeError:
_refused = True
check("...and on a driver that has no such field it refuses, not loses the value",
_refused)
nogood = validate_startup_config(dict(BASEOPTS, inverters=[
{k: v for k, v in GOOD.items() if k != "setpoint_entity"}]))
check("RULE 9(d): a GoodWe entry with no setpoint_entity is refused, by name "
"and by position, with the sentence it always had",
any(p.startswith("inverters[0] (u) has no") and "setpoint entity" in p.lower()
and "Set its ESPHome number entity, or remove the entry." in p
for p in nogood)
and nogood != base_problems)
check("...also with `driver: goodwe_esphome` said out loud",
any("has no" in p for p in validate_startup_config(dict(
BASEOPTS, inverters=[{k: v for k, v in dict(GOOD, driver="goodwe_esphome").items()
if k != "setpoint_entity"}]))))
nan, inf = float("nan"), float("inf")
for key in ("port", "unit_id"):
for bad_v, nm in ((nan, "NaN"), (inf, "inf"), (-inf, "-inf")):
try:
got = prob(**{key: bad_v})
ok_ = bool(named(got, key))
except Exception as e: # noqa: BLE001
got, ok_ = repr(e), False
check(f"{key} = {nm} is refused by key and does not raise", ok_)
keys_cfg = set(decl)
placed = {k.split(".", 1)[1] for _g, _p, f in L.fields()
for k in [f.key] if k.startswith("inverters.")}
check("ROSTER_HIDDEN: every per-entry key in config.yaml is either a card "
"field or hidden, never both and never neither",
all((k in placed) != (k in L.ROSTER_HIDDEN) for k in keys_cfg)
and not (placed & set(L.ROSTER_HIDDEN))
and set(L.ROSTER_HIDDEN) <= keys_cfg)
check("...and a per-entry key that is neither would be flagged (test_uilayout "
"grades the same rule with a made-up key)",
not all((k in placed) != (k in L.ROSTER_HIDDEN)
for k in keys_cfg | {"brand_new_key"}))
ent = dict(name="a", setpoint_entity="number.a", max_w=3000, bms_soc_floor=10,
soc_entity="sensor.s", batt_entity="sensor.b", driver="goodwe_esphome",
host="h", port=502, unit_id=3, maintenance=False)
opts_rt = {"inverters": [ent, dict(ent, name="b", setpoint_entity="number.b")]}
back = uimodel.ui_to_options(uimodel.options_to_ui(copy.deepcopy(opts_rt)))
check("the page model carries the hidden keys through a save untouched "
"(this is how ROSTER_HIDDEN keys survive the settings page)",
back.get("inverters") == opts_rt["inverters"])
del REGISTRY["fake"]
# THE SEAM MUTANTS THIS FILE WAS RUN AGAINST (twelve, each one edit to a copy of