"""Runnable check for the ingress configuration surface. `python3 test_selfconfig.py` UX-02. The panel edits, validates and saves the add-on's own options. Four of its properties are the ones that could hurt somebody, and they are what this file spends its length on: 1. AN INVALID BLOB IS NEVER WRITTEN. Not "warned about": not written. An invalid blob plus a restart is exit 78, and an add-on that is not running is not serving the panel either - the operator's way back would be the Supervisor UI, which is the surface this card exists because it is hard to read. Part 2 attempts the write and counts what the Supervisor received. 2. A BLOB THAT MOVED UNDERNEATH IS NEVER CLOBBERED. There are two config surfaces and only one blob. Part 3 simulates the other one writing between this page's read and its save. 3. EVERY MESSAGE COMES FROM THE SHIPPED VALIDATOR. Part 6 adds a NEW RULE to a COPY of validate.py, serves the page out of that copy in a subprocess, and watches the new sentence arrive on the field it names - with every UI file byte-identical, which is asserted by hashing them. 4. NOTHING HERE COMMANDS AN INVERTER. Part 7 lists what the panel exposes and compares it with what `/action` already offered. ⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run `tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it stops bytecode being WRITTEN, not a stale one being READ. That is TEST-15, and part 6 below builds a mutated tree on purpose, so it purges its own copy. """ import asyncio import hashlib import io import json import os import re import shutil import subprocess import sys import tempfile from aiohttp import ClientSession, web as aioweb from app import config_page, configui, selfconfig, web from app.validate import validate_startup_config HERE = os.path.dirname(os.path.abspath(__file__)) fails = [] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) def run(coro): return asyncio.run(coro) # --------------------------------------------------------------------------- # # Fixtures. # # ⚠️ `SCHEMA` AND `TRANSLATIONS` ARE FIXTURES OF SOMEBODY ELSE'S RESPONSE SHAPE, # and they are graded against the real thing somewhere else on purpose. # `tools/supervisor_selfapi_probe.py` builds the same two structures with # Supervisor 2026.08.0's OWN `UiOptions` and `_read_app_translations` and runs # this repository's `field_model()` over them - §C and §E. This file cannot: the # CI image is Alpine + python3 + aiohttp + paho and has neither the Supervisor # nor PyYAML. So the shapes below are small, and the claim that they are the # shapes that arrive is the probe's, not this file's. # --------------------------------------------------------------------------- # SCHEMA = [ {"name": "setpoint_entity", "required": True, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": True, "type": "integer"}, {"name": "meter_source", "required": True, "type": "select", "options": ["off", "ha_dsmr"]}, {"name": "log_level", "required": True, "type": "select", "options": ["info", "trace"]}, {"name": "batt_invert", "required": True, "type": "boolean"}, {"name": "inverters", "type": "schema", "optional": True, "multiple": True, "schema": [ {"name": "name", "required": True, "type": "string"}, {"name": "setpoint_entity", "required": True, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": True, "type": "integer"}, {"name": "bms_soc_floor", "lengthMin": 0.0, "lengthMax": 50.0, "required": True, "type": "integer"}]}, ] TRANSLATIONS = { "en": {"configuration": { "setpoint_entity": {"name": "Hardware · Inverter setpoint entity", "description": "Where the setpoint is written."}, "max_w": {"name": "Limits · Inverter rating (W)", "description": "The ceiling this site may command."}, "meter_source": {"name": "Meter · Source", "description": "Where the grid reading comes from."}, "log_level": {"name": "Advanced · Log level", "description": "How much the add-on writes to its log."}, "batt_invert": {"name": "Hardware · Battery power sign is reversed", "description": "Only if your sensor is backwards."}, "inverters": {"name": "Hardware · Inverter list", "description": "Leave empty for one inverter.", "fields": { "name": {"name": "Name"}, "setpoint_entity": {"name": "Setpoint entity"}, "max_w": {"name": "Rating (W)"}, "bms_soc_floor": {"name": "Battery cut-out (%)"}}}}}, "nl": {"configuration": { "max_w": {"name": "Grenzen · Omvormervermogen (W)", "description": "Het plafond voor deze site."}}}, } def base(**over): """An options blob that STARTS. Everything else is this, spoilt. The same fixture shape as test_validate.py's `base()`, for the same reason it gives: a check that is measuring a broken config has to be able to show that the unbroken one is accepted, or it is measuring nothing. """ o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000, "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", "meter_source": "off", "log_level": "info", "batt_invert": False, "inverters": [], "maintenance_charge_w": 2000, "maintenance_discharge_w": 2000} o.update(over) return o class FakeSupervisor: """The Supervisor's stored options, and a count of what reached them. ⚠️ IT COUNTS WRITES, NOT CALLS. "Nothing was written" is the property parts 2 and 3 are about, and a fake that only recorded that `write_options` was reached could not tell a refusal from a write that failed afterwards. """ def __init__(self, options=None): self.options = dict(options or base()) self.writes = [] self.restarts = 0 async def info(self): return {"options": dict(self.options), "schema": SCHEMA, "translations": TRANSLATIONS, "state": "started"} async def write_options(self, options): self.writes.append(dict(options)) self.options = dict(options) async def restart(self, confirm): if confirm != selfconfig.RESTART_CONFIRM: raise selfconfig.NotConfirmed("not confirmed") self.restarts += 1 class FakeHass: """`hass.limits()` and nothing else. It cannot write: that is the point.""" def __init__(self, ranges=None): self.ranges = ranges or {} self.reads = [] async def limits(self, entity): self.reads.append(entity) return self.ranges.get(entity, (None, None, None)) class FakeController: def __init__(self, opts=None, hass=None): self.o = dict(opts or base()) self.hass = hass or FakeHass() self.inverters = [] self.events = [] def log_event(self, text): self.events.append(text) def status_payload(self): return {"banner": "", "level": "ok", "rows": [], "checks": []} async def handle_action(self, what): raise AssertionError("the configuration surface must never reach " f"handle_action ({what!r})") # ⚠️ THE ANCHOR LIST IS EVERY OPTION KEY config.yaml DECLARES, read off the # file with the shipped parser rather than typed here. That is what the running # panel anchors against too: `GET /addons/self/info` returns the MERGED options, # so every key is present in the blob the page posts back. A test that anchored # against a small fixture's keys would report a rule as "unanchored" purely # because its fixture did not mention the key - which is what the first draft # of part 4 did, for the price rules. sys.path.insert(0, os.path.join(HERE, "tools")) import translations_lint as _tl # noqa: E402 ALL_KEYS, _NESTED = _tl.config_surface( io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()) # --------------------------------------------------------------------------- # print("1. the compare-and-swap token") _a = base() _b = dict(reversed(list(base().items()))) # same content, other key order check("two blobs with the same content have the same fingerprint, whatever " "order their keys were built in - a token that changed on a dict " "reordering would refuse every save for no reason", selfconfig.fingerprint(_a) == selfconfig.fingerprint(_b)) check("one changed value changes it", selfconfig.fingerprint(base()) != selfconfig.fingerprint(base(max_w=2001))) check("...and so does a changed ROSTER ORDER, because roster order is meaning", selfconfig.fingerprint(base(inverters=[{"name": "a"}, {"name": "b"}])) != selfconfig.fingerprint(base(inverters=[{"name": "b"}, {"name": "a"}]))) check("it is a hex digest and not the blob - nothing about the configuration " "leaves through it", re.fullmatch(r"[0-9a-f]{64}", selfconfig.fingerprint(base())) is not None) # --------------------------------------------------------------------------- # print() print("2. AN INVALID BLOB IS REFUSED, AND NOTHING IS WRITTEN") sup = FakeSupervisor() fp = selfconfig.fingerprint(sup.options) bad = base(setpoint_entity="") # rule 2: a controller with no actuator refused = None try: run(selfconfig.save(sup, bad, fp)) except selfconfig.SaveRefused as err: refused = err check("the save raises SaveRefused(invalid)", refused is not None and refused.kind == "invalid") check("⚠️ AND THE SUPERVISOR RECEIVED NOTHING. Not a write that was rolled " "back, not a write of the old blob - no write at all", sup.writes == []) check("the stored options are byte-identical to what they were", selfconfig.fingerprint(sup.options) == fp) check("...and the refusal carries the validator's OWN sentence, unedited", refused.problems == validate_startup_config(bad, None, None)) check("...which is the sentence that names the key and says what it costs", any("setpoint_entity is not set" in p for p in refused.problems)) _good = base() res = run(selfconfig.save(sup, _good, fp)) check("a config that STARTS is saved - or nothing above means anything", len(sup.writes) == 1 and sup.writes[0] == _good) check("...and the caller gets the new token back, so the next save can " "compare against what it just wrote", res["fingerprint"] == selfconfig.fingerprint(_good)) # --------------------------------------------------------------------------- # print() print("2b. ⚠️ THE TWO HALVES OF THE max_w FIX, EACH PINNED SO IT CANNOT BE UNDONE") # Review found that BOTH halves reverted with the whole 24-file suite still # green - including the half that turns a crash into a SILENT ACCEPTANCE, which # is the more dangerous of the two and was a wrong first attempt at this very # fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4. from app.inverter import _flat_max_w, build_roster # noqa: E402 from app.validate import _number # noqa: E402 # ⚠️ THIS CHECK EXISTS BECAUSE THE OBVIOUS ONE HAS A HOLE. "problems_and_ranges # does not raise" is ALSO satisfied by the backstop swallowing the exception and # returning [] - which is the silent-acceptance bug half two exists to prevent. # Measured: with BOTH halves reverted, every max_w check below reported ok and # only the forced-boom pins went red. So half one is pinned HERE, on the # composition itself, where the backstop cannot answer for it. _raised = None try: build_roster(base(max_w=""), None) except Exception as exc: # noqa: BLE001 _raised = exc check("⭐ THE COMPOSITION ITSELF TOLERATES A CLEARED max_w - not merely the " "backstop above it. `validate.py`'s `_number()` reads '' as the default, " "so rule 9 sees 2000 and is content; a bare `float('')` in the flat path " "raised instead, inverting inverter.py's own rule that a shape rule 9 " "tolerates must be composable", _raised is None) # HALF ONE: composition tolerates exactly what rule 9 tolerates. for raw in ("", None, 0, False, True, -1, "2000", " 2000 ", "2e3", 1e18): o = base(max_w=raw) expect = _number(o, "max_w", 2000.0) if expect is not None: check(f"_flat_max_w agrees with _number() for max_w={raw!r}", _flat_max_w(o) == expect) _raised = None try: _probs, _ = run(selfconfig.problems_and_ranges(base(max_w=""), None)) except Exception as exc: # noqa: BLE001 _raised, _probs = exc, None check("⚠️ A CLEARED max_w BOX ANSWERS WITH A VERDICT, NOT AN EXCEPTION. It " "used to raise ValueError from inside Controller.__init__, which the " "panel served as HTTP 500 on a non-JSON body and the page rendered as " "'cannot reach the add-on' - untrue, for the most prominent limit " "field's most likely transient state", _raised is None) check("...and it agrees with the validator, which reads '' as the default", _probs == list(validate_startup_config(base(max_w=""), None, None))) _raised = None try: _probs, _ = run(selfconfig.problems_and_ranges(base(max_w="abc"), None)) except Exception as exc: # noqa: BLE001 _raised, _probs = exc, None check("an UNPARSEABLE max_w answers with the validator's own sentence", _raised is None and any("is not a number" in p for p in (_probs or []))) # HALF ONE, THE PART THAT MAKES IT SAFE RATHER THAN MERELY NOT-CRASHING: # clearing the box on the LIVE shape must FAIL CLOSED, not silently halve the # rail. `unit_caps_indexed` reads the flat rail through the same `_number`, so # the rail collapses to 2000 in lockstep and rule 10 catches the collapse. _live = base(max_w=5000, slew_w=400) _probs, _ = run(selfconfig.problems_and_ranges(dict(_live), None)) check("the live shape (5000 W rail, slew_w 400) starts", _probs == []) _probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None)) check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the " "rail collapsing to the 2000 default under an explicit 400 W slew, so " "the box cannot be emptied into a quietly halved rail", any("site rail of 2000 W" in p for p in _probs)) # HALF TWO: the backstop cannot report "no problems" for a config it could not # compose. Returning [] there enables Save - a crash became a green. _orig_roster = selfconfig.build_roster def _boom(*_a, **_kw): raise ValueError("forced boom") selfconfig.build_roster = _boom try: _probs, _ = run(selfconfig.problems_and_ranges(base(), None)) finally: selfconfig.build_roster = _orig_roster check("⚠️ THE BACKSTOP NEVER RETURNS AN EMPTY LIST. A roster that cannot be " "composed while the validator names no reason is itself the finding - " "returning [] would turn a crash into a silent acceptance and enable " "Save, which is worse than the 500 it replaced", _probs != [] and len(_probs) == 1) check("...and it says so in words, naming the exception", any("could not be composed" in p and "forced boom" in p for p in _probs)) check("...and the patch was undone, so nothing below inherits it", selfconfig.build_roster is _orig_roster) # --------------------------------------------------------------------------- # print() print("3. THE OTHER SURFACE WRITES WHILE THIS PAGE IS OPEN") # ⚠️ SIMULATED AT THE EXACT MOMENT IT MATTERS. The Supervisor's Configuration # tab is not removable, so this is not a hypothetical race - it is the normal # way two people, or one person on two devices, use this add-on. The blob is # changed AFTER the page's read and BEFORE its save. # ⚠️ EVERY BLOB IN THIS SECTION MUST BE ONE THAT STARTS. The gate validates # BEFORE it compares, so a fixture that is also invalid would be refused for # the wrong reason and this section would be measuring rule 6 instead of the # compare-and-swap. (It was, in the first draft: `max_w: 1800` beneath the # fixture's `maintenance_charge_w: 2000` is a genuine refusal.) sup = FakeSupervisor(base(max_w=2000)) page_fp = selfconfig.fingerprint(sup.options) # what the page loaded sup.options = base(max_w=2500) # the other tab saves mine = base(max_w=3000, log_level="trace") # what the operator typed refused = None try: run(selfconfig.save(sup, mine, page_fp)) except selfconfig.SaveRefused as err: refused = err check("the save raises SaveRefused(stale)", refused is not None and refused.kind == "stale") check("⚠️ AND AGAIN NOTHING WAS WRITTEN - the other surface's value stands, " "and the operator's edits were not silently thrown at it", sup.writes == [] and sup.options["max_w"] == 2500) check("the refusal NAMES the keys that differ, so the page can print them " "rather than saying 'something changed'", refused.changed == ["log_level", "max_w"]) check("...and carries what is stored, so the operator decides in front of the " "difference rather than through it", refused.stored["max_w"] == 2500) _after = run(selfconfig.save(sup, mine, selfconfig.fingerprint(sup.options))) check("saving again against the CURRENT token succeeds - the refusal is a " "gate, not a lock", len(sup.writes) == 1 and sup.options["max_w"] == 3000 and _after["fingerprint"] == selfconfig.fingerprint(mine)) # ⚠️ AND THERE IS NO WAY ROUND IT. A caller with no token cannot save. sup = FakeSupervisor() try: run(selfconfig.save(sup, base(max_w=2400), "")) check("an empty token is refused", False) except selfconfig.SaveRefused as err: check("an empty token is refused as stale rather than treated as 'do not " "check' - there is no force door in this module", err.kind == "stale" and sup.writes == []) # --------------------------------------------------------------------------- # print() print("4. WHERE A REFUSAL LANDS ON THE PAGE") # ⚠️ MEASURED, NOT CLAIMED. The anchoring is name occurrence: a sentence is # printed under every option key it spells. Rather than assert a coverage # figure, this runs the SHIPPED validator on a spread of broken configs and # prints what anchored and what did not. _cases = [ ("no actuator", base(setpoint_entity="")), ("max_w over the device", base(max_w=4000)), ("charge rate above the ceiling", base(maintenance_charge_w=3000)), ("sign threshold at the ceiling", base(sign_disagree_w=2000)), ("two writers on one actuator", base(inverters=[{"name": "a", "setpoint_entity": "number.goodwe_setpoint", "max_w": 2000, "bms_soc_floor": 10}])), ("a price source that cannot mean what it says", base(price_provider="manual_csv")), ] _ranges = {"number.goodwe_setpoint": (-3000.0, 3000.0, 1.0)} _total = _anchored = 0 _orphans = [] for title, opts in _cases: probs = run(selfconfig.problems_for(opts, FakeHass(_ranges))) by_key, un = configui.problems_by_field(probs, ALL_KEYS) _total += len(probs) _anchored += len(probs) - len(un) _orphans += [(title, p[:70]) for p in un] print(f" {title:34s} {len(probs)} problem(s), " f"{len(probs) - len(un)} beside a field") check(f"every one of the {_total} refusals these six broken configs produce " f"lands beside at least one of the fields it is about. Unanchored: " f"{_orphans}", _total > 0 and not _orphans) _probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges))) _by, _un = configui.problems_by_field(_probs, ALL_KEYS) check("the device-range rule is REACHED from the panel - the setpoint entity's " "own range is read, so rule 1 fires here exactly as it does at startup", any("THE DEVICE WINS" in p for p in _probs)) check("...and it lands on `max_w`, which is the field the operator has to " "change", "max_w" in _by) # ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w` # is a suffix of both `unit_sign_disagree_w` and `site_sign_disagree_w` - three # real options, two of them SAFETY-30's. A plain substring test would print # every per-unit refusal next to the SITE field as well, which is a message # about unit-0 beside a box that has nothing to do with it. CI runs this file # and not the probe, so the assertion has to live here too. _collide = [k for k in ALL_KEYS if any(o != k and o.endswith("_" + k) for o in ALL_KEYS)] check(f"config.yaml really does declare keys that are suffixes of other keys, " f"so the boundary matching is load-bearing: {_collide}", bool(_collide)) check("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under " "`sign_disagree_w`", sorted(configui.problems_by_field( ["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], ALL_KEYS)[0]) == ["max_w", "unit_sign_disagree_w"]) check("a cross-field refusal is filed under EVERY key it names, because a " "cross-field rule is about all of its fields", len(configui.problems_by_field( ["max_w is 8000 W but number.x accepts at most 5000 W"], ["max_w", "setpoint_entity"])[0]) == 1) check("the panel shows no refusal at all for a config that starts", run(selfconfig.problems_for(base(), FakeHass(_ranges))) == []) # ⚠️ AND WHEN A DEVICE DOES NOT ANSWER, THE PAGE SAYS WHICH ONE. The two # device-range rules are then skipped - here and at startup, identically - so a # green page would be green for a reason the operator cannot see. _p, _unread = run(selfconfig.problems_and_ranges(base(max_w=4000), FakeHass())) check("with the setpoint device silent, rule 1 does not fire and the entity " "that did not answer is named, so the page can say what was skipped", _p == [] and _unread == ["number.goodwe_setpoint"]) _p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000), FakeHass(_ranges))) check("...and with the same device answering, the same config IS refused - " "which is what makes the line above a skip rather than a pass", any("THE DEVICE WINS" in p for p in _p2) and _unread2 == []) check("the note is about the config UNDER TEST, not about the running roster: " "a newly typed entity that does not answer is the one named", run(selfconfig.problems_and_ranges( base(setpoint_entity="number.just_typed"), FakeHass(_ranges)))[1] == ["number.just_typed"]) # --------------------------------------------------------------------------- # print() print("5. THE UI OWNS NO COPY OF ANY RULE, LABEL OR KEY") _page = io.open(os.path.join(HERE, "app", "config_page.py"), encoding="utf-8").read() _ui_files = {name: io.open(os.path.join(HERE, "app", name), encoding="utf-8").read() for name in ("config_page.py", "configui.py", "web.py")} # The option keys config.yaml declares, read off the file rather than typed # here - a hand-written list would go stale in the direction that hides a # copy. # `name` and `max_w` also occur as ordinary English/JS words or as model field # names, so the test is on the SCRIPT half of the page and on keys that could # only be there deliberately. _script = _page.split("