Files
glenn schrooyenandClaude Opus 5.5 9b1fa758ad
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s
OPT-17: the solar forecast can come from several entities, summed
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.

- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
  day is summed. Hourly splits are merged when every member has one, else
  the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
  day's forecast unusable (no partial sum). A caution row names it by
  position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
  the single-key path is byte-identical to e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.

DECISIONS/OPT-17-several-forecast-entities.md has the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 02:55:58 +02:00

853 lines
47 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, UiOptions
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_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=""):
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)
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))
print("\n== H. TEL-14: the SHAPE of a per-entity PV sign option ==")
# Four candidate shapes, each against a 0.12.0 blob (`pv_entities` set, no new
# key). Transcript and decision: DECISIONS/TEL-14-pv-sign.md.
PV12 = {"pv_entity": "", "pv_entities": ["sensor.pv_inverter", "sensor.pv_meter"]}
_pv12 = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV12))
_nk = "pv_invert_entities"
def _ui_of(sch, name):
return [e for e in UiOptions(None)(sch) if e.get("name") == name]
# (a) a `-` prefix on a list item, under the shipped `- str`.
ok_a, out_a = validate(dict(_pv12, pv_entities=["sensor.pv_inverter",
"-sensor.pv_meter"]))
show("(a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - "
"Supervisor does not look inside the string, so the sign would live in a "
"syntax that only the description text explains",
ok_a and out_a.get("pv_entities") == ["sensor.pv_inverter",
"-sensor.pv_meter"],
"" if ok_a else out_a)
# (b) a parallel list - THE SHIPPED SHAPE.
ok_b, out_b = validate(_pv12)
show("(b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` "
"key) validates unchanged and the new key defaults to []",
ok_b and out_b.get(_nk) == []
and out_b.get("pv_entities") == PV12["pv_entities"],
"" if ok_b else out_b)
ok_b2, out_b2 = validate(dict(_pv12, **{_nk: ["sensor.pv_meter"]}))
show("...and one entity marked inverted validates",
ok_b2 and out_b2.get(_nk) == ["sensor.pv_meter"], "" if ok_b2 else out_b2)
ok_b3, out_b3 = validate({k: v for k, v in defaults.items() if k != _nk})
show("⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` "
"removed is REFUSED BY NAME - its `[]` default is load-bearing, as "
"`pv_entities: []` is",
(not ok_b3) and f"Missing option '{_nk}' in root" in str(out_b3), out_b3)
_ui_b = _ui_of(schema, _nk)
show("...and the Configuration tab renders it as a REPEATABLE string field "
"with its own top-level (translated) label",
len(_ui_b) == 1 and _ui_b[0].get("multiple") is True
and _ui_b[0].get("type") == "string", json.dumps(_ui_b))
# (c) a list of dicts - on `pv_entities` itself, and as a new key.
_s_dicts = dict(schema, pv_entities=[{"entity": "str", "invert": "bool?"}])
ok_c, out_c = validate_with(_s_dicts, _pv12)
show("(c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored "
"are REFUSED - that shape would stop the add-on starting on upgrade",
(not ok_c) and "pv_entities" in str(out_c), out_c)
_s_src = dict(schema, pv_sources=[{"entity": "str", "invert": "bool?"}])
ok_c2, out_c2 = validate_with(_s_src, dict(_pv12, pv_sources=[]))
_ui_c = _ui_of(_s_src, "pv_sources")
show("(c2) ...as a THIRD key it validates, but renders as a type-schema "
"repeatable section whose per-entry keys reach the screen RAW "
"(`entity`, `invert`) on this frontend - see section F",
ok_c2 and len(_ui_c) == 1 and _ui_c[0].get("type") == "schema"
and _ui_c[0].get("multiple") is True,
json.dumps(_ui_c)[:200] if ok_c2 else out_c2)
# (d) one global bool.
_s_bool = dict(schema, pv_invert="bool")
ok_d, out_d = validate_with(_s_bool, dict(_pv12, pv_invert=False))
show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==")
_mk = "maintenance_start_hour"
ok_i, out_i = validate(defaults)
show("the shipped `options:` block validates and carries 19",
ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i)
# What the Supervisor hands the add-on is deepmerge(options:, stored).
_stored10 = dict(defaults, **{_mk: 10})
ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10))
show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10",
ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2)
_absent = {k: v for k, v in defaults.items() if k != _mk}
ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent))
show("a stored blob WITHOUT the key gets the new default, 19",
ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3)
ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
show("the range is unchanged: 24 is refused (`at most 23`)",
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
print("\n== J. OPT-17: several solar forecast entities per day ==")
# The same question as §G, for `pv_forecast_entity` /
# `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is
# varied from the accepted baseline and asserted on the key's name.
# DECISIONS/OPT-17-several-forecast-entities.md.
FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
_fc_old = {k: v for k, v in defaults.items()
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
_fc_base = MERGER.merge(copy.deepcopy(defaults),
dict(_fc_old, **FC_STORED))
ok_j, out_j = validate(_fc_base)
show("a 0.17.0 blob (single forecast keys set, no list keys) validates "
"unchanged, and both new keys default to []",
ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]
and out_j.get("pv_forecast_entities") == []
and out_j.get("pv_forecast_tomorrow_entities") == [],
"" if ok_j else out_j)
for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"):
_s1 = dict(schema, **{_k: ["str"]})
ok_j1, out_j1 = validate_with(_s1, _fc_base)
show(f"`{_k}` declared as a LIST refuses the string an install stores",
(not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1),
out_j1)
_m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})),
copy.deepcopy(FC_STORED))
ok_j2, out_j2 = validate_with(_s1, _m1)
show("...and a `[]` default does not rescue it (list merge is override)",
_m1[_k] == FC_STORED[_k] and not ok_j2, out_j2)
_sm = dict(schema, **{_k: "match(.*)?"})
_ok_s, _ = validate_with(_sm, _fc_base)
_ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a",
"sensor.b"]}))
_ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417}))
_ui_m = _ui_of(_sm, _k)
show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the "
"integer 417, and renders as a plain text box (no second row)",
_ok_s and _ok_l and _ok_n and _ui_m
and _ui_m[0].get("multiple") is not True,
f"string={_ok_s} list={_ok_l} integer-417={_ok_n} "
f"ui={json.dumps(_ui_m)}")
_two = ["sensor.fc_east", "sensor.fc_west"]
ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict(
_fc_old, pv_forecast_entities=_two,
pv_forecast_tomorrow_entities=_two)))
show("⭐ SHIPPED: two-entity lists for today and tomorrow validate",
ok_j3 and out_j3.get("pv_forecast_entities") == _two
and out_j3.get("pv_forecast_tomorrow_entities") == _two,
"" if ok_j3 else out_j3)
for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k})
show(f"...and the `options:` block with ONLY `{_k}` removed is refused "
"by name, so its `[]` default is load-bearing",
(not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4),
out_j4)
_ui_j = _ui_of(schema, _k)
show(f"...and `{_k}` renders as a repeatable string field",
len(_ui_j) == 1 and _ui_j[0].get("multiple") is True
and _ui_j[0].get("type") == "string", json.dumps(_ui_j))
_flat_j = {k: v for k, v in schema.items()
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
try:
_rj = vol.Schema(vol.All(dict, AppOptions(
None, _flat_j, cfg["name"], "local_goodwe_controller")))(
MERGER.merge(copy.deepcopy(defaults), dict(
FC_STORED, pv_forecast_entities=_two)))
show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the "
"single ones",
"pv_forecast_entities" not in _rj
and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"])
except vol.Invalid as e:
show("a DOWNGRADE to 0.17.0's schema drops the list keys", 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.
print()
if FAILED:
print(f"== {len(FAILED)} CASE(S) FAILED ==")
for t in FAILED:
print(" - " + t)
sys.exit(1)
print("== all cases passed ==")