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
This commit is contained in:
glenn schrooyen
2026-09-06 21:38:31 +02:00
co-authored by Claude Opus 5
parent 3368231048
commit fdaf3fb54f
12 changed files with 2903 additions and 8 deletions
+562
View File
@@ -0,0 +1,562 @@
"""UX-02: the configuration page itself - markup, style and the form's script.
⚠️ WHY THIS IS ITS OWN FILE. `web.py` is the ROUTES: three status handlers and,
since this card, five configuration ones. The page is ~500 lines of HTML, CSS
and JavaScript, and inlining it there would bury the routing - which is the
half a reviewer has to check for "does anything here command an inverter".
⚠️ AND WHAT IS NOT IN THIS FILE, WHICH IS THE POINT OF THE CARD. There is no
option key, no label, no unit, no range, no default and NO RULE in the script
below. It renders a model the server built out of `GET /addons/self/info`
(app/configui.py) and it renders refusals the server got out of
`validate_startup_config()` (app/selfconfig.py). Its longest function is the one
that decides which HTML input a `type` deserves.
That is deliberate and it is checkable: test_selfconfig.py part 5 greps this
file for the option keys and for phrases out of validate.py's refusals and
asserts it contains none of them, and part 6 adds a NEW rule to a copy of
validate.py and watches it appear on the page with this file untouched.
⚠️ EVERY URL IS RELATIVE. Ingress serves the add-on under a generated path
prefix (`/api/hassio_ingress/<token>/`), so an absolute `/config/model` 404s in
the field while working perfectly on a laptop. web.py's own header has said so
since the status page shipped; this file has five more chances to get it wrong.
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
front of the inverter with: one column, big touch targets, sections that start
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
where it cannot be scrolled past.
"""
CONFIG_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe - Configuration</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f;--accent:#1a73e8;
--badtint:rgba(197,34,31,.10);--warntint:rgba(178,94,2,.10);
--oktint:rgba(19,115,51,.10)}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82;
--accent:#8ab4f8;--badtint:rgba(242,139,130,.14);
--warntint:rgba(253,214,99,.14);--oktint:rgba(129,201,149,.14)}}
*{box-sizing:border-box}
body{margin:0;padding:0 0 96px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
.wrap{max-width:760px;margin:0 auto;padding:14px}
h1{font-size:18px;margin:0 0 2px}
a{color:var(--accent)}
.sub{color:var(--muted);font-size:13px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.note{border-left:4px solid var(--warn);background:var(--warntint);
border-radius:8px;padding:11px 13px;margin-bottom:12px;font-size:14px}
.note.bad{border-left-color:var(--bad);background:var(--badtint)}
.note.ok{border-left-color:var(--ok);background:var(--oktint)}
.note b{display:block;margin-bottom:3px}
details.grp{background:var(--card);border:1px solid var(--line);
border-radius:10px;margin-bottom:10px;overflow:hidden}
details.grp>summary{cursor:pointer;padding:14px;font-weight:600;
list-style:none;display:flex;justify-content:space-between;gap:10px;
align-items:center}
details.grp>summary::-webkit-details-marker{display:none}
details.grp>summary::after{content:"\\25be";color:var(--muted);font-weight:400}
details.grp[open]>summary::after{content:"\\25b4"}
details.grp[open]>summary{border-bottom:1px solid var(--line)}
.gbody{padding:4px 14px 14px}
.pill{font-size:12px;font-weight:600;border-radius:999px;padding:2px 9px;
background:var(--badtint);color:var(--bad)}
.pill.q{background:var(--warntint);color:var(--warn)}
.f{padding:13px 0;border-bottom:1px solid var(--line)}
.f:last-child{border-bottom:0}
.f>label{display:block;font-weight:600;margin-bottom:2px}
.k{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
.row{display:flex;gap:8px;align-items:center;margin:6px 0 0}
.row input[type=text],.row input[type=number],.row select{flex:1 1 auto;
min-width:0;font:inherit;padding:10px;border-radius:8px;
border:1px solid var(--line);background:var(--bg);color:var(--ink)}
.row .unit{color:var(--muted);font-size:13px;white-space:nowrap}
.rng{color:var(--muted);font-size:12px;margin-top:4px}
.help{color:var(--muted);font-size:13px;margin-top:6px;cursor:pointer;
max-height:3.1em;overflow:hidden;position:relative}
.help.open{max-height:none}
.help::after{content:" more";color:var(--accent)}
.help.open::after{content:" less";color:var(--accent)}
.prob{margin-top:8px;border-left:4px solid var(--bad);background:var(--badtint);
border-radius:6px;padding:9px 11px;font-size:13.5px}
.prob+.prob{margin-top:6px}
.sw{display:flex;align-items:center;gap:10px;margin-top:6px}
.sw input{width:20px;height:20px}
.entry{border:1px solid var(--line);border-radius:9px;padding:10px 12px;
margin:10px 0;background:var(--bg)}
.entry h4{margin:0 0 2px;font-size:14px}
button{font:inherit;padding:10px 14px;border-radius:8px;
border:1px solid var(--line);background:var(--card);color:var(--ink);
cursor:pointer}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
button[disabled]{opacity:.5;cursor:not-allowed}
button.small{padding:6px 10px;font-size:13px}
.bar{position:fixed;left:0;right:0;bottom:0;background:var(--card);
border-top:1px solid var(--line);padding:10px 14px;z-index:5}
.bar .in{max-width:760px;margin:0 auto;display:flex;gap:10px;
align-items:center;flex-wrap:wrap}
.bar .msg{flex:1 1 200px;min-width:0;font-size:13px;color:var(--muted)}
dialog{border:1px solid var(--line);border-radius:12px;background:var(--card);
color:var(--ink);max-width:520px;width:92%;padding:18px}
dialog::backdrop{background:rgba(0,0,0,.5)}
.diff{font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
white-space:pre-wrap;word-break:break-word;margin-top:6px}
</style></head><body>
<div class="wrap">
<h1>GoodWe - Configuration</h1>
<p class="sub"><a href="./">&larr; Status and controls</a></p>
<div class="note" id="authority">
<b>This page and the Supervisor's Configuration tab edit the same settings.</b>
Both write one options blob. This page is the one that can tell you a
setting is wrong <i>before</i> you save it; the Configuration tab cannot,
because it only knows types and ranges. If the add-on is not running, the
Configuration tab is the only one of the two that still exists - that is
what it is for.
</div>
<div class="note" id="restartcost">
<b>Saving does not restart anything, and does not take effect on its own.</b>
Stored settings are applied when the add-on next starts. Restarting to
apply them costs a short loss of control: the add-on commands
<b>0 W on every inverter</b> as it shuts down, and the battery does nothing
until it is back up. That is a separate button, below, with its own
confirmation.
</div>
<div id="banner"></div>
<div id="staleness"></div>
<div id="form"></div>
</div>
<div class="bar"><div class="in">
<button class="primary" id="save" disabled>Save settings</button>
<button id="restart" disabled>Restart to apply</button>
<button id="reload">Discard edits</button>
<span class="msg" id="msg">loading&hellip;</span>
</div></div>
<dialog id="confirm">
<h3 style="margin-top:0">Restart the add-on?</h3>
<p>The add-on will command <b>0 W on every inverter</b>, stop, and start
again with the stored settings. Control of the battery is lost for as long as
that takes. Nothing else about the inverter is changed, and no latched
failsafe is cleared.</p>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="cancel">Cancel</button>
<button class="danger" id="go">Yes, restart now</button>
</div>
</dialog>
<script>
"use strict";
var MODEL = null; // groups/fields, from the server
var STORED = null; // the options blob the server last read
var FP = ""; // its fingerprint - the compare-and-swap token
var VALS = {}; // what is on screen right now
var timer = null;
function el(tag, cls, text){
var e = document.createElement(tag);
if(cls) e.className = cls;
if(text !== undefined && text !== null) e.textContent = text;
return e;
}
function msg(text, kind){
var m = document.getElementById('msg');
m.textContent = text;
m.style.color = kind === 'bad' ? 'var(--bad)'
: kind === 'ok' ? 'var(--ok)' : 'var(--muted)';
}
function note(host, kind, title, body){
var d = el('div', 'note ' + (kind || ''));
if(title) d.appendChild(el('b', null, title));
if(body) d.appendChild(document.createTextNode(body));
host.appendChild(d);
return d;
}
// --- the value of one input, by the type the SERVER gave the field ---------
// The only "knowledge" in this script: which HTML control a Supervisor type
// deserves, and how to read it back. No key names, no bounds, no rules.
function readInput(input, type){
if(type === 'boolean') return input.checked;
if(type === 'integer' || type === 'float'){
if(input.value === '') return '';
var n = Number(input.value);
return isNaN(n) ? input.value : n;
}
return input.value;
}
function makeInput(field, value, onchange){
var row = el('div', 'row');
var input;
if(field.type === 'boolean'){
row.className = 'sw';
input = el('input');
input.type = 'checkbox';
input.checked = !!value;
var lab = el('span', 'k', field.key);
row.appendChild(input); row.appendChild(lab);
} else if(field.type === 'select'){
input = el('select');
(field.options || []).forEach(function(o){
var opt = el('option', null, o); opt.value = o;
if(String(value) === o) opt.selected = true;
input.appendChild(opt);
});
row.appendChild(input);
} else {
input = el('input');
if(field.type === 'integer' || field.type === 'float'){
input.type = 'number';
input.inputMode = field.type === 'integer' ? 'numeric' : 'decimal';
input.step = field.type === 'integer' ? '1' : 'any';
if(field.min !== undefined) input.min = field.min;
if(field.max !== undefined) input.max = field.max;
} else {
input.type = 'text';
input.autocapitalize = 'none';
input.spellcheck = false;
}
input.value = (value === undefined || value === null) ? '' : value;
row.appendChild(input);
if(field.unit) row.appendChild(el('span', 'unit', field.unit));
}
input.addEventListener('input', onchange);
input.addEventListener('change', onchange);
return {row: row, input: input};
}
function helpBlock(field){
if(!field.help) return null;
var h = el('div', 'help', field.help);
h.title = field.help;
h.addEventListener('click', function(){ h.classList.toggle('open'); });
return h;
}
function rangeHint(field){
var bits = [];
if(field.min !== undefined || field.max !== undefined){
bits.push('accepted: ' + (field.min !== undefined ? field.min : '-')
+ ' to ' + (field.max !== undefined ? field.max : '-')
+ (field.unit ? ' ' + field.unit : ''));
}
bits.push(field.key);
return el('div', 'rng', bits.join(' \\u00b7 '));
}
// --- a list of scalars (`- str` in the schema) -----------------------------
function scalarList(field, value){
var box = el('div');
var items = Array.isArray(value) ? value.slice() : [];
function draw(){
box.textContent = '';
items.forEach(function(v, i){
var row = el('div', 'row');
var input = el('input'); input.type = 'text'; input.value = v;
input.autocapitalize = 'none'; input.spellcheck = false;
input.addEventListener('input', function(){
items[i] = input.value; VALS[field.key] = items.slice(); queue();
});
var rm = el('button', 'small', 'Remove');
rm.addEventListener('click', function(){
items.splice(i, 1); VALS[field.key] = items.slice(); draw(); queue();
});
row.appendChild(input); row.appendChild(rm);
box.appendChild(row);
});
var add = el('button', 'small', 'Add');
add.addEventListener('click', function(){
items.push(''); VALS[field.key] = items.slice(); draw(); queue();
});
box.appendChild(add);
}
draw();
return box;
}
// --- a list of objects: the roster editor ----------------------------------
function objectList(field, value){
var box = el('div');
var entries = Array.isArray(value) ? JSON.parse(JSON.stringify(value)) : [];
function push(){ VALS[field.key] = JSON.parse(JSON.stringify(entries)); }
function draw(){
box.textContent = '';
if(!entries.length){
var e = el('div', 'rng',
'No entries. With none, the settings above describe the one inverter.');
box.appendChild(e);
}
entries.forEach(function(entry, i){
var card = el('div', 'entry');
card.appendChild(el('h4', null, 'Entry ' + (i + 1)
+ (entry && entry.name ? ' \\u2014 ' + entry.name : '')));
(field.fields || []).forEach(function(sub){
var f = el('div', 'f');
// The label is the translation's own, verbatim - UX-01 already says
// "(optional)" in the ones that are, and adding a second one here
// would be this panel putting words next to a field.
f.appendChild(el('label', null, sub.label));
var made = makeInput(sub, entry ? entry[sub.key] : undefined,
function(){
entries[i][sub.key] = readInput(made.input, sub.type);
push(); queue();
});
f.appendChild(made.row);
f.appendChild(rangeHint(sub));
var h = helpBlock(sub); if(h) f.appendChild(h);
card.appendChild(f);
});
var rm = el('button', 'small danger', 'Remove this inverter');
rm.addEventListener('click', function(){
entries.splice(i, 1); push(); draw(); queue();
});
card.appendChild(rm);
box.appendChild(card);
});
var add = el('button', 'small', 'Add an inverter');
add.addEventListener('click', function(){
var fresh = {};
(field.fields || []).forEach(function(sub){
fresh[sub.key] = sub.type === 'boolean' ? false
: (sub.type === 'integer' || sub.type === 'float') ? ''
: '';
});
entries.push(fresh); push(); draw(); queue();
});
box.appendChild(add);
}
draw();
return box;
}
// --- rendering the whole form ---------------------------------------------
var FIELD_NODES = {};
function render(){
var host = document.getElementById('form');
host.textContent = '';
FIELD_NODES = {};
MODEL.groups.forEach(function(group){
var d = el('details', 'grp');
var s = el('summary');
s.appendChild(el('span', null, group.name || 'Not grouped'));
var pill = el('span', 'pill'); pill.style.display = 'none';
s.appendChild(pill);
d.appendChild(s);
var body = el('div', 'gbody');
group.fields.forEach(function(field){
var f = el('div', 'f');
f.appendChild(el('label', null, field.label));
if(field.type === 'schema'){
f.appendChild(objectList(field, VALS[field.key]));
} else if(field.multiple){
f.appendChild(scalarList(field, VALS[field.key]));
} else {
var made = makeInput(field, VALS[field.key], function(){
VALS[field.key] = readInput(made.input, field.type); queue();
});
f.appendChild(made.row);
}
if(field.type !== 'schema') f.appendChild(rangeHint(field));
var h = helpBlock(field); if(h) f.appendChild(h);
var probs = el('div'); f.appendChild(probs);
FIELD_NODES[field.key] = {probs: probs, group: d, pill: pill};
body.appendChild(f);
});
d.appendChild(body);
host.appendChild(d);
});
}
// --- refusals: printed exactly as the validator wrote them -----------------
function showProblems(payload){
Object.keys(FIELD_NODES).forEach(function(k){
FIELD_NODES[k].probs.textContent = '';
});
document.querySelectorAll('.pill').forEach(function(p){
p.style.display = 'none'; p.textContent = '';
});
var banner = document.getElementById('banner');
banner.textContent = '';
var counts = new Map();
Object.keys(payload.by_key || {}).forEach(function(key){
var node = FIELD_NODES[key];
payload.by_key[key].forEach(function(text){
var box = el('div', 'prob', text);
if(node) node.probs.appendChild(box); else banner.appendChild(box);
});
if(node){
counts.set(node.pill, (counts.get(node.pill) || 0)
+ payload.by_key[key].length);
node.group.open = true;
}
});
counts.forEach(function(n, pill){
pill.textContent = n + (n === 1 ? ' problem' : ' problems');
pill.style.display = '';
});
(payload.unanchored || []).forEach(function(text){
banner.appendChild(el('div', 'prob', text));
});
var total = (payload.problems || []).length;
var save = document.getElementById('save');
save.disabled = total > 0;
if(total){
note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. '
+ 'Saving is blocked until these are fixed; nothing has been written. '
+ 'Each message below is the add-on\\u2019s own words, from the same '
+ 'check that runs at startup.');
banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad');
} else {
msg('this configuration would start', 'ok');
}
if(payload.device_note){
note(banner, '', 'Two range checks could not run.', payload.device_note);
}
}
// ⚠️ THE DEBOUNCE IS WHAT BOUNDS THE READS THIS PAGE MAKES OF HOME ASSISTANT.
// Each validation asks the setpoint device(s) for their own range, and those
// are real Core API reads on the same session the control loop uses. Because
// every keystroke RESETS the timer, sustained typing produces no requests at
// all and one lands 350 ms after the operator stops - which is the property
// that matters, not the number.
function queue(){
msg('checking\\u2026');
document.getElementById('save').disabled = true;
if(timer) clearTimeout(timer);
timer = setTimeout(validate, 350);
}
async function validate(){
try{
const r = await fetch('config/validate', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({options: VALS})});
showProblems(await r.json());
}catch(e){
msg('cannot reach the add-on: ' + e, 'bad');
}
}
function showStaleness(keys, stored){
var host = document.getElementById('staleness');
host.textContent = '';
if(!keys || !keys.length) return;
var d = note(host, '', 'Saved, but not running yet.',
'These settings are stored and differ from what this add-on is running '
+ 'right now. They take effect when it next starts:');
var pre = el('div', 'diff', keys.map(function(k){
return k + ': running ' + JSON.stringify(stored.running[k])
+ ' -> stored ' + JSON.stringify(stored.stored[k]);
}).join('\\n'));
d.appendChild(pre);
}
async function load(){
msg('loading\\u2026');
try{
const r = await fetch('config/model?lang='
+ encodeURIComponent((navigator.language || 'en')
.slice(0, 2)),
{cache: 'no-store'});
const m = await r.json();
MODEL = m; STORED = m.options; FP = m.fingerprint;
VALS = JSON.parse(JSON.stringify(m.options));
render();
showStaleness(m.running_differs, {running: m.running, stored: m.options});
document.getElementById('restart').disabled = false;
await validate();
}catch(e){
msg('cannot load the configuration: ' + e, 'bad');
}
}
async function save(){
msg('saving\\u2026');
document.getElementById('save').disabled = true;
var r, body;
try{
r = await fetch('config/save', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({options: VALS, fingerprint: FP})});
body = await r.json();
}catch(e){
msg('cannot reach the add-on: ' + e, 'bad');
document.getElementById('save').disabled = false;
return;
}
if(r.ok){
FP = body.fingerprint;
msg('saved - restart to apply', 'ok');
var host = document.getElementById('staleness');
host.textContent = '';
note(host, 'ok', 'Saved. Not applied yet.',
'The add-on is still running the settings it started with. Press '
+ '"Restart to apply" when you are ready to lose control of the '
+ 'battery for a few seconds.');
return;
}
if(body.kind === 'stale'){
var host2 = document.getElementById('staleness');
host2.textContent = '';
var d = note(host2, 'bad', 'Nothing was saved: these settings changed '
+ 'somewhere else while you were editing.',
'Somebody - most likely the Supervisor\\u2019s Configuration tab - '
+ 'wrote these settings after this page loaded. Your edits are still on '
+ 'screen and nothing has been overwritten. Read what changed, then '
+ 'press Save again if you still want your version.');
d.appendChild(el('div', 'diff', (body.changed || []).map(function(k){
return k + ': theirs ' + JSON.stringify(body.stored[k])
+ ' -> mine ' + JSON.stringify(VALS[k]);
}).join('\\n') || 'no key that this page also edits'));
FP = body.fingerprint;
msg('not saved - the stored settings had changed', 'bad');
document.getElementById('save').disabled = false;
return;
}
if(body.kind === 'invalid'){
showProblems(body);
msg('not saved - the configuration would not start', 'bad');
return;
}
msg('not saved: ' + (body.error || r.status), 'bad');
document.getElementById('save').disabled = false;
}
document.getElementById('save').addEventListener('click', save);
document.getElementById('reload').addEventListener('click', load);
document.getElementById('restart').addEventListener('click', function(){
document.getElementById('confirm').showModal();
});
document.getElementById('cancel').addEventListener('click', function(){
document.getElementById('confirm').close();
});
document.getElementById('go').addEventListener('click', async function(){
document.getElementById('confirm').close();
msg('restarting\\u2026');
try{
await fetch('config/restart', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({confirm: 'restart'})});
msg('restart requested - this page will stop answering for a moment');
}catch(e){
msg('restart requested - this page will stop answering for a moment');
}
});
load();
</script></body></html>
"""
+315
View File
@@ -0,0 +1,315 @@
"""UX-02: the config surface's MODEL - built from what the Supervisor returns.
WHAT THIS MODULE IS FOR, in one sentence: turn the three things
`GET /addons/self/info` already carries - `schema`, `translations` and
`options` - into the shape a form renders from, WITHOUT this repository owning
a second copy of any of them.
⚠️ THE RULE THIS FILE EXISTS TO OBEY. There is exactly one description of the
option surface (config.yaml's `schema:` block), exactly one set of labels
(translations/*.yaml, UX-01) and exactly one set of cross-field rules
(app/validate.py). A config UI is the classic place where all three get
transcribed a second time - a hand-written field list, a hand-written label, a
hand-written "max_w must not exceed the device" in JavaScript - and the second
copy is what drifts. So:
* the FIELDS come from `info["schema"]`, which is Supervisor's own
`App.schema_ui` == `UiOptions(...)(config.yaml["schema"])`. Nothing here
lists an option key.
* the LABELS come from `info["translations"]`, which is Supervisor's own
`_read_app_translations()` over translations/*.yaml. Nothing here contains
an English or Dutch sentence about an option.
* the RULES are not here at all. `problems_by_field()` takes strings the
SHIPPED validator produced and decides only WHERE ON THE PAGE to put them.
The consequence is the acceptance criterion: a rule added to validate.py, an
option added to config.yaml, or a label improved in translations/en.yaml all
reach the panel with no change to this file, to web.py, or to the page's
JavaScript. test_selfconfig.py part 6 proves the first of those by running the
panel against a MUTATED COPY of validate.py in a subprocess.
⚠️ STDLIB ONLY, AND THAT IS LOAD-BEARING. This module is imported by
tools/supervisor_selfapi_probe.py, which runs inside the Home Assistant
Supervisor release image so that the model can be built from the REAL loader's
output rather than from a transcription of it. That image has no paho-mqtt, so
an import of anything under `app/` that reaches `mqtt.py` would make the probe
unrunnable and the measurement would quietly become a re-reading.
--- WHAT THE SUPERVISOR CANNOT RENDER, AND WHY THAT IS THE POINT -------------
Add-on options are a FLAT LIST. UX-01's own header says it: no section-header
primitive, no units on an input, no per-option grouping. UX-01 answered that by
prefixing every label with `Group · `, which is the best a flat list allows.
This module reads that prefix back off and turns it into a real heading, so the
grouping the operator already has in words becomes grouping they have on the
screen - in whichever language their labels resolved in, because the prefix is
translated too (`Limits` / `Grenzen`).
⚠️ NOTHING IS INVENTED WHEN THE PREFIX IS ABSENT. A key whose label carries no
` · ` is NOT quietly filed under the last heading seen - that would put a new
option in a section nobody chose and it would look deliberate. It goes into the
model's `ungrouped` list, is rendered under a heading that says so, and
test_selfconfig.py asserts the list is EMPTY today, so the day config.yaml
grows a key UX-01 has not grouped, a check says so instead of the page guessing.
"""
import re
__all__ = ["resolve_entry", "label_of", "help_of", "split_group", "unit_of",
"field_model", "problems_by_field", "changed_keys"]
# --------------------------------------------------------------------------- #
# 1. LABELS - the frontend's own resolution order, transcribed once
#
# ⚠️ THIS IS A TRANSCRIPTION OF SOMEBODY ELSE'S CODE AND IT IS LABELLED AS ONE.
# tools/supervisor_shape_probe.py section F reads the resolution out of the
# shipped frontend bundle in the release image this site runs
# (`5680.*.js`, frontend 20250925.1):
#
# computeLabel = (s,_,o) => this._getTranslationEntry(this.hass.language,s,o)?.name
# || this._getTranslationEntry("en",s,o)?.name
# || s.name
#
# i.e. THE FALLBACK IS PER KEY, `en` is the second chance and the raw key is the
# third. The panel resolves labels the same way ON PURPOSE: two config surfaces
# that disagree about what a field is CALLED would be worse than one ugly one.
# If that resolution ever changes upstream, this is a copy that can go stale -
# it is three lines, it is named here, and the probe prints both renderings side
# by side so a divergence is visible rather than argued about.
# --------------------------------------------------------------------------- #
def resolve_entry(translations, lang, key, path=()):
"""Yield `(language, entry)` for `key`, in the per-key `lang -> en` order.
`translations` is `info["translations"]` verbatim: `{lang: {"configuration":
{key: {"name", "description", "fields": {...}}}}}`. `path` names the
enclosing repeatable section for a per-entry key, e.g. `("inverters",)`.
"""
for language in (lang, "en"):
node = ((translations or {}).get(language) or {}).get("configuration")
if not isinstance(node, dict):
continue
for step in path:
node = ((node.get(step) or {}).get("fields")) or {}
entry = node.get(key)
if isinstance(entry, dict):
yield language, entry
def label_of(translations, lang, key, path=()):
"""(label, source-language) with the raw key as the last resort."""
for language, entry in resolve_entry(translations, lang, key, path):
name = str(entry.get("name") or "").strip()
if name:
return name, language
return key, "raw"
def help_of(translations, lang, key, path=()):
"""(description, source-language). "" when nothing was translated."""
for language, entry in resolve_entry(translations, lang, key, path):
text = str(entry.get("description") or "").strip()
if text:
return text, language
return "", "none"
# UX-01's separator, U+00B7 with a space each side. It is not a general
# delimiter guess: it is the exact string that file's generator writes, and the
# audit in tools/translations_lint.py is what keeps every label carrying one.
GROUP_SEP = " · "
def split_group(label):
"""`"Limits · Inverter rating (W)"` -> `("Limits", "Inverter rating (W)")`.
A label with no separator comes back as `("", label)` - see the module
docstring on why that is not filed under the previous heading.
"""
if GROUP_SEP in label:
group, _, rest = label.partition(GROUP_SEP)
return group.strip(), rest.strip()
return "", label
# --------------------------------------------------------------------------- #
# 2. UNITS - presentation, derived from the key's own suffix
#
# ⚠️ THIS IS THE ONE PLACE THIS MODULE PUTS WORDS NEXT TO A FIELD, and it is
# deliberately the weakest possible rule: a suffix map, applied to a key name,
# affecting nothing but what is printed to the right of an input box. It states
# no bound, no default and no relation, so it cannot disagree with config.yaml
# or with validate.py about anything - the worst a wrong entry here can do is
# print "W" beside a number that is in seconds, which is visible to the first
# person who reads the screen.
#
# It is NOT a schema, and it must never become one: a unit string here that
# named a bound - "h (0-23)", say - would be a second copy of a range that
# config.yaml already states and the panel already renders from the schema. One
# such entry was written and removed; test_selfconfig.py part 5 is what removed
# it, by asserting that no unit string contains a digit.
#
# Anything the map does not know about gets no unit rather than a guessed one.
# --------------------------------------------------------------------------- #
_UNITS = (
("_eur_kwh", "€/kWh"),
("_eur", "€"),
("_soc_floor", "%"),
("_soc_target", "%"),
("_soc_ceiling", "%"),
("_days", "days"),
("_min", "min"),
("_hour", "h"),
("_horizon_h", "h"),
("_cycles", "cycles"),
("_phases", "phases"),
("_w", "W"),
("_s", "s"),
)
def unit_of(key):
for suffix, unit in _UNITS:
if key.endswith(suffix):
return unit
return ""
# --------------------------------------------------------------------------- #
# 3. THE MODEL
# --------------------------------------------------------------------------- #
def _one_field(node, translations, lang, path=()):
"""One entry of Supervisor's `schema_ui` -> one field of the model.
⚠️ THE TYPE, THE RANGE AND THE ENUM ARE COPIED, NOT DECIDED. `type`,
`lengthMin`/`lengthMax` (which is what `UiOptions` calls a NUMERIC range,
misleadingly, and measured rather than guessed - see
tools/supervisor_selfapi_probe.py section C) and `options` come straight off
the node. This function chooses no bound of its own, so a range edited in
config.yaml is a range the panel enforces on the very next page load.
"""
key = str(node.get("name") or "")
raw_label, label_lang = label_of(translations, lang, key, path)
group, label = split_group(raw_label)
text, help_lang = help_of(translations, lang, key, path)
field = {
"key": key,
"label": label,
"group": group,
"help": text,
"label_lang": label_lang,
"help_lang": help_lang,
"type": str(node.get("type") or "string"),
"required": bool(node.get("required")),
"multiple": bool(node.get("multiple")),
"unit": unit_of(key),
}
if node.get("lengthMin") is not None:
field["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
field["max"] = node["lengthMax"]
if node.get("options"):
field["options"] = list(node["options"])
if node.get("type") == "schema":
field["fields"] = [_one_field(sub, translations, lang, path + (key,))
for sub in node.get("schema") or ()]
return field
def field_model(schema, translations, lang="en"):
"""`info["schema"]` + `info["translations"]` -> the groups a page renders.
Returns `{"groups": [{"name", "fields": [...]}], "ungrouped": [key, ...],
"keys": [key, ...]}`.
⚠️ ORDER IS THE SCHEMA'S, AND SO IS GROUP ORDER. `App.schema_ui` renders
config.yaml's `schema:` block in file order (measured in
tools/supervisor_shape_probe.py section F), and a group takes its position
from the FIRST field that names it. So the panel's section order is
config.yaml's section order, and moving a block in that file moves it here -
which is the property that stops this model needing an order of its own.
"""
groups, order, ungrouped, keys = {}, [], [], []
for node in schema or ():
field = _one_field(node, translations, lang)
keys.append(field["key"])
name = field["group"]
if not name:
ungrouped.append(field["key"])
if name not in groups:
groups[name] = []
order.append(name)
groups[name].append(field)
return {
"groups": [{"name": name, "fields": groups[name]} for name in order],
"ungrouped": ungrouped,
"keys": keys,
}
# --------------------------------------------------------------------------- #
# 4. WHERE A REFUSAL GOES ON THE PAGE
#
# ⚠️ READ THIS BEFORE ADDING ANYTHING TO IT. The entire value of this card is
# that validate.py's cross-field sentences reach the operator BEFORE a save.
# Those sentences are written by validate.py and are shown BYTE FOR BYTE. This
# function decides one thing and one thing only: which field to print each
# sentence under. It does not shorten, re-word, classify by severity, or map a
# message to a rule number - every one of those would be a second, drifting
# opinion about a rule this module is not allowed to hold.
#
# THE MECHANISM IS NAME OCCURRENCE, and it knows no keys of its own: the caller
# passes the key list from the schema. A message that names `max_w` is filed
# under `max_w`. That is why validate.py's habit of naming the option it is
# refusing - which it already had, for the log - is what makes the anchoring
# work, and why nothing here breaks when a rule is added.
#
# ⚠️ WORD BOUNDARIES, AND THE COLLISION THEY EXIST FOR. `sign_disagree_w` is a
# substring of `unit_sign_disagree_w` and of `site_sign_disagree_w` - three
# distinct options, two of them SAFETY-30's. A plain `in` test files every
# per-unit refusal under the site key as well, which would put a message about
# unit-0 next to a field that has nothing to do with it. The boundary class
# excludes `_` on purpose so that the longer names do not match the shorter key.
#
# ⚠️ AND THE HONEST LIMIT, STATED RATHER THAN DISCOVERED: a refusal whose
# sentence never spells an option key cannot be anchored, and is shown in the
# page's top-level refusal block instead. Nothing is lost - it is the same
# sentence, on the same screen, before the same save - it simply is not beside
# a box. test_selfconfig.py part 4 MEASURES how many of the shipped rules
# anchor and prints the ones that do not, rather than this comment claiming a
# coverage figure.
# --------------------------------------------------------------------------- #
def problems_by_field(problems, keys):
"""(by_key, unanchored) for a list of validator sentences.
A sentence naming several keys is filed under EVERY one of them - a
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
hit = False
for key, pattern in patterns:
if pattern.search(problem):
by_key.setdefault(key, []).append(problem)
hit = True
if not hit:
unanchored.append(problem)
return by_key, unanchored
def changed_keys(left, right):
"""Sorted keys whose values differ between two option blobs.
Used for one thing: saying WHICH settings are stored but not yet running,
and which the other configuration surface moved underneath this one. It
compares values as they are, so a list reordered counts as changed - which
is correct for `inverters`, where order is the roster order.
"""
return sorted(k for k in set(left) | set(right)
if left.get(k) != right.get(k))
+372
View File
@@ -0,0 +1,372 @@
"""UX-02: reading and writing THIS add-on's own options, through the Supervisor.
WHAT THIS IS. An add-on may read and write its own options. That is not a
guess: `supervisor/api/middleware/security.py` in
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` - the version this
site runs - matches an installed app's own token against
api_bypass = ^(?:|/addons/self/(?!security|update)[^/]+
|/addons/self/options/config|/info|/services.*
|/discovery.*|/auth)$
BEFORE it looks at `access_hassio_api` or at any role, so `GET
/addons/self/info`, `POST /addons/self/options` and `POST /addons/self/restart`
need no `hassio_role` at all. tools/supervisor_selfapi_probe.py RUNS those
compiled patterns against these exact paths rather than re-reading the regex,
because a bypass list is precisely the kind of thing that is read optimistically.
⚠️ AND THE PROBE FOUND ONE THE CARD DID NOT PREDICT, WHICH IS WHY IT WAS RUN.
`/addons/self/options/validate` - Supervisor's OWN options validator - has TWO
path segments after `self`, so `[^/]+` does not match it and it is not in the
list of literals either. An add-on cannot call it. That is not a problem for
this card, because the Supervisor's validator only knows the schema (types and
ranges) and this panel's whole reason to exist is the CROSS-FIELD rules it
structurally cannot express - but it does mean there is exactly one validator
reachable from here, `app/validate.py`, which is the one we want.
--- THE THREE THINGS THIS MODULE REFUSES TO DO ------------------------------
1. ⚠️ IT NEVER WRITES A BLOB THE SHIPPED VALIDATOR REFUSED. Not "warns and
writes"; does not write. The reason is specific to an INGRESS panel and it
is the failure mode that makes this card dangerous: 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 would be left with no way back except the
Supervisor UI - the surface this card exists because it is hard to read.
So the refusal happens here, before the POST, and the edits stay on screen.
2. ⚠️ IT NEVER OVERWRITES A BLOB THAT MOVED UNDERNEATH IT. The Supervisor's
Configuration tab CANNOT be removed (see DECISIONS/UX-02-two-config-
surfaces.md), so there are two editors of one blob, and `POST
/addons/self/options` REPLACES the stored user options - measured, not
assumed: `App.options`'s setter is `self.persist[ATTR_OPTIONS] = deepcopy(
value)`, and the effective options are `deepmerge(config defaults, persist)`.
A key omitted from the POST therefore falls back to config.yaml's DEFAULT
rather than to its previous value.
This is the same shape of hazard as two writers on one actuator, and it gets
the same discipline. `Controller.write_refusal()` (SAFETY-28) says: A NUMBER
REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS TICK, FOR
THIS UNIT. Here: A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM
THE BLOB THAT IS STORED RIGHT NOW. The read hands out a `fingerprint()`, the
save hands it back, and the save re-reads and compares before writing. A
change made in the other tab in the meantime is a REFUSAL with the changed
keys named, not a silent clobber.
⚠️ THE COMPARISON IS RE-READ IMMEDIATELY BEFORE THE WRITE, not taken from
the page load, so the window in which the other surface can slip in is one
HTTP round trip rather than however long the operator spent typing.
3. ⚠️ IT NEVER RESTARTS AS A SIDE EFFECT OF SAVING. Applying options costs a
restart, and `Controller.shutdown()` commands 0 W on every inverter on the
way out (main.py, "shutting down - commanding 0 W on every inverter"). That
is a real, if brief, loss of control of the battery, so it is its own
deliberate action with its own confirmation - `restart()` below requires an
explicit token in the request body, so no stray POST can spend it.
--- WHAT THIS MODULE MAY NOT GROW INTO --------------------------------------
⚠️ NOTHING HERE COMMANDS AN INVERTER, CLEARS A LATCH, OR REACHES A SAFETY
MECHANISM. The panel's configuration half talks to the Supervisor about a JSON
blob and to `validate.py` about whether that blob is startable; the only
actuator-shaped verb in the file is `restart()`, which restarts THIS ADD-ON and
whose 0 W is the existing, documented wind-down. Adding "and also set the
setpoint while we are here" would put a second writer on the actuator through
the config surface, which is the non-goal this add-on is built around.
"""
import asyncio
import hashlib
import json
import logging
import os
import aiohttp
from .configui import changed_keys
from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges
_LOG = logging.getLogger("goodwe.selfconfig")
SUPERVISOR_API = "http://supervisor"
SELF_INFO = f"{SUPERVISOR_API}/addons/self/info"
SELF_OPTIONS = f"{SUPERVISOR_API}/addons/self/options"
SELF_RESTART = f"{SUPERVISOR_API}/addons/self/restart"
# The word the page has to send with a restart request. It is not a password -
# ingress means Home Assistant has already authenticated the caller - it is an
# INTENT token, so that a reload, a retry or a stray fetch cannot spend a
# control interruption on the operator's behalf.
RESTART_CONFIRM = "restart"
# ⚠️ LONGER THAN hass.py's 10 s, AND FOR THE OPPOSITE REASON. `hass.py` would
# rather abandon a write and re-issue it on the next tick than hold the control
# loop. Nothing here is on the control loop - these calls are made by an
# operator standing at a screen - and a Supervisor busy with a backup can take
# a while to answer. Abandoning a `POST /addons/self/options` early would leave
# the panel unsure whether the blob was stored, which is the one state this
# module must never be in.
TIMEOUT_S = 30.0
class SupervisorUnavailable(Exception):
"""The Supervisor could not be reached, or answered with an error."""
class NotConfirmed(Exception):
"""A control interruption was asked for without the intent token.
Its own exception rather than a `SaveRefused`, because it is not a refusal
ABOUT a configuration - nothing was validated and nothing was compared. It
means the request did not say what it was asking for.
"""
class SaveRefused(Exception):
"""The save did not happen, and NOTHING WAS WRITTEN.
`kind` is `"invalid"` (the shipped validator refused the blob) or `"stale"`
(the stored blob changed underneath). `problems` carries the validator's
own sentences, byte for byte; `changed` names the keys that moved.
"""
def __init__(self, kind, problems=(), changed=(), stored=None):
self.kind = kind
self.problems = list(problems)
self.changed = list(changed)
self.stored = stored
super().__init__(f"save refused ({kind})")
def canonical(options) -> str:
"""The one textual form of an options blob, for identity only.
Sorted keys, no insignificant whitespace, `ensure_ascii=False` so an entity
id with a non-ASCII character is one character rather than an escape - the
string is hashed, never displayed, so the only property that matters is
that equal blobs produce equal text and different blobs do not.
"""
return json.dumps(options, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, default=str)
def fingerprint(options) -> str:
"""The compare-and-swap token for one options blob.
⚠️ A DIGEST, NOT A VERSION NUMBER, because there is no version to read. The
Supervisor exposes no revision or ETag for an add-on's options, so the only
thing the panel can hold onto between reading and writing is the CONTENT it
read. That is enough for what this is for: the question is not "how many
times has this changed" but "is what I am about to overwrite still what I
was shown". Identical content is by definition safe to overwrite.
"""
return hashlib.sha256(canonical(options).encode("utf-8")).hexdigest()
class SelfConfig:
"""The add-on's own options, read and written through the Supervisor."""
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
@property
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"}
async def _call(self, method, url, payload=None):
try:
async with self.session.request(
method, url, headers=self._headers, json=payload,
timeout=aiohttp.ClientTimeout(total=TIMEOUT_S),
) as resp:
text = await resp.text()
if resp.status >= 400:
# The Supervisor's own message is carried through unedited:
# `{"result": "error", "message": "..."}`. A rewritten
# error is a second opinion about somebody else's failure.
try:
detail = (json.loads(text) or {}).get("message") or text
except ValueError:
detail = text
raise SupervisorUnavailable(
f"{method} {url} -> HTTP {resp.status}: "
f"{str(detail)[:400]}")
try:
body = json.loads(text) if text else {}
except ValueError as err:
raise SupervisorUnavailable(
f"{method} {url} -> unreadable answer: {err}") from None
return body.get("data", body)
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
raise SupervisorUnavailable(f"{method} {url} -> {err}") from None
async def info(self) -> dict:
"""`GET /addons/self/info`.
⚠️ THREE OF THIS PANEL'S FOUR INGREDIENTS COME OUT OF THIS ONE CALL, and
that is the reason the panel owns no copy of any of them. Measured on
2026.08.0 (`supervisor/api/apps.py:info_data`):
`options` - `App.options`, i.e. deepmerge(config.yaml defaults,
the stored user options). Every key is present, which
is what makes a whole-blob read-modify-write safe.
`schema` - `App.schema_ui`, i.e. `UiOptions(...)(config.yaml
["schema"])`. The field list, types, ranges and
enums, in config.yaml's own order.
`translations` - `App.translations`, i.e. Supervisor's own
`_read_app_translations()` over translations/*.yaml,
which is UX-01's work reaching a second surface for
free.
The fourth ingredient is `app/validate.py`, and it is not fetched.
"""
return await self._call("GET", SELF_INFO)
async def write_options(self, options: dict) -> None:
"""`POST /addons/self/options` - REPLACES the stored user options.
⚠️ CALL THIS ONLY THROUGH `save()`. On its own it is the unguarded
write: no validation, no compare-and-swap. It is a separate method so
that the gate above it can be read - and tested - as a gate.
"""
await self._call("POST", SELF_OPTIONS, {"options": options})
async def restart(self, confirm: str) -> None:
"""`POST /addons/self/restart`. Costs a control interruption.
The add-on's own wind-down runs first: `Controller.shutdown()` commands
0 W on every inverter before the process exits. So this is not an
abrupt loss of control, it is a deliberate one - but it IS one, and the
page says so before the operator presses it.
"""
if confirm != RESTART_CONFIRM:
raise NotConfirmed(
"a restart interrupts control and must be confirmed")
_LOG.warning("operator asked for a restart from the ingress panel - "
"the wind-down commands 0 W on every inverter first")
await self._call("POST", SELF_RESTART)
async def problems_for(opts: dict, hass=None) -> list:
"""Every reason the SHIPPED validator would refuse to start on `opts`.
⚠️ THIS IS THE STARTUP GATE'S OWN COMPOSITION, NOT A SUBSET OF IT. The
gate runs in TWO places in main.py and this is the union of both, which is
what an operator meets:
* `amain()` calls `validate_startup_config(opts, dev_min, dev_max)`
UNCONDITIONALLY, before anything starts - so this does too, with the
flat `setpoint_entity`'s device range when that entity was readable and
None when it was not;
* `run_control()` adds `validate_unit_ranges(roster, skip_entity=flat)`
once every unit's own ESPHome has answered - so this does too, with the
same `skip_entity`, so one inverter is not reported twice under two
wordings.
A panel that ran only half the gate would go green on a config that then
exits 78 - which is worse than no panel, because the operator would have
been told it was fine.
⚠️ AND THE DEVICE RANGES ARE READ, NOT INVENTED. `hass.limits()` is a READ
of a Home Assistant state; nothing here writes anything to any device. When
a device does not answer - ESPHome asleep, an entity that does not exist
yet, or `hass=None` in a check - the range is None and the range rules are
SKIPPED, exactly as they are at startup. That is the honest behaviour and it
is also the conservative one: the panel then shows fewer refusals than the
startup gate might, never more, so it cannot bless a config the gate refuses
on a rating it could not see. It is stated on the page rather than hidden.
`build_roster(opts, None)` builds the roster with NO Store, so this reads
no latch and writes no state; it exists to give `validate_unit_ranges()`
the same objects `main.py` gives it, rather than a second transcription of
what counts as a roster entry.
"""
problems, _unread = await problems_and_ranges(opts, hass)
return problems
async def problems_and_ranges(opts: dict, hass=None):
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
OPERATOR CANNOT SEE. Two rules compare a configured ceiling against the
device's own rating, and when the device is silent they are skipped rather
than guessed at - so an unread range means the panel is showing FEWER
refusals than the startup gate might, and it has to say so, naming the
entity that did not answer rather than hinting at one.
It is a separate function from `problems_for()` because the answer is about
the CONFIGURATION UNDER TEST, not about the running roster: an operator who
has just typed a new `setpoint_entity` must be told about THAT entity, and
a route that asked the running controller instead would report on the old
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)
if hass is not None:
async def _limits(unit):
if unit.setpoint_entity:
unit.dev_min, unit.dev_max, _ = await hass.limits(
unit.setpoint_entity)
await asyncio.gather(*(_limits(unit) for unit in units))
dev_min = dev_max = None
for unit in units:
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
dev_min, dev_max = unit.dev_min, unit.dev_max
break
problems = list(validate_startup_config(opts, dev_min, dev_max))
problems += validate_unit_ranges(units, skip_entity=flat)
unread = [unit.setpoint_entity for unit in units
if unit.setpoint_entity and unit.dev_min is None
and unit.dev_max is None]
return problems, unread
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
hass=None) -> dict:
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
with nothing written otherwise.
THE ORDER IS DELIBERATE AND BOTH HALVES ARE PRE-WRITE GATES:
1. VALIDATE FIRST. It is local, it costs nothing, and its refusal is
about what is on the operator's screen - so it is the answer they can
act on without knowing anything about the other surface.
2. RE-READ AND COMPARE SECOND, immediately before the POST, so the
window in which the Supervisor tab can slip a change past is one round
trip rather than the length of an editing session.
3. Only then write.
⚠️ `expect` IS REQUIRED AND THERE IS NO "FORCE" DOOR. A caller that has not
read the current blob has nothing to compare, and a save that skips the
comparison is the clobber this gate exists to prevent. If the operator
genuinely wants what the other surface wrote replaced, the page reloads,
shows them what changed, and they save again from THAT - which is a
decision made in front of the difference rather than through it.
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
"nothing written. First: %s", len(problems), problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
stored = info.get("options") or {}
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
await selfcfg.write_options(proposed)
_LOG.info("options saved from the ingress panel; a restart is needed "
"before they take effect")
return {"fingerprint": fingerprint(proposed)}
+186 -3
View File
@@ -8,12 +8,33 @@ perfectly on a developer's laptop.
Design bias: this page must be readable at the end of a long day, on a phone, in
a cellar, by someone who did not write it. Numbers alone are not enough - it
says what is wrong in words.
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
`/` is the status and control surface and is unchanged. `/config` is the
configuration surface: its markup lives in app/config_page.py, its field model
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
stays HERE is the routing, so that the question a reviewer has to answer - does
anything the panel exposes reach the inverter? - is answered by reading one
short file.
⚠️ THE ANSWER TO THAT QUESTION, STATED SO IT CAN BE CHECKED: the configuration
routes below read `GET /addons/self/info`, run the SHIPPED validator, write
`POST /addons/self/options` and, on an explicitly confirmed request, `POST
/addons/self/restart`. NONE of them writes a setpoint, clears a latch, changes a
mode or reaches `controller.handle_action()`. The set of things an operator can
do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set.
"""
import logging
from aiohttp import web
from . import selfconfig
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
_LOG = logging.getLogger("goodwe.web")
PAGE = """<!doctype html>
@@ -49,6 +70,7 @@ PAGE = """<!doctype html>
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<p class="muted"><a href="config">Configuration &rarr;</a></p>
<div id="banner" class="banner b-warn">loading…</div>
<div class="card">
@@ -95,7 +117,33 @@ refresh(); setInterval(refresh, 3000);
"""
def build_app(controller) -> web.Application:
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
# validate.py's rules compare a configured ceiling against the setpoint
# device's OWN reported range, and that range is only knowable by asking Home
# Assistant. When ESPHome is asleep or the entity does not exist yet the
# startup gate SKIPS those two rules rather than guessing - so the panel skips
# them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured "
"ceiling against what the device itself accepts were skipped - here and at "
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
def build_app(controller, selfcfg=None) -> web.Application:
"""The ingress app. `selfcfg` is injectable so a check can drive it.
⚠️ IT IS BUILT FROM THE CONTROLLER'S OWN SESSION AND TOKEN, not from a
second `ClientSession`. `amain()` owns one session for the process and every
Home Assistant call already goes through it; a second one here would be a
second connection pool whose failures nothing reports.
"""
if selfcfg is None:
selfcfg = selfconfig.SelfConfig(controller.hass.session,
controller.hass.token)
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
@@ -113,17 +161,152 @@ def build_app(controller) -> web.Application:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
return web.json_response({"ok": True, "result": result})
# --- UX-02: the configuration surface ---------------------------------
async def config_index(_request):
return web.Response(text=CONFIG_PAGE, content_type="text/html")
async def _proposed(request):
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
return body, options
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
⚠️ `running_differs` IS NOT COSMETIC. `controller.o` is the blob this
PROCESS started with (`/data/options.json`); `info["options"]` is what
the Supervisor has STORED and would apply at the next start. They differ
exactly when somebody has saved and not restarted - here, or in the
Configuration tab. Printing the difference is the only way an operator
finds out that the page they are reading is not describing the running
system, and it is also how an edit made on the other surface becomes
visible on this one.
"""
try:
info = await selfcfg.info()
except selfconfig.SupervisorUnavailable as err:
_LOG.error("cannot read the add-on's own options: %s", err)
return web.json_response({"error": str(err)}, status=502)
options = info.get("options") or {}
lang = str(request.query.get("lang", "en") or "en")[:8]
model = field_model(info.get("schema") or [],
info.get("translations") or {}, lang)
model.update({
"options": options,
"fingerprint": selfconfig.fingerprint(options),
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
})
return web.json_response(model)
async def config_validate(request):
"""The SHIPPED validator, run against a proposed blob. Writes nothing.
⚠️ IT READS HOME ASSISTANT AND WRITES NOTHING ANYWHERE. The two
device-range rules need `hass.limits()`, which is a GET of an entity's
state. The page debounces, so a burst of typing is one call after the
typing stops rather than one per keystroke.
⚠️ THE STRINGS ARE PASSED THROUGH UNTOUCHED. Not summarised, not
renumbered, not mapped to a severity. validate.py's sentences are the
product this card is delivering; anything this layer did to them would
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass)
by_key, unanchored = problems_by_field(problems, _keys_of(options))
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
return web.json_response(payload)
def _keys_of(options):
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
# sound only because the page posts the WHOLE blob it read - which is
# `App.options`, the merge of config.yaml's defaults with the stored
# ones, so every option key is in it. Nothing here types a key name;
# see configui.problems_by_field() for what is done with them.
return sorted(options)
async def config_save(request):
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
body, options = await _proposed(request)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
hass=controller.hass)
except selfconfig.SaveRefused as refused:
if refused.kind == "stale":
stored = refused.stored or {}
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"stored": stored,
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
# made in front of what changed.
"fingerprint": selfconfig.fingerprint(stored)},
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
return web.json_response(
{"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts")
return web.json_response({"ok": True, **result})
async def config_restart(request):
"""Restart the add-on, at the operator's explicit request.
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
the request must carry the intent token; a reload, a retry or a stray
POST cannot spend a control interruption. It restarts THIS ADD-ON and
nothing else: no setpoint is written from here, and a latched failsafe
is restored from the Store on the way back up rather than cleared.
"""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
try:
await selfcfg.restart(str(body.get("confirm") or ""))
except selfconfig.NotConfirmed as err:
return web.json_response({"error": str(err)}, status=400)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel")
return web.json_response({"ok": True})
app = web.Application()
app.add_routes([
web.get("/", index),
web.get("/status", status),
web.post("/action", action),
web.get("/config", config_index),
web.get("/config/model", config_model),
web.post("/config/validate", config_validate),
web.post("/config/save", config_save),
web.post("/config/restart", config_restart),
])
return app
async def start(controller, port: int = 8099):
runner = web.AppRunner(build_app(controller))
async def start(controller, port: int = 8099, selfcfg=None):
runner = web.AppRunner(build_app(controller, selfcfg))
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()