TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner reported within minutes of it going live that they have several PV inverters and could configure one - which is ordinary for a multi-string roof, not a property of one house. `pv_entities` is the roster and the row carries the SITE total. ⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`, and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no irradiance" conclusion the channel exists to TEST. So `pv.total()` answers `None` the moment any configured entity is absent: the topic carries HA's own `None`, the entity goes to `unknown`, the recorder records a GAP. The alternative (the partial sum beside a contributor count) was implemented and run as mutation N1b; it loses because the MQTT row is read COLD a week later by an attribution that would believe it, while the qualifying count would live in a second entity with its own history. The partial information is not thrown away - the STATUS PAGE names every entity that reported, with its watts, and every one that did not, with its reason, because that surface is read LIVE by a human who wants the diagnostic. ⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is `is None`, never falsiness - a roster that blanked the site on a zero would report an absence on every clear evening. THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list: tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an 0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and deepmerge's list strategy is `override` so a `[]` default does not rescue it - the live add-on would not start. `match(.*)?` does accept both shapes and is rejected for a different, measured reason: it validates nothing (a string, a list and the integer 417 all pass) and renders as a plain text box. So `pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for `inverters:`, with a status caution saying which key won - a union would double-count the inverter the old key still names. STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than replaced and now grades five claims over three document sets. The control hash is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the roster control document is byte-identical between the two trees. 2/5 is a claim TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly as 0.11.0 does" is a real cross-tree identity over control AND telemetry. ⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit 24: a discriminator defined as "the baseline lacks this feature" dies on the release that ships the feature, and dies as a false refusal. Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B (`sum` -> `max`, the same three characters) and was run on a fresh copy with `cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO contributions in two units with absence driven at EVERY position - a member contributing 0 W could go absent without moving the sum of the rest, which is habit 23 with a coinciding value in the arithmetic rather than in the plant. 31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0, so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG entry is wanted and deliberately not written - 11b forbids `## Unreleased` and the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the material it needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -65,7 +65,7 @@ import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
from deepmerge import Merger
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
||||
from supervisor.apps.options import AppOptions
|
||||
from supervisor.apps.options import AppOptions, UiOptions
|
||||
from pathlib import Path
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
|
||||
@@ -86,13 +86,24 @@ print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema[
|
||||
# this line must not crash the probe before section B gets to REFUSE it.
|
||||
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
|
||||
|
||||
def validate(options):
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller")))
|
||||
def validate_with(sch, options):
|
||||
"""`(ok, result-or-refusal)` for ONE schema and ONE options blob.
|
||||
|
||||
⚠️ TEL-12 §G VARIES THE SCHEMA rather than only the options, which is what a
|
||||
question about a KEY'S SHAPE requires. `validate()` below is this with the
|
||||
shipped schema and is what every earlier section calls, unchanged.
|
||||
"""
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, sch, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
return True, v(copy.deepcopy(options))
|
||||
except vol.Invalid as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def validate(options):
|
||||
return validate_with(schema, options)
|
||||
|
||||
FAILED = []
|
||||
|
||||
def show(t, ok, d=""):
|
||||
@@ -554,6 +565,124 @@ for lang in ("en", "nl"):
|
||||
show(f"...and the coverage audit is clean against the real loader's view too. "
|
||||
f"Problems: {_probs}", not _probs)
|
||||
|
||||
print("\n== G. TEL-12: the SHAPE of a several-inverter PV option ==")
|
||||
# ⚠️ WHY THIS SECTION EXISTS. TEL-12 had a real choice of surface and the two
|
||||
# candidates differ on ONE question nobody could answer by reading: does
|
||||
# Supervisor accept a stored STRING for a key whose schema has become a LIST?
|
||||
# 0.11.0 shipped `pv_entity: str?`, the reference install HAS a string persisted
|
||||
# in it, and an upgrade that refuses that string leaves an add-on which will not
|
||||
# start - CFG-04 §B's trap, in the other direction. So it was measured.
|
||||
#
|
||||
# ⚠️ AND IT IS AN ISOLATED COUNTERFACTUAL LIKE §B AND §D. One key's schema
|
||||
# changes at a time, from a baseline that is ACCEPTED on the line above, and each
|
||||
# refusal is asserted ON THE KEY'S NAME rather than on `not ok` (habit 6).
|
||||
PV_STORED = {"pv_entity": "sensor.pv_power"} # what 0.11.0 persisted
|
||||
_pv_base = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV_STORED))
|
||||
ok, out = validate(_pv_base)
|
||||
show("the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED "
|
||||
"- the baseline every refusal below is varied from", ok,
|
||||
f"pv_entity={out.get('pv_entity')!r} pv_entities={out.get('pv_entities')!r}"
|
||||
if ok else out)
|
||||
|
||||
# --- OPTION 1: `pv_entity` becomes a list. THE ONE THAT LOSES. ---------------
|
||||
_s_list = dict(schema, pv_entity=["str"])
|
||||
ok_l, out_l = validate_with(_s_list, _pv_base)
|
||||
show("⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as "
|
||||
"a LIST, the string an 0.11.0 install has persisted is REFUSED, and the "
|
||||
"refusal names the key - so that shape would leave the live add-on unable "
|
||||
"to start on upgrade",
|
||||
(not ok_l) and "Invalid list for option 'pv_entity'" in str(out_l), out_l)
|
||||
# ⚠️ AND THE `[]` DEFAULT DOES NOT RESCUE IT, which is the half somebody would
|
||||
# reach for. `_OPTIONS_MERGER`'s list strategy is OVERRIDE (CFG-04 §4), so the
|
||||
# operator's string simply stands.
|
||||
_d_list = dict(defaults, pv_entity=[])
|
||||
_m_list = MERGER.merge(copy.deepcopy(_d_list), copy.deepcopy(PV_STORED))
|
||||
ok_l2, out_l2 = validate_with(_s_list, _m_list)
|
||||
show("...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's "
|
||||
"list strategy is `override`, so the operator's string survives the merge "
|
||||
"and is refused anyway",
|
||||
_m_list["pv_entity"] == "sensor.pv_power" and not ok_l2, out_l2)
|
||||
ok_l3, out_l3 = validate_with(_s_list, dict(_pv_base, pv_entity=["sensor.a"]))
|
||||
show("...(the list shape itself is fine - it is only the STORED STRING it "
|
||||
"refuses, which is what makes this a migration and not a schema question)",
|
||||
ok_l3, out_l3 if not ok_l3 else "a list value validates")
|
||||
|
||||
# --- OPTION 1b: one key, both shapes. ACCEPTED, AND STILL WRONG. -------------
|
||||
# ⚠️ REPORTED EVEN THOUGH IT PASSES, because "no single spelling accepts both"
|
||||
# would have been a FALSE reason to reject option 1 - and MUTATION.md's habit
|
||||
# about inventing justifications applies to a probe as much as to a card.
|
||||
_s_match = dict(schema, pv_entity="match(.*)?")
|
||||
try:
|
||||
SCHEMA_APP_CONFIG(dict(raw, schema=dict(raw["schema"],
|
||||
pv_entity="match(.*)?")))
|
||||
_manifest_ok = True
|
||||
except vol.Invalid as e:
|
||||
_manifest_ok, _me = False, str(e)
|
||||
_ok_s, _ = validate_with(_s_match, _pv_base)
|
||||
_ok_li, _ = validate_with(_s_match, dict(_pv_base, pv_entity=["a", "b"]))
|
||||
_ok_num, _ = validate_with(_s_match, dict(_pv_base, pv_entity=417))
|
||||
_ui_match = [e for e in UiOptions(None)(_s_match)
|
||||
if e.get("name") == "pv_entity"]
|
||||
show("⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR "
|
||||
"A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, "
|
||||
"a list passes, and so does the integer 417; and the Configuration tab "
|
||||
"renders it as a plain text box rather than a repeatable field, so an "
|
||||
"operator cannot enter a second inverter in the UI at all",
|
||||
_manifest_ok and _ok_s and _ok_li and _ok_num
|
||||
and _ui_match and _ui_match[0].get("multiple") is not True,
|
||||
f"string={_ok_s} list={_ok_li} integer-417={_ok_num} "
|
||||
f"ui={json.dumps(_ui_match)}")
|
||||
|
||||
# --- OPTION 2: `pv_entities` beside `pv_entity`. WHAT SHIPPED. ---------------
|
||||
show("⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no "
|
||||
"`pv_entities` key at all, because it did not exist when that blob was "
|
||||
"written - validates against THIS manifest, with the new key defaulted to "
|
||||
"the empty list. The live install upgrades without touching its options",
|
||||
ok and out.get("pv_entity") == "sensor.pv_power"
|
||||
and out.get("pv_entities") == [])
|
||||
_three = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
ok3, out3 = validate(MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(PV_STORED, pv_entities=_three)))
|
||||
show("...and a three-entity roster validates, per-entry, with both keys set - "
|
||||
"which the add-on resolves by REPLACEMENT and says so on its status page",
|
||||
ok3 and out3.get("pv_entities") == _three,
|
||||
json.dumps(out3.get("pv_entities")) if ok3 else out3)
|
||||
# ⚠️ §B's LOAD-BEARING MEASUREMENT, FOR THIS KEY. Exactly one key varies.
|
||||
_no_pve = {k: v for k, v in defaults.items() if k != "pv_entities"}
|
||||
ok_np, out_np = validate(_no_pve)
|
||||
show("⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is "
|
||||
"REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the "
|
||||
"`pv_entities: []` default is load-bearing, exactly as `inverters: []` is: "
|
||||
"Supervisor treats a nested list as required unless its element is an "
|
||||
"OPTIONAL scalar, and `- str` is not one",
|
||||
(not ok_np) and "Missing option 'pv_entities' in root" in str(out_np),
|
||||
out_np)
|
||||
_ui_pv = [e for e in UiOptions(None)(schema)
|
||||
if e.get("name") in ("pv_entity", "pv_entities")]
|
||||
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
||||
"(`multiple: true`), so an operator adds their second and third inverter "
|
||||
"in the UI rather than in YAML",
|
||||
[e["name"] for e in _ui_pv] == ["pv_entity", "pv_entities"]
|
||||
and _ui_pv[1].get("multiple") is True
|
||||
and _ui_pv[0].get("multiple") is not True, json.dumps(_ui_pv))
|
||||
# --- the ROLLBACK direction, as §E does for `inverters` ----------------------
|
||||
_flat = {k: v for k, v in schema.items() if k != "pv_entities"}
|
||||
try:
|
||||
_rolled = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, _flat, cfg["name"], "local_goodwe_controller")))(
|
||||
MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(PV_STORED, pv_entities=_three)))
|
||||
show("a DOWNGRADE to a schema without `pv_entities` drops the key with a "
|
||||
"warning rather than refusing, and leaves `pv_entity` standing - so "
|
||||
"rolling back to 0.11.0 returns the install to its single entity "
|
||||
"rather than bricking it",
|
||||
"pv_entities" not in _rolled
|
||||
and _rolled.get("pv_entity") == "sensor.pv_power")
|
||||
except vol.Invalid as e:
|
||||
show("a DOWNGRADE to a schema without `pv_entities` drops the key", False,
|
||||
str(e))
|
||||
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
Reference in New Issue
Block a user