Compare commits

..
3 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 bcee2356e0 UX-02: pin both halves of the max_w fix, because neither was graded
checks / unit (pull_request) Successful in 6m16s
Review of the fix commit 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 was my own wrong first attempt at this fix. A fix nothing
grades is a fix that comes back. MUTATION.md habit 4.

⚠️ AND THE OBVIOUS PIN HAD THE SAME DEFECT IT WAS WRITTEN TO CATCH. "problems_
and_ranges does not raise" is ALSO satisfied by the backstop swallowing the
exception and returning [] - the silent acceptance half two exists to prevent.
MEASURED: with both halves reverted, every max_w check reported `ok` and only
the forced-boom pins went red. So half one is now pinned on `build_roster`
ITSELF, where the backstop cannot answer for it. That is the same defect as the
one being fixed - a check whose data cannot reach the failure it names - written
into the check for it, and it is the twelfth recorded instance.

New section 2b of test_selfconfig.py:
  - `_flat_max_w` agrees with `_number()` across ten shapes, so composition
    tolerates exactly what rule 9 tolerates.
  - ⭐ `build_roster(max_w="")` does not raise - half one, standing alone.
  - a cleared box answers with the validator's own verdict; an unparseable one
    answers "max_w 'abc' is not a number".
  - ⭐ clearing max_w on the LIVE shape (5000 W rail, explicit slew_w 400) is
    REFUSED by rule 10 "against a site rail of 2000 W". `unit_caps_indexed`
    reads the flat rail through the same `_number`, so the rail collapses in
    lockstep and rule 10 catches the collapse. This is what makes the fix safe
    rather than merely non-crashing: the box cannot be emptied into a quietly
    halved rail.
  - ⭐ the backstop never returns an empty list, forced by monkeypatching
    build_roster to raise; and the patch is shown undone.

DRIVEN, all four arms through the real harness:
  clean            rc=0  fails=0
  revert half one  rc=1  fails=2     (_flat_max_w -> bare float())
  revert half two  rc=1  fails=2     (backstop returns [] again)
  revert BOTH      rc=1  fails=3
Each half is caught on its own, which is the property the earlier draft lacked.

Suite: 24 files, all pass.

Not addressed here, filed instead: N3 (`/config/` 404s), N4 (undeclared keys on
a hand-crafted POST, which the page cannot produce and Supervisor drops), N5
(saving pins every default as an explicit option), the page reporting "would
start" for a blob Supervisor's own schema refuses, and 2000.0 hardcoded in three
places that agree today with nothing pinning them together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:38:54 +02:00
glenn schrooyenandClaude Opus 5 2bb07ccdde UX-02: the panel was weaker than the startup gate for one field
checks / unit (pull_request) Successful in 6m9s
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.

N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:

  validate.py `_number()`  reads "" as "use the default", so rule 9 saw 2000
                           and was content
  inverter.py line 515     did a bare `float(opts.get("max_w", 2000))`, which
                           raises ValueError on ""

So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.

⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.

MEASURED after the fix, `problems_and_ranges` with no HA:
  max_w 5000, slew_w 400   0 problems      (the live shape)
  max_w "",   slew_w 400   1 problem       rule 10, "against a site rail of
                                           2000 W" - clearing the box FAILS
                                           CLOSED on the live install rather
                                           than silently halving the rail
  max_w "",   slew_w 0     0 problems      accepted at the 2000 default
  max_w "abc"              1 problem       "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.

N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.

