Files
goodwe-addon/goodwe_controller/test_selfconfig.py
T
glenn schrooyenandClaude Opus 5 fdaf3fb54f
checks / unit (pull_request) Successful in 6m17s
UX-02: the validator's refusals, next to the field, before the save
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.

WHAT IT ADDS
  app/configui.py     the field model, built from `GET /addons/self/info`'s own
                      `schema` + `translations`. No option key, no label, no
                      rule of its own. UX-01's `Group · ` label prefix becomes a
                      real section heading, in whichever language resolved.
  app/selfconfig.py   the Supervisor conversation and the write gate.
  app/config_page.py  the page. Its longest function decides which HTML input a
                      type deserves; it contains no rule.
  app/web.py          five routes. Still the only file a reviewer has to read to
                      answer "does anything here reach the inverter".

THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
  1. A blob the SHIPPED validator refuses is never written - not warned about,
     not written. An invalid blob plus a restart is exit 78, and the panel is
     gone with the add-on that serves it.
  2. A blob that moved underneath is refused, not clobbered. Read hands out a
     sha256 of the canonical blob, save hands it back, and the stored blob is
     re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
     applied to configuration - "a blob reaches the Supervisor only if it was
     derived from the blob stored right now" - with the honest weakening stated
     in the decision record: a second writer is DETECTED, not made
     inexpressible.
  3. Every message is the validator's own, byte for byte. test_selfconfig.py
     part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
     under app/ to show validate.py is the only one that differs, and serves the
     real /config/validate route out of that copy in a subprocess. The new
     sentence arrives, filed under the key it names.
  4. Nothing on the surface commands an inverter or clears a latch. Saving does
     not restart: applying costs a control interruption - the wind-down commands
     0 W on every inverter - so that is a separate, confirmed button that says
     so first.

MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
  - the three self-paths are permitted, and `/addons/self/info` is admitted by
    the `default` role too while `options` and `restart` rest on the bypass
    ALONE. The probe's first draft claimed the role admitted none of the three
    and went red.
  - `/addons/self/options/validate` is NOT reachable from inside an add-on, so
    the only validator this panel can call is ours - which is the one with the
    cross-field rules.
  - a POST that omits a key does not preserve it: the key reverts to
    config.yaml's default. Hence whole-blob read-modify-write.
  - the Configuration tab CAN be removed, and doing so drops 0-of-71 options
    through and takes the add-on down. The card said it could not be; it stays
    by decision, which is a better reason.

DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.

Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

---

⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.

So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 21:38:31 +02:00

797 lines
38 KiB
Python

