SMA-2 round 2: keep the driver keys out of the schema, translations and labels
Owner decision 2026-11-07: clients see nothing new until SMA-4. config.yaml, translations, labels and the page layout are back to 23ff9d0's. The validation of the keys stays for callers that skip the schema. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
0b20f53cf8
commit
c015230452
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
### Not in a release yet
|
### 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. The inverter list also accepts five new optional settings that nothing reads yet, and the schema no longer requires a setpoint entity per entry: a GoodWe entry without one is still refused at start ([[SMA-2]]).
|
- Each inverter is now reached through a driver, so another make can be added later. Nothing changes for a GoodWe site ([[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]]).
|
- 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]]).
|
- 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]]).
|
- With a 5 s meter, readings 30 s late are now noticed within 100 seconds, not 3 minutes ([[SAFETY-129]]).
|
||||||
|
|||||||
@@ -30,19 +30,26 @@ one unit named twice is the SAFETY-28 refusal. The census still counts every
|
|||||||
with 23ff9d0 on 349 option shapes is a test). The driver binds its Home
|
with 23ff9d0 on 349 option shapes is a test). The driver binds its Home
|
||||||
Assistant on first use by the Controller, so a unit added later is wired too.
|
Assistant on first use by the Controller, so a unit added later is wired too.
|
||||||
|
|
||||||
**Schema.** The brief says "`setpoint_entity` becomes optional" (SMA-2, "Schema per
|
**Schema, and why the new keys are not in it.** The brief asks for
|
||||||
inverter entry"; SMA-9 section 1.1): it is the GoodWe driver's own key, so a
|
`driver`, `host`, `port`, `unit_id`, `maintenance` per entry and for
|
||||||
driver reached by host and port has none. `setpoint_entity` is optional per entry
|
`setpoint_entity` to become optional. Owner decision 2026-11-07: clients see
|
||||||
in config.yaml; rule 9(d) still refuses a GoodWe entry without it, with the same
|
nothing new until SMA-4/SMA-7 use the keys. The Supervisor form shows every
|
||||||
sentence (`test_sma2_driver.py` part 6), beside `driver` (only `goodwe_esphome`),
|
schema key (and marks required ones), and a nested entry with a key the schema
|
||||||
`host`, `port`, `unit_id`, `maintenance`. The last four are checked for shape
|
lacks has that key silently dropped (measured, shape probe section K), so the
|
||||||
and not read. The Supervisor shape probe was re-run (section K).
|
only way to hide them is to keep them out of config.yaml. So for now config.yaml, the
|
||||||
|
translations and the labels are exactly 23ff9d0's, and `setpoint_entity` stays
|
||||||
|
required there: until a driver without one is selectable, no entry can lack it.
|
||||||
|
The code underneath is the brief's: the driver defaults to `goodwe_esphome`,
|
||||||
|
rule 9(d) is per driver (a GoodWe entry without a setpoint entity is refused,
|
||||||
|
same sentence), and rule 9(h) checks `driver`, `host`, `port`, `unit_id`,
|
||||||
|
`maintenance` when a caller that skips the schema (the panel's save path,
|
||||||
|
sim/inject, the checks) supplies them; those sentences name the key, having no
|
||||||
|
label. When SMA-4 needs them: add them to the schema as optional with
|
||||||
|
translations, make `setpoint_entity` `str?`, and add them to `uilayout` as card
|
||||||
|
fields or a hidden list.
|
||||||
|
|
||||||
**Checked against 23ff9d0:** replay digests and a log of every Home Assistant
|
**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
|
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
|
fake driver beside a GoodWe unit allocates, grades, drops and publishes per
|
||||||
unit; a call site that goes round the seam is caught.
|
unit; a call site that goes round the seam is caught.
|
||||||
|
|
||||||
**Per-entry keys the page does not draw** are listed in `uilayout.ROSTER_HIDDEN`;
|
|
||||||
test_uilayout and test_sma2_driver fail for a key that is neither a card field
|
|
||||||
nor hidden, and a save through the page keeps them (a test).
|
|
||||||
|
|||||||
@@ -1683,11 +1683,6 @@ LABELS = {
|
|||||||
"capacity_kwh": "Usable battery capacity",
|
"capacity_kwh": "Usable battery capacity",
|
||||||
"inverters": "Inverter list",
|
"inverters": "Inverter list",
|
||||||
"name": "Name",
|
"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_source": "Where the meter reading comes from",
|
||||||
"meter_phases": "Number of phases",
|
"meter_phases": "Number of phases",
|
||||||
"p1_host": "HomeWizard P1 address",
|
"p1_host": "HomeWizard P1 address",
|
||||||
@@ -1769,11 +1764,6 @@ LABELS_NL = {
|
|||||||
"capacity_kwh": "Bruikbare capaciteit",
|
"capacity_kwh": "Bruikbare capaciteit",
|
||||||
"inverters": "Lijst met omvormers",
|
"inverters": "Lijst met omvormers",
|
||||||
"name": "Naam",
|
"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_source": "Waar de meterstand vandaan komt",
|
||||||
"meter_phases": "Aantal fasen",
|
"meter_phases": "Aantal fasen",
|
||||||
"p1_host": "Adres van de HomeWizard P1",
|
"p1_host": "Adres van de HomeWizard P1",
|
||||||
|
|||||||
@@ -302,19 +302,6 @@ DERIVED = {
|
|||||||
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
|
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():
|
def fields():
|
||||||
"""Every field once, in page order, roster fields after `inverters`."""
|
"""Every field once, in page order, roster fields after `inverters`."""
|
||||||
for group in GROUPS:
|
for group in GROUPS:
|
||||||
|
|||||||
@@ -814,6 +814,12 @@ def _whole(raw, lo: int, hi: int):
|
|||||||
def _driver_problems(entry: dict, who: str) -> list[str]:
|
def _driver_problems(entry: dict, who: str) -> list[str]:
|
||||||
"""Rule 9(h) (SMA-2): the keys that choose and address an inverter driver.
|
"""Rule 9(h) (SMA-2): the keys that choose and address an inverter driver.
|
||||||
|
|
||||||
|
NOT IN config.yaml's SCHEMA, so the Supervisor form and its validator never
|
||||||
|
see them (owner decision 2026-11-07: clients see nothing new until SMA-4).
|
||||||
|
They can still reach this rule from callers that skip the schema (the
|
||||||
|
panel's save path, sim/inject, the checks), so they are graded here, and
|
||||||
|
named by key because they have no label.
|
||||||
|
|
||||||
Only `goodwe_esphome` is selectable for now. `host`, `port`, `unit_id` and
|
Only `goodwe_esphome` is selectable for now. `host`, `port`, `unit_id` and
|
||||||
`maintenance` are accepted and checked for shape, and nothing reads them
|
`maintenance` are accepted and checked for shape, and nothing reads them
|
||||||
yet: a later driver's entry must not be refused for carrying them.
|
yet: a later driver's entry must not be refused for carrying them.
|
||||||
@@ -822,21 +828,21 @@ def _driver_problems(entry: dict, who: str) -> list[str]:
|
|||||||
raw = entry.get("driver")
|
raw = entry.get("driver")
|
||||||
if raw not in (None, "") and str(raw).strip() not in drivers.SELECTABLE:
|
if raw not in (None, "") and str(raw).strip() not in drivers.SELECTABLE:
|
||||||
out.append(
|
out.append(
|
||||||
f"{who} has {S('driver')} {messages.shown(raw)}. Use "
|
f"{who} has `inverters.driver` {messages.shown(raw)}. Use "
|
||||||
f"{', '.join(drivers.SELECTABLE)}, or leave it empty.")
|
f"{', '.join(drivers.SELECTABLE)}, or leave it empty.")
|
||||||
host = entry.get("host")
|
host = entry.get("host")
|
||||||
if host not in (None, "") and (not isinstance(host, str)
|
if host not in (None, "") and (not isinstance(host, str)
|
||||||
or re.search(r"\s", host.strip())):
|
or re.search(r"\s", host.strip())):
|
||||||
out.append(f"{who} has {S('host')} {messages.shown(host)}, which is "
|
out.append(f"{who} has `inverters.host` {messages.shown(host)}, which is "
|
||||||
"not an address. Use a name or an IP address.")
|
"not an address. Use a name or an IP address.")
|
||||||
for key, lo, hi in (("port", 1, 65535), ("unit_id", 0, 247)):
|
for key, lo, hi in (("port", 1, 65535), ("unit_id", 0, 247)):
|
||||||
val = entry.get(key)
|
val = entry.get(key)
|
||||||
if val not in (None, "") and _whole(val, lo, hi) is None:
|
if val not in (None, "") and _whole(val, lo, hi) is None:
|
||||||
out.append(f"{who} has {S(key)} {messages.shown(val)}. Use a "
|
out.append(f"{who} has `inverters.{key}` {messages.shown(val)}. Use a "
|
||||||
f"whole number from {lo} to {hi}.")
|
f"whole number from {lo} to {hi}.")
|
||||||
maint = entry.get("maintenance")
|
maint = entry.get("maintenance")
|
||||||
if maint is not None and not isinstance(maint, bool):
|
if maint is not None and not isinstance(maint, bool):
|
||||||
out.append(f"{who} has {S('maintenance')} {messages.shown(maint)}. "
|
out.append(f"{who} has `inverters.maintenance` {messages.shown(maint)}. "
|
||||||
"Use on or off.")
|
"Use on or off.")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -942,17 +942,11 @@ schema:
|
|||||||
# batteries, whereas an absent capacity has an honest answer - no opinion
|
# batteries, whereas an absent capacity has an honest answer - no opinion
|
||||||
# about the split, every unit weighs 1.0, which is what shipped.
|
# about the split, every unit weighs 1.0, which is what shipped.
|
||||||
#
|
#
|
||||||
# SMA-2. `driver` empty means goodwe_esphome, the only choice for now.
|
# ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||||
# `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
|
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||||
# latch; one with no `max_w` would have to borrow a rating, which is the
|
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||||
# sibling-grading defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's
|
# no `max_w` would have to borrow a rating, which is the sibling-grading
|
||||||
# (options block above). `setpoint_entity` WAS A FOURTH; since SMA-2 it is the
|
# defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above).
|
||||||
# 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
|
# The optional keys are the sensors, whose absence honestly means "this unit
|
||||||
# reports none", and `capacity_kwh`.
|
# reports none", and `capacity_kwh`.
|
||||||
#
|
#
|
||||||
@@ -964,18 +958,13 @@ schema:
|
|||||||
# test_inverter.py part 12 asserts the ordering is still hostile.
|
# test_inverter.py part 12 asserts the ordering is still hostile.
|
||||||
inverters:
|
inverters:
|
||||||
- name: str
|
- name: str
|
||||||
setpoint_entity: str?
|
setpoint_entity: str
|
||||||
max_w: int(100,5000)
|
max_w: int(100,5000)
|
||||||
bms_soc_floor: int(0,50)
|
bms_soc_floor: int(0,50)
|
||||||
soc_entity: str?
|
soc_entity: str?
|
||||||
batt_entity: str?
|
batt_entity: str?
|
||||||
batt_invert: bool?
|
batt_invert: bool?
|
||||||
capacity_kwh: float(0.1,200)?
|
capacity_kwh: float(0.1,200)?
|
||||||
driver: list(goodwe_esphome)?
|
|
||||||
host: str?
|
|
||||||
port: port?
|
|
||||||
unit_id: int(0,247)?
|
|
||||||
maintenance: bool?
|
|
||||||
|
|
||||||
estop_fitted: bool
|
estop_fitted: bool
|
||||||
battery_modes_enabled: bool
|
battery_modes_enabled: bool
|
||||||
|
|||||||
@@ -1536,9 +1536,8 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1)
|
|||||||
for ln in _entry9.splitlines()[1:] if ": " in ln)
|
for ln in _entry9.splitlines()[1:] if ": " in ln)
|
||||||
print(f" the shipped per-entry schema: {_decl9}")
|
print(f" the shipped per-entry schema: {_decl9}")
|
||||||
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||||
"`max_w` and `bms_soc_floor` are REQUIRED - no "
|
"`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no "
|
||||||
"trailing `?` (SMA-2: `setpoint_entity` is the GoodWe driver's own key "
|
"trailing `?` - so the Supervisor refuses a half-written entry before the "
|
||||||
"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 "
|
"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. "
|
"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 "
|
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
|
||||||
@@ -1554,14 +1553,13 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
|||||||
"refuses every options blob written before it existed",
|
"refuses every options blob written before it existed",
|
||||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||||
"soc_entity", "batt_entity", "batt_invert",
|
"soc_entity", "batt_entity", "batt_invert",
|
||||||
"capacity_kwh", "driver", "host", "port", "unit_id",
|
"capacity_kwh"}
|
||||||
"maintenance"}
|
|
||||||
and not any(_decl9[k].endswith("?")
|
and not any(_decl9[k].endswith("?")
|
||||||
for k in ("name", "max_w", "bms_soc_floor"))
|
for k in ("name", "setpoint_entity", "max_w",
|
||||||
|
"bms_soc_floor"))
|
||||||
and all(_decl9[k].endswith("?")
|
and all(_decl9[k].endswith("?")
|
||||||
for k in ("setpoint_entity", "soc_entity", "batt_entity",
|
for k in ("soc_entity", "batt_entity", "batt_invert",
|
||||||
"batt_invert", "capacity_kwh", "driver", "host",
|
"capacity_kwh")))
|
||||||
"port", "unit_id", "maintenance")))
|
|
||||||
|
|
||||||
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
|
# ⚠️ 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.
|
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
|
||||||
|
|||||||
@@ -592,22 +592,29 @@ ent = sch[sch.index("\n inverters:"):]
|
|||||||
ent = ent[:ent.index("\n\n")]
|
ent = ent[:ent.index("\n\n")]
|
||||||
decl = dict(ln.strip().lstrip("- ").split(": ", 1)
|
decl = dict(ln.strip().lstrip("- ").split(": ", 1)
|
||||||
for ln in ent.splitlines()[1:] if ": " in ln)
|
for ln in ent.splitlines()[1:] if ": " in ln)
|
||||||
check("setpoint_entity is optional per entry, beside driver, host, port, "
|
NEW_KEYS = ("driver", "host", "port", "unit_id", "maintenance")
|
||||||
"unit_id and maintenance; name, max_w and bms_soc_floor stay required",
|
check("HIDDEN FROM CLIENTS (owner, 2026-11-07): config.yaml's per-entry schema "
|
||||||
decl["setpoint_entity"] == "str?" and decl["driver"] == "list(goodwe_esphome)?"
|
"is what it was on 23ff9d0 - the five driver keys are not in it, and "
|
||||||
and decl["host"] == "str?" and decl["port"] == "port?"
|
"setpoint_entity is still required - so the Supervisor form shows "
|
||||||
and decl["unit_id"] == "int(0,247)?" and decl["maintenance"] == "bool?"
|
"nothing new",
|
||||||
and not any(decl[k].endswith("?") for k in ("name", "max_w",
|
not set(NEW_KEYS) & set(decl) and not decl["setpoint_entity"].endswith("?")
|
||||||
"bms_soc_floor")))
|
and set(decl) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||||
check("config.yaml lists exactly the selectable drivers",
|
"soc_entity", "batt_entity", "batt_invert",
|
||||||
decl["driver"] == f"list({'|'.join(SELECTABLE)})?")
|
"capacity_kwh"})
|
||||||
|
_tr = {lg: io.open(HERE / "translations" / f"{lg}.yaml", encoding="utf-8").read()
|
||||||
|
for lg in ("en", "nl")}
|
||||||
|
from app import messages as _msgs # noqa: E402
|
||||||
|
check("...and no translation, label or description was added for them",
|
||||||
|
not any(f" {k}:\n" in t.split(" estop_fitted:")[0].split(" inverters:")[1]
|
||||||
|
for t in _tr.values() for k in NEW_KEYS)
|
||||||
|
and not set(NEW_KEYS) & (set(_msgs.LABELS) | set(_msgs.LABELS_NL)))
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
print("6. round 2: census parity, setters, rule 9(d), NaN ports, hidden keys")
|
print("6. round 2: census parity, setters, rule 9(d), NaN ports, hidden keys")
|
||||||
|
|
||||||
import copy, itertools, math # noqa: E402
|
import copy, itertools, math # noqa: E402
|
||||||
from app import uilayout as L, uimodel # noqa: E402
|
from app import uimodel # noqa: E402
|
||||||
from app.validate import ACTUATOR_WRITE_KEYS # noqa: E402
|
from app.validate import ACTUATOR_WRITE_KEYS # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -708,25 +715,13 @@ for key in ("port", "unit_id"):
|
|||||||
got, ok_ = repr(e), False
|
got, ok_ = repr(e), False
|
||||||
check(f"{key} = {nm} is refused by key and does not raise", ok_)
|
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,
|
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",
|
soc_entity="sensor.s", batt_entity="sensor.b", driver="goodwe_esphome",
|
||||||
host="h", port=502, unit_id=3, maintenance=False)
|
host="h", port=502, unit_id=3, maintenance=False)
|
||||||
opts_rt = {"inverters": [ent, dict(ent, name="b", setpoint_entity="number.b")]}
|
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)))
|
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 "
|
check("an entry that carries the driver keys (from a caller that skips the "
|
||||||
"(this is how ROSTER_HIDDEN keys survive the settings page)",
|
"schema) comes back from a save through the page model unchanged",
|
||||||
back.get("inverters") == opts_rt["inverters"])
|
back.get("inverters") == opts_rt["inverters"])
|
||||||
|
|
||||||
del REGISTRY["fake"]
|
del REGISTRY["fake"]
|
||||||
|
|||||||
@@ -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 "
|
check("...and the repeatable `inverters:` section's per-entry keys were read "
|
||||||
"too, which is the half a top-level-only reader would miss",
|
"too, which is the half a top-level-only reader would miss",
|
||||||
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
||||||
and len(NESTED["inverters"]) == 13) # SMA-2: +5 driver keys
|
and len(NESTED["inverters"]) == 8)
|
||||||
|
|
||||||
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
||||||
"languages")
|
"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"per-entry keys came back out of each, which is what says the "
|
||||||
f"strictness above is not just refusing everything",
|
f"strictness above is not just refusing everything",
|
||||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 13
|
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
|
||||||
for lg in LANGS))
|
for lg in LANGS))
|
||||||
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
||||||
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
||||||
|
|||||||
@@ -51,10 +51,8 @@ def coverage_problems(order, nested):
|
|||||||
out += [f"{k} is not in config.yaml" for k in
|
out += [f"{k} is not in config.yaml" for k in
|
||||||
set(placed) | set(L.DERIVED) | set(L.HIDDEN) if k not in order]
|
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.")]
|
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", [])
|
out += [f"inverters.{k} has no card field" for k in nested.get("inverters", [])
|
||||||
if sub.count(k) + (k in L.ROSTER_HIDDEN) != 1]
|
if sub.count(k) != 1]
|
||||||
out += [f"inverters.{k} is not in config.yaml" for k in sub
|
out += [f"inverters.{k} is not in config.yaml" for k in sub
|
||||||
if k not in nested.get("inverters", [])]
|
if k not in nested.get("inverters", [])]
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ 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)
|
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||||
for title, entry, expect in (
|
for title, entry, expect in (
|
||||||
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
|
("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 max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
|
||||||
("no bms_soc_floor (SAFETY-32)",
|
("no bms_soc_floor (SAFETY-32)",
|
||||||
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
||||||
@@ -255,39 +257,22 @@ for title, entry, expect in (
|
|||||||
ok, out = validate(dict(D_BASE, inverters="[]"))
|
ok, out = validate(dict(D_BASE, inverters="[]"))
|
||||||
show("a JSON STRING where the list belongs is refused", not ok, out)
|
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 ==")
|
print("\n== K. SMA-2: the driver keys are NOT in the schema (owner, 2026-11-07) ==")
|
||||||
# `setpoint_entity` is OPTIONAL per entry since SMA-2 (it is the GoodWe driver's
|
# `driver`, `host`, `port`, `unit_id` and `maintenance` are graded by validate.py
|
||||||
# own key; validate.py rule 9(d) requires it for GoodWe), beside `driver`,
|
# rule 9(h) but kept out of config.yaml so the Configuration tab shows nothing
|
||||||
# `host`, `port`, `unit_id` and `maintenance`. Same method as §D: an ACCEPTED
|
# new. This pins both halves: the shipped entry is the one it always was, and the
|
||||||
# baseline, ONE value varied, the refusal asserted on the KEY'S NAME.
|
# Supervisor (measured) silently DROPS an unknown key from an entry, so they
|
||||||
K_FULL = dict(D_FULL, driver="goodwe_esphome", host="inverter.local", port=502,
|
# cannot arrive by the form or by YAML.
|
||||||
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]))
|
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
||||||
show("an entry from before SMA-2 (none of the new keys) is ACCEPTED unchanged",
|
show("the pre-SMA-2 entry is ACCEPTED unchanged", ok and out["inverters"] == [D_FULL], out)
|
||||||
ok and out["inverters"] == [D_FULL], out)
|
for _k in ("driver", "host", "port", "unit_id", "maintenance"):
|
||||||
ok, out = validate(dict(D_BASE, inverters=[
|
ok, out = validate(dict(D_BASE, inverters=[dict(D_FULL, **{_k: "x"})]))
|
||||||
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}]))
|
show(f"an entry carrying `{_k}` is accepted and the key is dropped",
|
||||||
show("an entry with NO setpoint_entity is now accepted by the schema "
|
ok and out["inverters"] == [D_FULL], out)
|
||||||
"(rule 9(d) refuses it for GoodWe, at startup)", ok, out)
|
show("the per-entry schema has exactly the eight keys it had",
|
||||||
# ⚠️ THE SUPERVISOR'S REFUSAL NAMES THE VALUE'S RULE, NOT THE KEY, FOR A NESTED
|
sorted(schema["inverters"][0]) == sorted(["name", "setpoint_entity", "max_w",
|
||||||
# ENTRY (§D's "at most 5000"), so the expectation is that rule's own words.
|
"bms_soc_floor", "soc_entity", "batt_entity", "batt_invert", "capacity_kwh"]),
|
||||||
for title, entry, expect in (
|
json.dumps(schema["inverters"]))
|
||||||
("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 ==")
|
print("\n== E. the ROLLBACK direction, and the operator's UI ==")
|
||||||
|
|||||||
@@ -79,21 +79,6 @@ configuration:
|
|||||||
capacity_kwh:
|
capacity_kwh:
|
||||||
name: "Usable battery capacity (kWh) (optional)"
|
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."
|
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:
|
estop_fitted:
|
||||||
name: "Hardware · RS485 emergency stop is 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."
|
description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page."
|
||||||
|
|||||||
@@ -60,21 +60,6 @@ configuration:
|
|||||||
capacity_kwh:
|
capacity_kwh:
|
||||||
name: "Bruikbare capaciteit (kWh) (optioneel)"
|
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."
|
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:
|
estop_fitted:
|
||||||
name: "Hardware · RS485-noodstop is geplaatst"
|
name: "Hardware · RS485-noodstop is geplaatst"
|
||||||
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
|
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
|
||||||
|
|||||||
Reference in New Issue
Block a user