Files
goodwe-addon/goodwe_controller/app/config_page.py
Claude Opus 5 ac9854886b
checks / unit (pull_request) Successful in 7m14s
TARIFF-15: your own tariff card, as two coefficients the card itself grades
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.

New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.

It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).

THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.

And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).

The market's own arithmetic, reproduced through the shipped code:

  Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471   card prints 15,55
  ENGIE (0.1   x  61.96   + 0.9) x 1.06 = 7.5218  card prints 7,522

NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.

Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.

A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.

`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.

22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 03:49:08 +02:00

563 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 {
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>
"""