"""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("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("<script>", 1)[1]
_leaked = sorted(k for k in ALL_KEYS
if len(k) > 6 and re.search(r"[^A-Za-z0-9_]" + k + r"[^A-Za-z0-9_]",
_script))
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
f"a field list of its own. Leaked: {_leaked}", not _leaked)
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
"setpoint_entity is not set", "would silently never happen"]
_copied = sorted(p for p in _phrases
for f, text in _ui_files.items() if p in text)
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
f"UI files - the sentences are fetched, never written here. Copied: "
f"{_copied}", not _copied)
# ⚠️ THIS ONE HAS ALREADY EARNED ITS KEEP. The suffix map shipped with
# `("_hour", "h (0-23)")` in it - a RANGE, next to a field, in a second place
# from config.yaml's `int(0,23)`. It is exactly the drift this card forbids,
# in the one corner of the panel that is allowed to print anything of its own.
check("no unit string contains a digit, so the unit map cannot be stating a "
"bound, a default or a relation - config.yaml is the only place a range "
"is written and the schema is where the form gets it from",
all(not any(ch.isdigit() for ch in unit)
for _suffix, unit in configui._UNITS))
check("...and no entry of it is a whole option key - it is a suffix map, so it "
"cannot become a per-key opinion by accident",
not [suf for suf, _u in configui._UNITS if suf.lstrip("_") in ALL_KEYS])
# ⚠️ RELATIVE URLS, WHICH IS WHAT INGRESS REQUIRES. An absolute path works
# perfectly on a laptop and 404s in the field, so it cannot be left to review.
_abs = re.findall(r"""(?:fetch\(|href=)['"](/[^'"]*)['"]""",
_page + _ui_files["web.py"])
check(f"no absolute URL in either page - every fetch and every link is "
f"relative, because ingress serves this under a generated path prefix. "
f"Absolute: {_abs}", not _abs)
check("the page declares a viewport, which is the whole of 'readable on the "
"phone you are holding at the inverter'",
'name="viewport"' in _page and "max-width:760px" in _page)
# --------------------------------------------------------------------------- #
print()
print("6. ⚠️ A RULE ADDED TO validate.py APPEARS IN THE UI WITH NO UI CHANGE")
# THE ACCEPTANCE CRITERION, RUN RATHER THAN ARGUED. A copy of this package is
# made, ONE new rule is inserted into validate.py's `validate_startup_config()`,
# and the real HTTP route is served out of that copy in a subprocess. If the
# page carried any list of known rules, the new sentence would not arrive.
SENTINEL = ("SENTINEL-UX02: log_level is set to 'trace', which this rule "
"refuses so that a check can watch it arrive.")
_INSERT = (' if str(opts.get("log_level", "")) == "trace":\n'
f' problems.append({SENTINEL!r})\n')
_probe = r'''
import asyncio, json, os, sys
sys.path.insert(0, os.environ["TREE"])
from aiohttp import ClientSession, web as aioweb
from app import web
class H:
session = None
token = ""
async def limits(self, e): return (None, None, None)
class C:
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
def log_event(self, t): pass
def status_payload(self): return {}
async def handle_action(self, w): raise AssertionError("no")
async def main():
app = web.build_app(C())
runner = aioweb.AppRunner(app); await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0); await site.start()
port = site._server.sockets[0].getsockname()[1]
async with ClientSession() as s:
async with s.post(f"http://127.0.0.1:{port}/config/validate",
json={"options": json.loads(os.environ["OPTS"])}) as r:
print(json.dumps(await r.json()))
await runner.cleanup()
asyncio.run(main())
'''
_tmp = tempfile.mkdtemp(prefix="ux02-mutant-")
try:
_tree = os.path.join(_tmp, "goodwe_controller")
shutil.copytree(os.path.join(HERE, "app"), os.path.join(_tree, "app"))
# ⚠️ PURGE THE COPY'S BYTECODE. MUTATION.md habit: `python3 -B` stops
# bytecode being WRITTEN, never a stale one being READ, and a copytree
# brings any `__pycache__` along with it.
_purged = 0
for root, dirs, files in os.walk(_tree):
for d in list(dirs):
if d == "__pycache__":
shutil.rmtree(os.path.join(root, d))
dirs.remove(d)
_purged += 1
print(f" purged {_purged} __pycache__ director(y|ies) from the copy; "
f"{sum(1 for r, _d, fs in os.walk(_tree) for f in fs if f.endswith('.pyc'))}"
" .pyc left")
_vpath = os.path.join(_tree, "app", "validate.py")
_src = io.open(_vpath, encoding="utf-8").read()
_at = _src.index("def validate_startup_config")
_anchor = " problems: list[str] = []\n"
_cut = _src.index(_anchor, _at) + len(_anchor)
io.open(_vpath, "w", encoding="utf-8").write(
_src[:_cut] + _INSERT + _src[_cut:])
check("the mutated copy differs from the shipped tree in validate.py, and "
"in validate.py ONLY - so anything the page gains is the rule's",
[name for name in sorted(os.listdir(os.path.join(HERE, "app")))
if name.endswith(".py")
and hashlib.sha256(io.open(os.path.join(HERE, "app", name), "rb")
.read()).hexdigest()
!= hashlib.sha256(io.open(os.path.join(_tree, "app", name), "rb")
.read()).hexdigest()] == ["validate.py"])
_env = dict(os.environ, TREE=_tree, OPTS=json.dumps(base(log_level="trace")),
PYTHONDONTWRITEBYTECODE="1")
_out = subprocess.run([sys.executable, "-B", "-c", _probe], env=_env,
capture_output=True, text=True, timeout=120)
if _out.returncode != 0:
print(_out.stdout[-2000:])
print(_out.stderr[-2000:])
check("the page's own /config/validate route answers out of the mutated "
"tree", _out.returncode == 0)
_payload = json.loads(_out.stdout.strip().splitlines()[-1]) if _out.stdout else {}
check("⚠️ THE NEW RULE'S SENTENCE IS ON THE PAGE, and no file under app/ "
"other than validate.py was touched to put it there",
SENTINEL in (_payload.get("problems") or []))
check("...and it is filed under `log_level`, the field it names, by the "
"same anchoring that files every other rule - so a new rule arrives "
"in the right place rather than in a catch-all",
SENTINEL in (_payload.get("by_key") or {}).get("log_level", []))
check("...while the SHIPPED tree does not refuse that config at all, which "
"is what makes the line above evidence about the rule rather than "
"about the fixture",
run(selfconfig.problems_for(base(log_level="trace"), None)) == [])
finally:
shutil.rmtree(_tmp, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("7. THE PANEL'S REACH INTO THE INVERTER IS UNCHANGED")
_app = web.build_app(FakeController(), FakeSupervisor())
_routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
check("the configuration surface adds five routes and touches none of the "
"three that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
("GET", "/status"), ("POST", "/action"),
("POST", "/config/restart"), ("POST", "/config/save"),
("POST", "/config/validate")])
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
encoding="utf-8").read()
for forbidden in ("set_number", "write_setpoint", "handle_action",
"command_site_zero", "sign_wd", "reset("):
check(f"neither selfconfig.py nor the page can reach `{forbidden}`",
forbidden not in _selfconfig_src
and forbidden not in _ui_files["config_page.py"])
# ⚠️ ASSERTED ON THE CONSTANTS, NOT ON A GREP OF THE FILE. The module's own
# docstring quotes Supervisor's bypass regex, which names `security`, `update`
# and `options/validate`; a grep would find those and report a reach the code
# does not have. The three constants are what `_call()` can be given.
check("the ONLY Supervisor endpoints this module defines are self/info, "
"self/options and self/restart",
sorted(v for k, v in vars(selfconfig).items()
if k.startswith("SELF_"))
== ["http://supervisor/addons/self/info",
"http://supervisor/addons/self/options",
"http://supervisor/addons/self/restart"])
# --------------------------------------------------------------------------- #
print()
print("8. end to end, over real HTTP, against a fake Supervisor")
async def _serve(app):
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
return runner, site._server.sockets[0].getsockname()[1]
async def _e2e():
out = {}
sup = FakeSupervisor(base(max_w=2000))
ctrl = FakeController(base(max_w=1500), # running != stored
FakeHass({"number.goodwe_setpoint":
(-3000.0, 3000.0, 1.0)}))
runner, port = await _serve(web.build_app(ctrl, sup))
root = f"http://127.0.0.1:{port}"
try:
async with ClientSession() as s:
async with s.get(f"{root}/config") as r:
out["page"] = (r.status, await r.text())
async with s.get(f"{root}/config/model?lang=nl") as r:
out["model"] = await r.json()
async with s.post(f"{root}/config/validate",
json={"options": base(setpoint_entity="")}) as r:
out["invalid"] = await r.json()
# ⚠️ THE COUNTS ARE SNAPSHOTTED AT EACH STAGE. Asserting
# `sup.writes == []` after the whole sequence would be asserting
# about the LAST request, not this one - the first draft did, and
# went red only because a later, legitimate save had happened.
async with s.post(f"{root}/config/save",
json={"options": base(setpoint_entity=""),
"fingerprint": out["model"]["fingerprint"]}
) as r:
out["save_invalid"] = (r.status, await r.json(),
len(sup.writes))
async with s.post(f"{root}/config/save",
json={"options": base(max_w=2400),
"fingerprint": "not-the-token"}) as r:
out["save_stale"] = (r.status, await r.json(), len(sup.writes))
async with s.post(f"{root}/config/save",
json={"options": base(max_w=2400),
"fingerprint": out["model"]["fingerprint"]}
) as r:
out["save_ok"] = (r.status, await r.json())
async with s.post(f"{root}/config/restart", json={}) as r:
out["restart_bare"] = (r.status, await r.json(), sup.restarts)
async with s.post(f"{root}/config/restart",
json={"confirm": "restart"}) as r:
out["restart_ok"] = (r.status, await r.json())
finally:
await runner.cleanup()
out["sup"] = sup
out["ctrl"] = ctrl
return out
E = run(_e2e())
check("GET config serves the page", E["page"][0] == 200
and "GoodWe - Configuration" in E["page"][1])
_m = E["model"]
check("the model carries the groups, the stored blob and its token",
# `Grenzen`, not `Limits`: this request asked for nl, and nl.yaml
# carries `max_w`. The three groups around it fall back to English per
# key, which is the frontend's own rule and is asserted two checks below.
[g["name"] for g in _m["groups"]] == ["Hardware", "Grenzen", "Meter",
"Advanced"]
and _m["options"]["max_w"] == 2000
and _m["fingerprint"] == selfconfig.fingerprint(_m["options"]))
check("...and it says which stored settings are NOT what this add-on is "
"running - which is how an edit made in the Configuration tab, or a save "
"not yet restarted into, becomes visible here",
_m["running_differs"] == ["max_w"] and _m["running"]["max_w"] == 1500)
check("...and it honours the browser's language per key, falling back to "
"English for the keys nl.yaml does not carry - the frontend's own rule",
_m["lang"] == "nl"
and [f["label"] for g in _m["groups"] for f in g["fields"]
if f["key"] == "max_w"] == ["Omvormervermogen (W)"]
and [f["label_lang"] for g in _m["groups"] for f in g["fields"]
if f["key"] == "meter_source"] == ["en"])
check("POST config/validate returns the validator's sentences, anchored",
E["invalid"]["problems"]
and "setpoint_entity" in E["invalid"]["by_key"])
check("a save of an invalid blob answers 422 and the Supervisor got nothing",
E["save_invalid"][0] == 422
and E["save_invalid"][1]["kind"] == "invalid"
and E["save_invalid"][2] == 0)
check("a save with a stale token answers 409, writes nothing, and hands back "
"the CURRENT token so the operator can save again in front of the "
"difference",
E["save_stale"][0] == 409
and E["save_stale"][1]["kind"] == "stale"
and E["save_stale"][1]["fingerprint"]
== selfconfig.fingerprint(E["save_stale"][1]["stored"])
and E["save_stale"][2] == 0)
check("a valid save with the right token is written, once",
E["save_ok"][0] == 200 and len(E["sup"].writes) == 1
and E["sup"].writes[0]["max_w"] == 2400)
check("...and it does NOT restart anything - applying is a separate, "
"confirmed act, because it costs a control interruption",
E["restart_bare"][0] == 400 and E["restart_bare"][2] == 0)
check("...which the confirmed request performs",
E["restart_ok"][0] == 200)
check("the action log records both, so §15 can say who changed what",
any("configuration saved" in e for e in E["ctrl"].events)
and any("restart requested" in e for e in E["ctrl"].events))
# --------------------------------------------------------------------------- #
print()
print("9. the page says what a save costs BEFORE it is pressed")
for phrase in ("0 W on every inverter", "Saving does not restart anything",
"Restart to apply"):
check(f"the page states {phrase!r} in its own markup, not in a message "
"that only appears afterwards", phrase in _page)
check("the restart confirmation names the cost again at the moment of pressing",
"Yes, restart now" in _page
and _page.count("0 W on every inverter") >= 2)
_flat = " ".join(_page.split())
check("and it says which surface is authoritative when the add-on is down",
"If the add-on is not running, the Configuration tab is the only one of "
"the two that still exists" in _flat)
# --------------------------------------------------------------------------- #
print()
print("10. the Supervisor conversation itself, over real HTTP")
# ⚠️ EVERYTHING ABOVE INJECTED A FAKE `selfcfg`, so none of it exercised the
# one piece that talks to somebody else's server: the headers, the request
# body's shape, the `{"result": "ok", "data": {...}}` envelope and what a
# refusal turns into. A gate whose transport is never run is a gate that has
# only been read.
async def _transport():
seen = []
async def h_info(request):
seen.append(("info", request.headers.get("Authorization"), None))
return aioweb.json_response({"result": "ok",
"data": {"options": {"max_w": 2000}}})
async def h_options(request):
body = await request.json()
seen.append(("options", request.headers.get("Authorization"), body))
return aioweb.json_response({"result": "ok", "data": {}})
async def h_restart(request):
seen.append(("restart", request.headers.get("Authorization"), None))
return aioweb.json_response({"result": "ok", "data": {}})
async def h_denied(_request):
return aioweb.json_response(
{"result": "error", "message": "Missing API permission"},
status=403)
app = aioweb.Application()
app.add_routes([aioweb.get("/addons/self/info", h_info),
aioweb.post("/addons/self/options", h_options),
aioweb.post("/addons/self/restart", h_restart),
aioweb.get("/denied", h_denied)])
runner, port = await _serve(app)
root = f"http://127.0.0.1:{port}"
saved = (selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
selfconfig.SELF_RESTART)
selfconfig.SELF_INFO = f"{root}/addons/self/info"
selfconfig.SELF_OPTIONS = f"{root}/addons/self/options"
selfconfig.SELF_RESTART = f"{root}/addons/self/restart"
out = {}
try:
async with ClientSession() as session:
cfg = selfconfig.SelfConfig(session, "TOKEN-123")
out["info"] = await cfg.info()
await cfg.write_options({"max_w": 2400})
await cfg.restart(selfconfig.RESTART_CONFIRM)
try:
await cfg.restart("")
out["unconfirmed"] = None
except selfconfig.NotConfirmed as err:
out["unconfirmed"] = str(err)
try:
await cfg._call("GET", f"{root}/denied")
out["denied"] = None
except selfconfig.SupervisorUnavailable as err:
out["denied"] = str(err)
try:
await cfg._call("GET", f"{root}/nothing-here")
out["missing"] = None
except selfconfig.SupervisorUnavailable as err:
out["missing"] = str(err)
finally:
(selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
selfconfig.SELF_RESTART) = saved
await runner.cleanup()
out["seen"] = seen
return out
T = run(_transport())
check("`info()` unwraps the Supervisor's `{result, data}` envelope",
T["info"] == {"options": {"max_w": 2000}})
check("every request carries the add-on's own SUPERVISOR_TOKEN as a bearer",
[auth for _what, auth, _b in T["seen"]] == ["Bearer TOKEN-123"] * 3)
check("the options write goes to /addons/self/options as {'options': blob} - "
"the shape `SCHEMA_OPTIONS` accepts, and the shape that REPLACES the "
"stored user options",
[(w, b) for w, _a, b in T["seen"] if w == "options"]
== [("options", {"options": {"max_w": 2400}})])
check("the restart is a POST to /addons/self/restart and reaches it only when "
"confirmed",
[w for w, _a, _b in T["seen"]] == ["info", "options", "restart"]
and T["unconfirmed"])
check("a Supervisor refusal is carried through with ITS message and its status "
"rather than rewritten - the page shows what the Supervisor said",
"403" in T["denied"] and "Missing API permission" in T["denied"])
check("...and so is a 404, so a path this add-on is not permitted is "
"distinguishable from one that does not exist",
"404" in T["missing"])
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")