UX-02: the validator's refusals, next to the field, before the save #61

Merged
admin merged 3 commits from UX-02 into release/1.0 2026-09-06 22:45:49 +02:00
3 changed files with 62 additions and 3 deletions
Showing only changes of commit 2bb07ccdde - Show all commits
+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:
+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.
#