checks / unit (pull_request) Successful in 5m1s
The add-on had NO translations/ directory, so Home Assistant fell back to rendering the raw option KEYS. An operator opening Configuration read `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` - seventy of them, in file order, with no description and no way to tell a constant derived from 1890 closed-loop test runs from their own inverter's rating. ⚠️ PRESENTATION ONLY. Not one default, not one schema range and not one line of behaviour moved. The 175 non-comment lines of config.yaml are byte-identical to release/1.0's, reordered; the only comment lines that changed are the group banners and five sentences whose "above"/"below" the reorder made false. * translations/en.yaml and translations/nl.yaml: a name and a description for all 70 options and all 7 per-entry keys of the `inverters:` repeatable section. The Dutch is written for a Flemish homeowner - afname/injectie, kwartierpiek, capaciteitstarief - not transliterated from the English. * ⭐ Every derived constant says WHERE IT CAME FROM and whether to touch it. `unit_sign_disagree_s` cites its 1890 closed-loop runs (SAFETY-30); `circulation_w` its 924 runs and the measured 400 W knee (SAFETY-31); `meter_freeze_s` its 2399 s capture (SAFETY-18); `bms_soc_floor` register 0x0560. The two numbers that are the operator's - max_w and peak_cap_w - say "THIS IS YOUR NUMBER". `cycle_cost_threshold_eur`, which is neither, says it is an open product question (OPT-08). * config.yaml reordered into task order in BOTH halves, comments carried with their keys: hardware -> meter -> ratings and limits -> tariff -> maintenance -> safety -> advanced. ⚠️ test_inverter.py:1788's trap is PRESERVED: the nested per-entry `max_w:` still precedes the flat one under `schema:`, and `inverters: []` still ships in the `options:` half. Moving the nested block below the flat one is a 1-byte edit; it was run and goes red in test_inverter.py and test_translations.py. * tools/supervisor_shape_probe.py section F MEASURES the result against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 - Supervisor's own _read_app_translations(), its own UiOptions, and the frontend's label resolution transcribed from the shipped bundle - and prints every rendered row in both languages. `all cases passed`, exit 0. * ⚠️ ONE MEASURED NEGATIVE RESULT, NOT GLOSSED: the per-entry keys inside the `inverters:` section still render RAW on frontend 20250925.1, because a list-of-objects goes to ha-selector-object and the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated - that is Supervisor's own shape for this - but this card does not claim they reach the screen. The `inverters` label and description do. * test_translations.py: a translation naming a non-existent option, or an option with no translation, fails CI - both directions, both languages, top level and per entry. Mutation-tested BOTH WAYS: eleven in-process mutations each required to NAME the key they broke, plus a negative control; six more run on disk through the purge-and-re-read protocol. Two of the eleven are the ones that cost the WHOLE FILE rather than one label - an unrecognised top-level block, and a `network:` value that is not a string. Supervisor drops a file that fails SCHEMA_APP_TRANSLATIONS with only a log warning, so those silently return the entire tab to raw keys; neither is reachable through the `configuration:` coverage, which is why they were hunted for and closed. * tools/translations_lint.py is the hand parser CI needs (no PyYAML in the add-on image) and the probe grades it, key by key and string by string, against Supervisor's own loader. It is strict and RAISES rather than skipping; nine malformed shapes prove it. Full 20-file suite green (19 existing + test_translations.py). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
459 lines
25 KiB
Python
459 lines
25 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)")
|
|
|
|
|
|
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 ==")
|