Files
goodwe-addon/goodwe_controller/app/config_page.py
T
glenn schrooyenandClaude 0801776645
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s
PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
F1: every password option is shown as ******** (set) or empty in /config/model
(options, running, ui) and in a stale refusal; the classic page draws it as a
password field; a save or validate that sends ******** or nothing keeps the
stored value. F2: a static_profile beside price_service is said once at start
(notification and log), not refused. F3 (owner, 2026-10-14): the no-price row,
Home line and notification only while maintenance_enabled is on. F4: tests for
the reviewer's R01 and R10. F5: price_service_token must be 16+ characters.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 18:56:51 +02:00

567 lines
23 KiB
Python

"""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 72 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 if(field.type === 'password'){
input.type = 'password';
input.autocomplete = 'new-password';
input.autocapitalize = 'none';
input.spellcheck = false;
} 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 would refuse to start on it. Saving is blocked until these '
+ 'are fixed, and nothing has been written. Each message below comes '
+ 'from the same check that runs at start.');
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>
"""