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:
co-authored by
Claude
parent
e56eeef8f0
commit
0b20f53cf8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user