checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the fourth is the same defect this card is about, one level up. ⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change dropping the required count to 20 would have passed while the sentence beside it still said 59 - a number displayed but not graded, which is exactly what let UX-01's AC 3 survive review in the first place, and exactly the shape `test_translations.py` was already fixed for. Now DERIVED and graded in two separate assertions, because they are two different claims: 1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list optional - asserted EQUAL to Supervisor's required count. The printed number is computed, so it cannot go stale. 2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not enough and I checked why: both sides read the same `?` convention, so marking a key optional moves BOTH and it stays green - measured, by making `slew_w` optional and watching it pass. That grades Supervisor agreeing with us, NOT the thing AC 3 was withdrawn for. The second assertion grades the premise: the floor is still far above the ~15 AC 3 asked for, and if it ever is not, the withdrawal needs re-arguing rather than re-asserting. Driven, both directions, against the real 2026.08.0 image: clean PASS "floor is DERIVED: 71 schema keys minus 12 optional ones is 59, and Supervisor's own required flag agrees" premise threshold 15 -> 40 FAIL "the floor of 59 is still far above the ~40 AC 3 asked for" Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`, which was never the filename shipped, and `DOCS.md` still saying section F "prints" what it now grades. Not changed, and review agreed both are disclosed rather than defects: the pair-keyed ledger cannot see a SECOND site for a pair it already holds (the site count can grow while the pair set cannot), and the probe's assertions run by hand rather than in CI because they need the Supervisor image - the same status as before this card, not a regression. Suite: 27 files, all pass. Probe: all cases passed on ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
567 lines
31 KiB
Python
567 lines
31 KiB
Python
"""CFG-04: does Supervisor accept a list-of-objects option? MEASURED, not assumed.
|
|
|
|
⚠️ WHY THIS SCRIPT EXISTS AND WHY IT IS COMMITTED. config.yaml had no
|
|
list-of-objects option anywhere before CFG-04, and `static_profile` carries a
|
|
structured value as a JSON STRING in a `str?` precisely because nobody knew
|
|
whether the Supervisor's options validator supported a nested one. That is a
|
|
question about a specific version of a specific piece of somebody else's
|
|
software, and the honest way to answer it is to run that software.
|
|
|
|
WHAT IT RUNS. Supervisor's OWN `AppOptions` validator and its OWN
|
|
`SCHEMA_APP_CONFIG`, out of the release image, against THIS repository's
|
|
config.yaml. Nothing here is a re-implementation of the validation rules; if
|
|
Supervisor changes them, this script changes its answer.
|
|
|
|
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
|
2026.08.0 is what 192.168.2.158 ran when CFG-04 landed):
|
|
|
|
docker run --rm \
|
|
-v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" -v /tmp/out:/out \
|
|
--entrypoint python3 \
|
|
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
|
/probe/supervisor_shape_probe.py
|
|
|
|
⚠️ IT TOUCHES NOTHING. It reads config.yaml and runs a validator in a throwaway
|
|
container. It does not talk to the live Supervisor, does not POST options and
|
|
does not install anything - the schema conflict in docs/deploy-rollback.md §4 is
|
|
exactly what you do NOT want to discover on a running site.
|
|
|
|
WHAT IT ANSWERED, 2026-09-04, on 2026.08.0 - the full output is in
|
|
DECISIONS/CFG-04-inverter-roster.md:
|
|
|
|
A. The manifest, list-of-objects schema and all, is ACCEPTED.
|
|
B. The shipped `options:` block validates as it stands, and the SAME block
|
|
with ONLY `inverters` removed is refused BY NAME - `Missing option
|
|
'inverters' in root`. That is the one measurement the `inverters: []`
|
|
default rests on, and it is an ISOLATED counterfactual: exactly one key
|
|
varies between the two runs, so the refusal cannot be about anything else.
|
|
B2. A reconstructed pre-0.6.0 options blob, merged with those defaults,
|
|
validates. Its RAW form is refused - but for whichever required key the
|
|
reconstruction happens to lack FIRST, which is an artefact of the
|
|
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
|
C. A two-inverter blob validates, per-entry types enforced.
|
|
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
|
|
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
|
|
own name asserted in the refusal - and a JSON string where the list belongs
|
|
is refused outright.
|
|
|
|
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
|
|
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
|
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
|
now carries the key and §D is an isolated counterfactual asserting the key name.
|
|
|
|
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
|
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
|
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
|
together by hand from two runs is not a measurement, so this one was measured:
|
|
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
|
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
|
differs from A's 10 so that the per-entry number is exercised at all, while
|
|
production is expected to have BOTH packs at 10 %.
|
|
"""
|
|
import sys, json, copy
|
|
sys.path.insert(0, "/usr/src/supervisor")
|
|
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 pathlib import Path
|
|
from supervisor.utils.yaml import read_yaml_file
|
|
|
|
MERGER = Merger(type_strategies=[(dict, ["merge"])],
|
|
fallback_strategies=["override"], type_conflict_strategies=["override"])
|
|
|
|
raw = read_yaml_file(Path("/w/config.yaml"))
|
|
print("== A. the whole config.yaml through SCHEMA_APP_CONFIG ==")
|
|
try:
|
|
cfg = SCHEMA_APP_CONFIG(raw)
|
|
print(" PASS Supervisor accepts the add-on manifest, version", cfg["version"])
|
|
except vol.Invalid as e:
|
|
print(" FAIL ", humanize_error(raw, e)); sys.exit(1)
|
|
|
|
schema, defaults = cfg["schema"], cfg["options"]
|
|
print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema["inverters"]))
|
|
# .get, not [], deliberately: if someone deletes the `inverters: []` default
|
|
# 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")))
|
|
try:
|
|
return True, v(copy.deepcopy(options))
|
|
except vol.Invalid as e:
|
|
return False, str(e)
|
|
|
|
FAILED = []
|
|
|
|
def show(t, ok, d=""):
|
|
print(f" {'PASS' if ok else 'FAIL'} {t}")
|
|
if d: print(" " + str(d)[:300])
|
|
if not ok: FAILED.append(t)
|
|
|
|
# The live install's persisted options, reconstructed from HANDOVER.md §1 and
|
|
# docs/deploy-rollback.md §0/§B.5. No `inverters` key: it did not exist.
|
|
LIVE = {
|
|
"meter_entity": "sensor.p1_meter_active_power", "meter_invert": False,
|
|
"soc_entity": "sensor.goodwe_battery_soc", "batt_entity": "sensor.goodwe_battery_power",
|
|
"batt_invert": False,
|
|
"setpoint_entity": "number.kelder_goodwe_master_stage1_goodwe_setpoint_w",
|
|
"meter_source": "homewizard_local", "meter_phases": 1, "meter_max_age_s": 30,
|
|
"meter_poll_s": 5, "p1_host": "192.168.2.250",
|
|
"max_w": 5000, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, "target_grid_w": -10,
|
|
"step_w": 10, "saturation_w": 500, "saturation_cycles": 3, "integrator_max_w": 0,
|
|
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 15, "auto_start": False,
|
|
"maintenance_enabled": False, "estop_fitted": True, "log_level": "info",
|
|
}
|
|
|
|
print("\n== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==")
|
|
# ⚠️ THIS IS AN ISOLATED COUNTERFACTUAL AND IT HAS TO STAY ISOLATED. The claim
|
|
# the `inverters: []` default rests on is about ONE key, so exactly ONE key
|
|
# varies between these two runs: the full shipped `options:` block, and the same
|
|
# block with `inverters` removed and nothing else touched. Feeding the validator
|
|
# some other, INCOMPLETE blob would also produce a refusal - but about whichever
|
|
# required key that blob lacks first, which is a different measurement wearing
|
|
# this one's label. §B2 below is that other blob, and it is labelled as such.
|
|
ok, out = validate(dict(defaults))
|
|
show("the shipped `options:` block validates AS IT STANDS - so anything refused "
|
|
"on the next line is refused for the one key that was removed", ok,
|
|
"" if ok else out)
|
|
_no_inv = {k: v for k, v in defaults.items() if k != "inverters"}
|
|
ok_ni, out_ni = validate(_no_inv)
|
|
# ⚠️ THE ASSERTION IS ON THE KEY NAME, NOT ON `not ok`, AND THAT IS THE POINT.
|
|
# A refusal is only evidence for THIS key if it NAMES this key. Asserted as a
|
|
# bare `not ok`, this line would go on printing PASS after someone deleted
|
|
# `inverters: []` from config.yaml - blind to the exact regression it is the
|
|
# committed evidence for. Asserted on the string, it goes red: with the default
|
|
# gone the line above fails too, because then nothing distinguishes the two runs.
|
|
_names_it = (not ok_ni) and "Missing option 'inverters' in root" in str(out_ni)
|
|
show("...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - "
|
|
"which is every options blob written before 0.6.0, since the key did not "
|
|
"exist when they were written", _names_it, out_ni)
|
|
|
|
print("\n== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==")
|
|
# ⚠️ WHAT THIS SECTION IS AND IS NOT. LIVE is an INCOMPLETE reconstruction - it
|
|
# carries the keys HANDOVER.md and deploy-rollback.md record and no others, so
|
|
# it is missing many required keys that have nothing to do with this card. Its
|
|
# raw refusal is therefore about whichever of THOSE the validator reaches first
|
|
# (it has printed `meter_freeze_s` and `p1_phase_export_entities` on different
|
|
# runs), NOT about `inverters`. It is kept because the MERGED half is a real
|
|
# demonstration that `deepmerge(options:, stored)` is what App.options is; the
|
|
# raw half is printed only so the merge has something to be contrasted with.
|
|
# The `inverters` measurement is §B, where one key and only one key varies.
|
|
ok, out = validate(LIVE)
|
|
show("the reconstructed raw blob is REFUSED - for an unrelated missing key, "
|
|
"because the reconstruction is incomplete; see the refusal printed below "
|
|
"and §B for the `inverters` measurement", not ok, out)
|
|
merged = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(LIVE))
|
|
ok, out = validate(merged)
|
|
show("the same blob MERGED with these defaults - what App.options actually is", ok,
|
|
f"inverters={out.get('inverters')!r} setpoint_entity={out.get('setpoint_entity')!r} "
|
|
f"max_w={out.get('max_w')!r}" if ok else out)
|
|
|
|
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
|
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
|
|
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
|
|
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
|
|
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
|
|
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
|
|
# nameplate is wrong for this purpose too, because nothing will be connected to
|
|
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
|
|
#
|
|
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
|
|
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
|
|
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
|
|
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
|
|
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
|
|
# read off the unit. So this blob is what an operator would POST TODAY, not a
|
|
# claim about what inverter B will turn out to do.
|
|
#
|
|
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
|
|
# This file's own §B is the record of what an instrument that cannot distinguish
|
|
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
|
|
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
|
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
|
# pair instead of the real one.
|
|
#
|
|
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
|
# blob is also what proves an operator can still POST a two-inverter config
|
|
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
|
# not exercise a per-entry number at all, for the same reason the two ratings
|
|
# must stay different.
|
|
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
|
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
|
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
|
# - there is no second pack on this site, and in production BOTH packs are
|
|
# expected to floor at 10 %, which is why the differing pair lives here and in
|
|
# the fixtures rather than in config.yaml's example.
|
|
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
|
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
|
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
|
"max_w": 5000, "bms_soc_floor": 10},
|
|
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
|
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
|
"max_w": 4600, "bms_soc_floor": 15}])
|
|
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
|
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
|
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
|
# whole job is the verdicts above, and a missing /out must not fail it.
|
|
try:
|
|
json.dump(out if ok else {}, open("/out/two_inverter_options.json", "w"), indent=1)
|
|
except OSError:
|
|
pass
|
|
|
|
print("\n== D. what the schema itself refuses, per entry ==")
|
|
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
|
|
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
|
|
# section. These entries used to omit several keys at once and assert a bare
|
|
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
|
|
# on printing PASS while the refusal it printed said `Missing option
|
|
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
|
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
|
# key - or, for the range case, moves exactly one value out of range.
|
|
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
|
|
"bms_soc_floor": 10}
|
|
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
|
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
|
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
|
|
# is the provenance problem §B2 is kept as a warning about.
|
|
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
|
|
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
|
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"),
|
|
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
|
|
("bms_soc_floor over the range (SAFETY-32)",
|
|
dict(D_FULL, bms_soc_floor=99), "at most 50")):
|
|
ok, out = validate(dict(D_BASE, inverters=[entry]))
|
|
show(f"an entry with {title} is refused, and the refusal NAMES it",
|
|
(not ok) and expect in str(out), out)
|
|
ok, out = validate(dict(D_BASE, inverters="[]"))
|
|
show("a JSON STRING where the list belongs is refused", not ok, 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
|
|
# downgrade, meeting options that carry an `inverters` list.
|
|
_flat_schema = {k: v for k, v in schema.items() if k != "inverters"}
|
|
_v = vol.Schema(vol.All(dict, AppOptions(None, _flat_schema, cfg["name"],
|
|
"local_goodwe_controller")))
|
|
try:
|
|
_rolled = _v(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
|
show("a schema WITHOUT `inverters` drops the key with a warning rather "
|
|
"than refusing - so a downgrade is not the §B trap in reverse",
|
|
"inverters" not in _rolled, sorted(_rolled)[:6])
|
|
except vol.Invalid as e:
|
|
show("a schema WITHOUT `inverters` drops the key", False, str(e))
|
|
|
|
from supervisor.apps.options import UiOptions # noqa: E402
|
|
try:
|
|
_ui = [e for e in UiOptions(None)(schema) if e.get("name") == "inverters"]
|
|
show("Supervisor's UI renderer turns it into a repeatable section rather "
|
|
"than choking - an operator can add the second inverter in the "
|
|
"Configuration tab, which the JSON-string alternative could not offer",
|
|
bool(_ui) and _ui[0].get("multiple") is True, json.dumps(_ui))
|
|
except Exception as e: # noqa: BLE001
|
|
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
|
|
|
|
|
|
print("\n== F. UX-01: the TRANSLATED Configuration tab, rendered ==")
|
|
# ⚠️ WHAT THIS SECTION MEASURES, AND WHY IT IS NOT AN ASSERTION ABOUT A FILE.
|
|
# UX-01 adds translations/en.yaml and translations/nl.yaml. Whether they reach
|
|
# an operator depends on two pieces of somebody else's software, not on us:
|
|
#
|
|
# 1. SUPERVISOR decides whether a file is loaded at all.
|
|
# `supervisor/store/data.py:_read_app_translations()` globs this directory,
|
|
# keys each file by its STEM, and runs it through `SCHEMA_APP_TRANSLATIONS`.
|
|
# ⚠️ A FILE THAT FAILS THAT SCHEMA IS DROPPED WITH A LOG WARNING AND
|
|
# NOTHING ELSE: `except (ConfigurationFileError, vol.Invalid): _LOGGER.
|
|
# warning(...); continue`. The add-on then renders raw keys and the only
|
|
# trace is one line in the Supervisor log. So the loader is RUN here, out
|
|
# of the release image, rather than the schema being re-implemented.
|
|
# 2. THE FRONTEND decides what it does with what Supervisor loaded. The
|
|
# resolution below is not invented: it is transcribed from the shipped
|
|
# bundle in THIS image, `api/panel/frontend_latest/5680.*.js`
|
|
# (frontend 20250925.1), which reads
|
|
#
|
|
# _getTranslationEntry(lang,s,o){let e=this.addon.translations[lang]?.
|
|
# configuration; if(e){if(o?.path)for(const t of o.path)
|
|
# if(e=e[t]?.fields,!e)return; return e[s.name]}}
|
|
# computeLabel =(s,_,o)=>this._getTranslationEntry(this.hass.language,s,o)?.name
|
|
# ||this._getTranslationEntry("en",s,o)?.name||s.name
|
|
# computeHelper=(s,o) =>this._getTranslationEntry(this.hass.language,s,o)?.description
|
|
# ||this._getTranslationEntry("en",s,o)?.description||""
|
|
# _filteredSchema=(opts,schema)=>schema.filter(e=>e.name in opts||e.required)
|
|
#
|
|
# i.e. the fallback is PER KEY, en is the second chance, and the raw key
|
|
# name is the third.
|
|
from supervisor.apps.validate import SCHEMA_APP_TRANSLATIONS # noqa: E402
|
|
from supervisor.store.data import _read_app_translations # noqa: E402
|
|
|
|
TRANSLATIONS = _read_app_translations(Path("/w"))
|
|
show("Supervisor's OWN translation loader accepts both files - a file that "
|
|
"fails SCHEMA_APP_TRANSLATIONS is DROPPED with a log warning and the tab "
|
|
"silently goes back to raw keys, so 'it is on disk' is not the question",
|
|
set(TRANSLATIONS) == {"en", "nl"}, sorted(TRANSLATIONS))
|
|
|
|
ui = UiOptions(None)(schema)
|
|
print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
|
|
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
|
|
"SCHEMA BLOCK'S and not the options block's)")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
|
|
#
|
|
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
|
|
# HA's "show unused optional configuration options" toggle. The line above has
|
|
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
|
|
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
|
|
# displaying the number that contradicted the criterion it was evidence for.
|
|
# That is the defect, not the count.
|
|
#
|
|
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
|
|
# this section and replayed rather than paraphrased:
|
|
#
|
|
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
|
|
# || e.required)
|
|
#
|
|
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
|
|
# stored blob after `options:` has been deep-merged into it - so a key is
|
|
# hideable only when it is BOTH absent from `options:` AND not required.
|
|
#
|
|
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
|
|
#
|
|
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
|
|
# `required` on every non-`?` key, and the filter above shows a required
|
|
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
|
|
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
|
|
# is the required count below. It is nowhere near 15. No arrangement of
|
|
# `options:` can reach the target; the criterion was never satisfiable.
|
|
#
|
|
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
|
|
# absent from `options:` has nothing for the Supervisor to merge into an
|
|
# existing box's stored blob. That is exactly the `delivery_hold_s`
|
|
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
|
|
# cannot contain resolves to its `options:` default instead of failing
|
|
# validation, and moving it out of `options:` is precisely how you get the
|
|
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
|
|
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
|
|
# ledgers into the LOAD-BEARING source of those defaults on a live install
|
|
# - which is the CFG-03 defect the same card's AC 4 exists to police.
|
|
#
|
|
# So: four fewer clicks, bought with the upgrade path and with the defaults
|
|
# pin. The decision is written up in
|
|
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
|
|
# and the three assertions below are what keeps it a decision rather than an
|
|
# omission - if anyone empties `options:` to shorten the form, this goes red.
|
|
_hideable = sorted(set(schema) - set(defaults))
|
|
_required = [n["name"] for n in ui if n.get("required")]
|
|
|
|
|
|
def _filtered(opts):
|
|
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
|
|
return [n for n in ui if n["name"] in opts or n.get("required")]
|
|
|
|
|
|
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
|
|
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
|
|
f"is not partially met, it is unstarted. Hideable: {_hideable}",
|
|
not _hideable and len(schema) == len(defaults))
|
|
show(f"...so replaying the frontend's own _filteredSchema on the served options "
|
|
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
|
|
len(_filtered(defaults)) == len(ui))
|
|
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
|
|
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
|
|
# the required count to 20 would have passed while the sentence beside it stayed
|
|
# wrong. That is the same shape as the count this repo already fixed in
|
|
# `test_translations.py`: a number displayed but not graded.
|
|
#
|
|
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
|
|
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
|
|
# `UiOptions` marks optional because it is a list - and the assertion is an
|
|
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
|
|
_OPTIONAL = {k for k, v in schema.items()
|
|
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
|
|
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
|
|
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
|
|
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
|
|
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
|
|
_EXPECT_REQ == len(_required))
|
|
|
|
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
|
|
# deliberately NOT enough on its own: both sides read the same `?` convention,
|
|
# so marking a key optional moves BOTH and it stays green. That is correct - it
|
|
# makes the printed number impossible to leave stale - but it grades Supervisor
|
|
# agreeing with us, not the thing AC 3 was withdrawn for.
|
|
#
|
|
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
|
|
# enough keys ever became optional that the floor approached 15, the withdrawal
|
|
# stops being true and this file should say so rather than keep printing a
|
|
# sentence that has quietly become wrong.
|
|
_AC3_ASKED = 15
|
|
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
|
|
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
|
|
f"goes red the withdrawal needs re-arguing, not re-asserting",
|
|
_EXPECT_REQ > 2 * _AC3_ASKED)
|
|
|
|
|
|
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
|
|
# flag rather than a count typed here. `options:` is emptied and NOTHING else
|
|
# varies, so the answer cannot be about anything but the merge.
|
|
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
|
|
f"expressible - the tab still renders {len(_filtered({}))} required "
|
|
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
|
|
f"withdrawn as unsatisfiable, not deferred.",
|
|
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
|
|
|
|
|
|
|
|
|
|
|
|
def _entry(lang, name, path=()):
|
|
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
|
if not node:
|
|
return None
|
|
for step in path:
|
|
node = (node.get(step) or {}).get("fields")
|
|
if not node:
|
|
return None
|
|
return node.get(name)
|
|
|
|
|
|
def _label(lang, name, path=()):
|
|
for lg in (lang, "en"):
|
|
e = _entry(lg, name, path)
|
|
if e and e.get("name"):
|
|
return e["name"], lg
|
|
return name, "RAW KEY"
|
|
|
|
|
|
def _helper(lang, name, path=()):
|
|
for lg in (lang, "en"):
|
|
e = _entry(lg, name, path)
|
|
if e and e.get("description"):
|
|
return e["description"], lg
|
|
return "", "NONE"
|
|
|
|
|
|
# ⚠️ EVERY FIELD, PRINTED. This is the transcript the card asks for: what the
|
|
# owner's Supervisor puts on the screen, not a claim that it would look nice.
|
|
for lang in ("en", "nl"):
|
|
print(f"\n --- as rendered at hass.language == {lang!r} "
|
|
f"(the owner's HA runs Europe/Brussels) ---")
|
|
for node in ui:
|
|
lbl, src = _label(lang, node["name"])
|
|
hlp, hsrc = _helper(lang, node["name"])
|
|
flag = "" if src == lang else f" [name from {src}]"
|
|
print(f" {node['name']:28s} -> {lbl}{flag}")
|
|
print(f" {'':28s} {hlp[:150]}{'...' if len(hlp) > 150 else ''}")
|
|
for sub in node.get("schema", ()):
|
|
slbl, ssrc = _label(lang, sub["name"], (node["name"],))
|
|
print(f" · {sub['name']:24s} -> {slbl}"
|
|
+ ("" if ssrc == lang else f" [{ssrc}]"))
|
|
|
|
_raw = [n["name"] for n in ui if _label("en", n["name"])[1] == "RAW KEY"]
|
|
show("NOT ONE top-level field falls through to its raw key name in `en` - "
|
|
f"which is what the tab rendered for all {len(ui)} of them before this "
|
|
f"card, because there was no translations/ directory at all. Raw: {_raw}",
|
|
not _raw)
|
|
_nohelp = [n["name"] for n in ui if not _helper("en", n["name"])[0]]
|
|
show("...and not one is left without a description, which is the half that "
|
|
f"says WHETHER TO TOUCH IT. Missing: {_nohelp}", not _nohelp)
|
|
_nl_missing = [n["name"] for n in ui if _label("nl", n["name"])[1] != "nl"]
|
|
show("...and every one of them is translated in `nl` too, so nothing on the "
|
|
f"owner's own screen falls back to English. Falling back: {_nl_missing}",
|
|
not _nl_missing)
|
|
|
|
# ⚠️ THE HONEST HALF, AND IT IS A NEGATIVE RESULT. `inverters` is a
|
|
# list-of-objects, so UiOptions gives it type "schema" WITH multiple: True, and
|
|
# `_convertSchemaElement` sends exactly that case to a SELECTOR rather than to a
|
|
# nested ha-form:
|
|
#
|
|
# if("schema"===t.type&&!t.multiple) return {...type:"expandable"...} <- not us
|
|
# if("schema"===t.type){const o={};for(const a of t.schema)
|
|
# o[a.name]={required:a.required,selector:...}; return {object:{multiple:t.multiple,fields:o}}}
|
|
#
|
|
# and ha-selector-object labels those fields with
|
|
#
|
|
# computeLabel=e=>{const t=this.selector.object?.translation_key;
|
|
# if(this.localizeValue&&t){...}
|
|
# return this.selector.object?.fields?.[e.name]?.label||e.name}
|
|
#
|
|
# `_convertSchemaElementToSelector` sets neither `label` nor `translation_key`,
|
|
# so ON THIS FRONTEND the per-entry keys inside the repeatable section render as
|
|
# `setpoint_entity`, `max_w`, `bms_soc_floor` - RAW - however well they are
|
|
# translated here. The `fields:` block is still shipped and still validated,
|
|
# because it is the shape Supervisor's own schema defines for exactly this and
|
|
# it is what a frontend that wires `label` through would consume; but this card
|
|
# must not claim it reaches the operator today, and the count below is asserted
|
|
# so that the day it changes, this transcript says so.
|
|
_multi = [n for n in ui if n.get("multiple") and n.get("type") == "schema"]
|
|
show("`inverters` is rendered as a REPEATABLE SECTION (type schema, multiple), "
|
|
"which is CFG-04's measurement re-confirmed",
|
|
[n["name"] for n in _multi] == ["inverters"], json.dumps(_multi)[:200])
|
|
show("⚠️ MEASURED AND NEGATIVE: this frontend gives that section's per-entry "
|
|
"keys to ha-selector-object, which labels them `fields[name].label || "
|
|
"name` - and the converter sets no `label` and no `translation_key`. So "
|
|
"the per-entry translations below are SHIPPED AND VALIDATED but do NOT "
|
|
"reach the screen on 20250925.1. The top-level `inverters` label and "
|
|
"description DO.",
|
|
bool(_multi) and all("label" not in s and "translation_key" not in s
|
|
for n in _multi for s in n.get("schema", ())),
|
|
"per-entry keys as rendered today: "
|
|
+ ", ".join(s["name"] for n in _multi for s in n.get("schema", ())))
|
|
|
|
# ⚠️ AND THE HAND PARSER CI USES IS GRADED AGAINST THIS LOADER, KEY BY KEY.
|
|
# test_translations.py runs in the add-on's CI image, which has no PyYAML, so it
|
|
# parses these files with tools/translations_lint.py. A hand parser that
|
|
# disagrees with the real loader is a check whose data cannot reach the failure
|
|
# it names, so the two are compared here - where both are available - rather
|
|
# than assumed to agree.
|
|
sys.path.insert(0, "/probe")
|
|
import translations_lint as _tl # noqa: E402
|
|
_mismatch = []
|
|
for lang in ("en", "nl"):
|
|
hand = _tl.parse_translation_file(f"/w/translations/{lang}.yaml")
|
|
real = {"configuration": TRANSLATIONS[lang].get("configuration", {})}
|
|
# SCHEMA_APP_TRANSLATIONS fills `description: None` for an absent one; the
|
|
# hand parser simply has no key. Normalise that ONE difference and nothing
|
|
# else, so a real disagreement cannot hide behind the normalisation.
|
|
def _norm(d):
|
|
return {k: {kk: vv for kk, vv in v.items() if vv is not None
|
|
and kk != "fields"}
|
|
| ({"fields": _norm(v["fields"])} if v.get("fields") else {})
|
|
for k, v in d.items()}
|
|
if _norm(hand.get("configuration", {})) != _norm(real["configuration"]):
|
|
a, b = _norm(hand.get("configuration", {})), _norm(real["configuration"])
|
|
_mismatch += [f"{lang}:{k}" for k in sorted(set(a) | set(b))
|
|
if a.get(k) != b.get(k)]
|
|
show("the hand parser CI uses (tools/translations_lint.py) returns EXACTLY "
|
|
"what Supervisor's own loader returns, for both files, key by key and "
|
|
f"string by string. Disagreements: {_mismatch}", not _mismatch)
|
|
_cfg_text = Path("/w/config.yaml").read_text(encoding="utf-8")
|
|
_probs = []
|
|
for lang in ("en", "nl"):
|
|
_probs += _tl.audit(
|
|
_cfg_text,
|
|
{"configuration": TRANSLATIONS[lang].get("configuration", {})}, lang)
|
|
show(f"...and the coverage audit is clean against the real loader's view too. "
|
|
f"Problems: {_probs}", not _probs)
|
|
|
|
# ⚠️ 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.
|
|
print()
|
|
if FAILED:
|
|
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
|
for t in FAILED:
|
|
print(" - " + t)
|
|
sys.exit(1)
|
|
print("== all cases passed ==")
|