Suite: 24 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:17:37 +02:00
glenn schrooyenandClaude Opus 5 fdaf3fb54f UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
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
4 changed files with 159 additions and 3 deletions
+31 -1
View File
@@ -512,7 +512,7 @@ def build_roster(opts: dict, store, sign_cfg=None,
soc_entity=opts.get("soc_entity", ""),
batt_entity=opts.get("batt_entity", ""),
batt_invert=bool(opts.get("batt_invert")),
max_w=float(opts.get("max_w", 2000)),
max_w=_flat_max_w(opts),
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
# - the one pack the flat options describe is the one pack whose
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
@@ -588,6 +588,36 @@ def _entry_bms_floor(entry: dict):
return None
def _flat_max_w(opts: dict) -> float:
"""The single-inverter shape's cap, tolerating what rule 9 tolerates.
⚠️ THIS USED TO BE A BARE `float(opts.get("max_w", 2000))` AND IT
DISAGREED WITH THE VALIDATOR. `validate.py`'s `_number()` reads `""` as
"use the default", so rule 9 looks at an empty `max_w` box, sees 2000 and
is content - while the bare `float("")` here raised ValueError from inside
`Controller.__init__`. Through UX-02's panel that surfaced as HTTP 500 on a
non-JSON body, which the page reported as "cannot reach the add-on": untrue,
and for the most prominent limit field's most likely transient state.
That inverted this module's own docstring rule - "keep the two in step: a
shape this tolerates that rule 9 does not name is a shape that runs" - in
the other direction: a shape rule 9 TOLERATED could not be composed at all.
The roster path never had the bug, because `_entry_max_w` below already
guards; only the flat path did.
Empty or absent is the default, exactly as `_number()` reads it.
Unparseable is 0.0, exactly as `_entry_max_w` reads it - and rule 9 refuses
it by name at line 790, with a sentence, before a control loop exists.
"""
raw = opts.get("max_w", 2000)
if raw is None or raw == "":
return 2000.0
try:
return float(raw)
except (TypeError, ValueError):
return 0.0
def _entry_max_w(entry: dict) -> float:
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
+30 -1
View File
@@ -304,7 +304,36 @@ async def problems_and_ranges(opts: dict, hass=None):
one. That was the first shape of this and it disagreed with itself.
"""
flat = str(opts.get("setpoint_entity", "") or "").strip()
units = build_roster(opts, None)
# ⚠️ THE ROSTER IS BUILT BEFORE THE VALIDATOR RUNS, AND IT PARSES NUMBERS.
# `build_roster` reaches `inverter.py`'s `float(opts.get("max_w", 2000))`,
# which raises on a value the validator has a SENTENCE for. Cleared the
# `max_w` box and the panel answered HTTP 500 - not JSON, so the page said
# "cannot reach the add-on", which was untrue - while `validate.py`'s
# "max_w ... is not a number" was unreachable through the panel and reachable
# at startup, where `main.py` validates before it composes. That made this
# surface WEAKER than the startup gate for one field, which is the opposite
# of what this module claims below.
#
# A malformed scalar therefore falls back to validating WITHOUT device
# bounds. Those bounds come from the roster, and there is no roster; the
# validator's own complaint about the unparseable value is the answer the
# operator needs, and it is strictly more informative than a 500.
try:
units = build_roster(opts, None)
except (TypeError, ValueError) as exc:
# ⚠️ AND IT MUST NOT RETURN AN EMPTY LIST. An empty list means "no
# problems", and the page enables Save on it. If the roster could not be
# composed at all and the validator has nothing to say about why, that
# disagreement is itself the finding - so it is reported rather than
# swallowed. Returning `[]` here would turn a crash into a silent
# acceptance, which is worse than the 500 this replaced.
found = list(validate_startup_config(opts))
if not found:
found = [f"the configuration could not be composed ({type(exc).__name__}: "
f"{exc}), and the validator did not name a reason. This is a "
f"disagreement between the two and should be reported."]
return found, []
if hass is not None:
async def _limits(unit):
if unit.setpoint_entity:
+97
View File
@@ -247,6 +247,103 @@ check("...and the caller gets the new token back, so the next save can "
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")
+1 -1
View File
@@ -4,7 +4,7 @@
# Without a `translations/` directory the Configuration tab renders the raw
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
# The frontend keeps a field when `e.name in options || e.required`, and all 71
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
# rendered, always, in `schema:` order, with no description. This file is what
# turns that into sentences.
#