UX-13 P2: the Installer settings view

- Settings tab: the Installer view (app/static/settings.js), drawn from
  app/uilayout.py through the new app/uipage.py: search, six collapsible
  groups with "n to fix / n to check / n changed" badges and an "applies
  after a restart" chip, radio cards, segmented choices, sliders with units
  (the peak limit in kW), entity pickers with search and the live reading,
  inverter cards, solar rows, locked measured values behind "Change", and
  the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
  config/save; uimodel turns it into options before the shipped validator,
  so nothing it refuses is saved. No route added. A value equal to the
  stored one is written as stored, so a save that changed nothing writes an
  identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
  as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
  is 0 W; (b) the trade blocks and the price line name the feed's own points
  ("Lowest published price"), the hour bars say "hour average"; (c) the entity
  list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
Claude
2026-09-28 02:49:58 +02:00
parent a36dca60dd
commit c9ab48d6fc
18 changed files with 1871 additions and 95 deletions
+81
View File
@@ -135,3 +135,84 @@ dialog::backdrop{background:rgba(0,0,0,.45)}
.chart [data-tip]{cursor:default}
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* UX-13 P2: Settings, the Installer view (mockup version 2) */
.btn.link{border:0;background:none;color:var(--accent);padding:6px 0;min-height:0}
.search{width:100%;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:10px 12px;min-height:44px;margin-bottom:10px}
.key{font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint);overflow-wrap:anywhere}
.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;border-radius:999px;padding:1px 8px;background:var(--info-bg);color:var(--muted);white-space:nowrap}
.chip.now{background:var(--ok-bg);color:var(--ok)} .chip.rs{background:var(--warn-bg);color:var(--warn)}
.chip.bad{background:var(--bad-bg);color:var(--bad)} .chip.changed{background:var(--accent-soft);color:var(--accent)}
details.grp{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:10px;box-shadow:var(--shadow)}
details.grp>summary{list-style:none;cursor:pointer;padding:14px;display:flex;gap:10px;align-items:center;min-height:56px;flex-wrap:wrap}
details.grp>summary::-webkit-details-marker{display:none}
details.grp>summary .grow{flex:1 1 200px}
details.grp>summary .gdesc{display:block}
details.grp>summary .chev{transition:transform .15s;flex:none;font-size:22px;line-height:1;width:18px;text-align:center;color:var(--muted)}
details.grp[open]>summary .chev{transform:rotate(90deg)}
.gchips,.gbadge{display:inline-flex;flex-wrap:wrap;gap:4px}
details.grp .body{padding:0 14px 8px}
details.sub{border-top:1px solid var(--line);margin-top:4px}
details.sub>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
.subhead{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint);margin:14px 0 0;font-weight:700}
.f{padding:12px 0;border-top:1px solid var(--line);min-width:0}
.part>.f:first-child,.subhead+.f{border-top:0}
.f .lbl{font-weight:600;font-size:14px}
.f .help{font-size:13px;color:var(--muted);margin-top:4px}
.f .ctl{margin-top:8px;min-width:0}
.f.inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:start}
.f.inline .ctl{margin-top:0;grid-row:1;grid-column:2}
.f.inline .help,.f.inline .msgs{grid-column:1/-1}
.f.changed>.head .lbl::after{content:" •";color:var(--accent)}
.msg{font-size:13px;margin-top:6px;display:flex;gap:6px;align-items:flex-start;border-radius:8px;padding:6px 8px;overflow-wrap:anywhere}
.msg .mi{font-weight:800;flex:none}
.msg.bad{background:var(--bad-bg);color:var(--bad)} .msg.warn{background:var(--warn-bg);color:var(--warn)} .msg.info{background:var(--info-bg);color:var(--info)}
.f.has-bad .ctl select,.f.has-bad .ctl input,.f.has-bad .ent-btn{border-color:var(--bad)}
.switch{position:relative;width:52px;height:44px;border:0;background:none;cursor:pointer;flex:none;padding:0}
.switch::before{content:"";position:absolute;left:2px;top:8px;width:48px;height:28px;border-radius:14px;background:var(--track);transition:background .15s}
.switch::after{content:"";position:absolute;top:11px;left:5px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:left .15s}
.switch[aria-checked="true"]::before{background:var(--accent)}
.switch[aria-checked="true"]::after{left:25px}
.swrap{display:inline-flex;align-items:center;gap:8px;min-height:44px}
.swtext{font-size:13px;color:var(--muted)}
select.sel,input.txt,input.num{border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 10px;min-height:44px;max-width:100%}
select.sel{width:100%}
.row>select.sel{width:auto;flex:1 1 200px}
input.num{width:110px;text-align:right;font-variant-numeric:tabular-nums}
input.txt{width:100%}
input:disabled{opacity:.6}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-width:100%;flex-wrap:wrap}
.seg button{border:0;background:var(--card);padding:8px 12px;min-height:44px;cursor:pointer;border-left:1px solid var(--line)}
.seg button:first-child{border-left:0}
.seg button[aria-checked="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
.choice{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;cursor:pointer;width:100%;text-align:left;background:var(--card);min-height:44px}
.choice[aria-checked="true"]{border-color:var(--accent);background:var(--accent-soft)}
.choice .rad{flex:none;width:18px;height:18px;border-radius:50%;border:2px solid var(--faint);margin-top:2px}
.choice[aria-checked="true"] .rad{border:5px solid var(--accent)}
.slider{display:flex;align-items:center;gap:10px;width:100%}
.slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:28px}
.unit{color:var(--muted);font-size:13px;white-space:nowrap}
.ent-btn{width:100%;text-align:left;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:8px 10px;min-height:44px;cursor:pointer;display:flex;gap:8px;align-items:center}
.ent-btn .grow{min-width:0}
.ent-btn .grow>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ent-pop{border:1px solid var(--line);border-radius:10px;margin-top:6px;background:var(--card);overflow:hidden}
.ent-pop input[type=search]{border:0;border-bottom:1px solid var(--line);width:100%;padding:10px;background:var(--card);min-height:44px}
.ent-note{padding:6px 10px}
.ent-list{max-height:240px;overflow:auto}
.ent-opt{display:flex;gap:8px;align-items:center;width:100%;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;padding:8px 10px;cursor:pointer;min-height:44px}
.ent-opt:hover,.ent-opt:focus{background:var(--accent-soft)}
.ent-opt .grow{min-width:0}
.ent-opt .grow>div{overflow-wrap:anywhere}
.ent-opt .v{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);white-space:nowrap}
.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px}
.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)}
.elist>.btn{margin-top:8px}
.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0}
.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap}
.inv>.f:nth-child(2){border-top:0}
.invs>.btn{margin:4px 0 8px}
#savebar{position:fixed;left:0;right:0;bottom:0;z-index:15}
#savebar .in{max-width:820px;margin:0 auto 10px;padding:10px 12px;background:var(--ink);color:var(--bg);border-radius:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,.25);width:calc(100% - 32px)}
#savebar .btn{min-height:44px;padding:6px 12px;background:transparent;color:var(--bg);border-color:rgba(127,127,127,.6)}
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
@media (max-width:640px){#savebar{bottom:62px}}
#s-set{padding-bottom:80px}
+11 -10
View File
@@ -40,10 +40,12 @@
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
}
let onYes = null;
// `key` names Home's words in ui_text, or is {name, description, yes}.
function ask(key, fn) {
$("#dlg-t").textContent = t("dialog." + key + ".name");
$("#dlg-b").textContent = t("dialog." + key + ".description");
$("#dlg-yes").textContent = t("dialog." + key + ".yes");
const w = typeof key === "string" ? t("dialog." + key) : key;
$("#dlg-t").textContent = w.name;
$("#dlg-b").textContent = w.description;
$("#dlg-yes").textContent = w.yes;
onYes = fn;
const d = $("#dlg");
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
@@ -98,7 +100,6 @@
renderControl(structured);
renderDetails(!structured);
renderBatteries();
renderSettings();
}
/* ---------------- Home: now ---------------- */
@@ -162,7 +163,7 @@
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = b.estimate ? ", " + t("plan.estimate_tip") : "";
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
@@ -289,7 +290,7 @@
if (open) $("#c-details").open = true;
}
/* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */
/* ---------------- Batteries (placeholder until P3) ---------------- */
function renderBatteries() {
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
@@ -301,10 +302,6 @@
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
}
function renderSettings() {
const line = S.plan && S.plan.region_line;
$("#set-region").textContent = line || ""; $("#set-region").hidden = !line;
}
/* ---------------- control buttons ---------------- */
// The action is a toggle, so the page reads the live state first and sends
@@ -334,6 +331,9 @@
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
// Shared with settings.js, which loads after this file.
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) };
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
function select(tab, focus) {
@@ -345,6 +345,7 @@
if (focus) tab.focus();
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
schedule();
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
}
TABS.forEach((x, i) => {
x.addEventListener("click", () => select(x, false));
+19 -6
View File
@@ -96,16 +96,23 @@
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="config" data-t="batteries.settings"></a>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="card">
<h2 data-t="settings.name"></h2>
<p class="small muted" data-t="settings.description"></p>
<p class="small" id="set-region" hidden></p>
<a class="btn primary" href="config" data-t="settings.open"></a>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
<a class="small" href="config" id="set-classic" data-s="classic"></a>
</div>
<p class="small muted" style="margin:0 0 10px" data-s="description"></p>
<div id="set-banner" aria-live="polite"></div>
<div id="set-body">
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
<div id="groups"><p class="small muted" data-s="loading"></p></div>
<p class="small muted" id="set-nomatch" hidden data-s="no_match.description"></p>
<p class="tiny muted" data-s="footer.description"></p>
</div>
</section>
@@ -126,10 +133,16 @@
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="savebar" role="region" hidden><div class="in">
<span class="grow" id="sb-text" aria-live="polite"></span>
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
</div></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
<script>/*SETTINGS_JS*/</script>
</body>
</html>
+555
View File
@@ -0,0 +1,555 @@
"use strict";
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
from app/uimodel.py), so this file names no option key: keys, labels,
ranges and choices all arrive as data. It posts the page model back; the
add-on turns it into options and runs its own start checks, and nothing
they refuse is saved. Every word is from app/ui_text or the add-on, and
data is set as text, never as HTML. */
(function () {
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
const W = ROOT.settings;
const { h, ask, toast, maint } = window.UX13;
const $ = (s) => document.querySelector(s);
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
$("#savebar").setAttribute("aria-label", W.savebar.label);
let M = null; // the last /config/model
let V = null; // the page model being edited
let ORIG = null; // the page model as stored
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
let MSG = []; // [{field, card, tier, text}] from the add-on
let DEVICE = "";
let LIST = null; // the inverter list field
let FILLED = {}; // field key -> the preset field that fills it
let active = false;
let uid = 0;
const ROWS = [];
const OPEN = new Set();
const UNLOCKED = new Set();
/* ---------------- the page model ---------------- */
const sub = (f) => f.key.slice(LIST.key.length + 1);
function valueIn(model, f, card) {
if (card == null) return model[f.key];
const c = (model[LIST.key] || [])[card];
return c ? c[sub(f)] : undefined;
}
const get = (f, card) => valueIn(V, f, card);
function visible(f) {
return (f.show || []).every(([name, op, want]) => {
const have = V[name];
if (op === "==") return same(have, want);
if (op === "!=") return !same(have, want);
if (op === "in") return want.some((x) => same(x, have));
if (have == null || have === "" || isNaN(+have)) return false;
return op === ">" ? +have > want : +have < want;
});
}
function put(f, card, v, redraw) {
if (card == null) V[f.key] = v;
else V[LIST.key][card][sub(f)] = v;
if (f.mirror) {
const m = findField(f.mirror);
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
}
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
if (redraw || f.fills) render(); else paint();
queueValidate();
}
function findField(key) {
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.key === key) return f;
for (const s of f.fields || []) if (s.key === key) return s;
}
return null;
}
/* ---------------- numbers: stored units <-> shown units ---------------- */
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
function shown(f, v) {
if (v == null || v === "") return "";
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
}
function stored(f, x) {
const st = f.step || (f.type === "integer" ? 1 : 0);
let v = x * (f.scale || 1);
if (st) v = Math.round(v / st) * st;
if (f.min != null) v = Math.max(f.min, v);
if (f.max != null) v = Math.min(f.max, v);
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
}
/* ---------------- controls ---------------- */
function choicesOf(f, cur) {
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur));
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
return out;
}
function toggle(f, card, lid) {
const on = !!get(f, card);
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
b.addEventListener("click", () => {
const n = b.getAttribute("aria-checked") !== "true";
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
put(f, card, n);
});
return h("span", { class: "swrap" }, b, txt);
}
function radios(f, card, lid, cards) {
const cur = get(f, card);
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
const items = choicesOf(f, cur);
const buttons = items.map(([v, label], i) => {
const on = same(v, cur);
const hint = cards && f.hints ? f.hints[v] : "";
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
b.addEventListener("click", () => put(f, card, v, true));
b.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = (i + d + items.length) % items.length;
put(f, card, items[j][0], true);
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
if (again) again.focus();
});
return b;
});
g.dataset.rk = f.key + "|" + card;
g.append(...buttons);
return g;
}
function select(f, card, lid) {
const cur = get(f, card);
const s = h("select", { class: "sel", "aria-labelledby": lid });
for (const [v, label] of choicesOf(f, cur)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
const preset = f.presets && f.presets[cur];
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
}
function slider(f, card, lid) {
if (f.min == null || f.max == null) return number(f, card, lid);
const sc = f.scale || 1;
const st = f.step || (f.type === "integer" ? 1 : 0.01);
const v = get(f, card);
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
n.addEventListener("change", () => {
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
});
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
}
function number(f, card, lid) {
const sc = f.scale || 1;
const by = FILLED[f.key];
const preset = by && by.presets[V[by.key]];
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
n.addEventListener("input", () => {
if (n.value === "") { if (f.optional) put(f, card, null); return; }
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
});
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
}
function text(f, card, lid) {
const v = get(f, card);
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
t.addEventListener("input", () => put(f, card, t.value));
return t;
}
function locked(f, card, lid) {
const box = h("div", {});
const id = f.key + "#" + card;
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
if (UNLOCKED.has(id)) { open(); return box; }
const v = get(f, card);
box.append(h("div", { class: "row" },
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
h("span", { class: "chip" }, W.locked),
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
return box;
}
/* entity picker: search, the matching kind first, the live reading */
const ENT = {};
function entities(kind) {
const now = Date.now();
const e = ENT[kind];
if (e && now - e.at < 30000) return e.p;
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
.catch(() => ({ available: false, entities: [] }));
ENT[kind] = { at: now, p };
return p;
}
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
function picker(f, lid, value, pick) {
let data = null, all = false;
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
const pop = h("div", { class: "ent-pop", hidden: true });
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
const note = h("div", { class: "tiny muted ent-note" });
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
const allBox = h("label", { class: "tiny muted ent-all" },
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
function drawBtn() {
const id = value();
const e = data && data.entities.find((x) => x.entity_id === id);
btn.replaceChildren();
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
}
function opt(id, name, val) {
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
}
function fill() {
const typed = q.value.trim();
const t = typed.toLowerCase();
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
note.textContent = data && data.text ? data.text : "";
note.hidden = !note.textContent;
}
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
btn.addEventListener("click", () => {
if (!pop.hidden) { close(); return; }
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
setTimeout(() => q.focus(), 0);
});
q.addEventListener("input", fill);
pop.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const opts = [...list.querySelectorAll(".ent-opt")];
const i = opts.indexOf(document.activeElement);
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
});
pop.append(q, note, list, allBox);
drawBtn();
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
return h("div", { class: "ent" }, btn, pop,
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
}
function entity(f, card, lid) {
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
}
function entityList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((_id, i) => {
box.append(h("div", { class: "erow" },
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
return box;
}
function solarList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((row, i) => {
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
box.append(h("div", { class: "erow" },
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
return box;
}
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList };
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
}
function fieldRow(f, card, gid) {
if (f.control === "inverter_list") return cardList(f, gid);
const lid = "sf" + (++uid);
const keys = keyText(f, card);
const msg = h("div", { class: "msgs" });
const make = CONTROLS[f.control] || text;
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""),
keys ? h("div", { class: "key" }, keys) : null),
h("div", { class: "ctl" }, make(f, card, lid)),
f.help ? h("div", { class: "help" }, f.help) : null, msg);
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
return row;
}
function blankCard(f, first) {
const out = {};
for (const s of f.fields) {
const k = sub(s);
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
}
return out;
}
function cardList(f, gid) {
const wrap = h("div", { class: "invs", "data-key": f.key });
const msg = h("div", { class: "msgs" });
const cards = V[f.key];
if (!Array.isArray(cards)) {
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
} else {
cards.forEach((c, i) => {
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"),
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
V[f.key].splice(i, 1); V[f.count] = V[f.key].length; put(f, null, V[f.key], true);
} }, W.remove) : null));
for (const s of f.fields) box.append(fieldRow(s, i, gid));
wrap.append(box);
});
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
put(f, null, V[f.key], true);
} }, "+ " + W.add_inverter));
}
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
return wrap;
}
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
function render() {
const host = $("#groups");
const y = window.scrollY;
host.replaceChildren();
ROWS.length = 0;
for (const g of M.layout.groups) {
const d = h("details", { class: "grp", "data-g": g.id },
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
d.open = OPEN.has(g.id);
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
const body = h("div", { class: "body" });
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
for (const p of g.parts) {
const pid = g.id + "." + p.id;
let box;
if (p.collapsed) {
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
box.open = OPEN.has(pid);
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
} else {
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
}
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
body.append(box);
}
if (g.id === "maintenance") {
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
h("div", { class: "row" },
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
}
d.append(body);
host.append(d);
}
paint();
window.scrollTo(0, y);
}
/* ---------------- messages, visibility, search, badges ---------------- */
const ICON = { bad: "×", warn: "!", info: "i" };
const RANK = { bad: 0, warn: 1, info: 2 };
function mine(r, m) {
if (m.field !== r.f.key) return false;
if (r.list || r.card == null) return true;
return m.card == null ? r.card === 0 : m.card === r.card;
}
function paint() {
const q = $("#set-search").value.trim().toLowerCase();
const placed = new Set();
for (const r of ROWS) {
r.visible = visible(r.f);
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible) placed.add(i); return hit; })
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
r.tiers = new Set(msgs.map((m) => m.tier));
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
// A card list counts as one change when cards were added or removed;
// an edit inside a card counts on its own row.
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
r.el.classList.toggle("changed", r.changed && !r.list);
}
// Search: a row shows when it is visible and matches. A card list shows
// while any of its rows does.
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
let any = false;
document.querySelectorAll("#groups details.grp").forEach((d) => {
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
d.querySelectorAll(".part").forEach((p) => {
const inside = rows.filter((r) => p.contains(r.el));
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
});
const hit = rows.some((r) => !r.el.hidden);
any = any || hit;
d.hidden = !!q && !hit;
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
const shownRows = rows.filter((r) => r.visible && !r.list);
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
const changed = rows.filter((r) => r.changed).length;
d.querySelector(".gbadge").replaceChildren(...[
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
});
$("#set-nomatch").hidden = !q || any;
// A sentence shown beside any field is not repeated here.
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
if (DEVICE) other.push({ tier: "info", text: DEVICE });
$("#set-other").hidden = !other.length;
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
savebar();
}
function dirtyCount() {
const seen = new Set();
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
return seen.size;
}
function savebar() {
const n = V ? dirtyCount() : 0;
$("#savebar").hidden = !(active && n);
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
}
$("#set-search").addEventListener("input", () => { if (V) paint(); });
/* ---------------- talking to the add-on ---------------- */
let vtimer = null, vseq = 0;
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
async function validate() {
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: V }) });
const b = await r.json();
if (seq !== vseq) return;
MSG = b.messages || [];
DEVICE = b.device_note || "";
paint();
} catch (e) { /* the next change tries again */ }
}
function banner(level, title, sub, button, onClick) {
const host = $("#set-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
function restart() {
ask(W.dialog, async () => {
try {
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
} catch (e) { /* the add-on stops answering while it restarts */ }
toast(W.toast.restarting.description);
});
}
async function load(after) {
let m;
try {
const r = await fetch("config/model?lang=en", { cache: "no-store" });
if (!r.ok) throw new Error("model " + r.status);
m = await r.json();
if (!m.layout || !m.ui) throw new Error("no layout");
} catch (e) {
$("#groups").replaceChildren();
$("#set-search").hidden = true;
banner("bad", W.load_failed.name, W.load_failed.description);
return;
}
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
$("#set-search").hidden = false;
FILLED = {};
LIST = null;
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.fills) FILLED[f.fills] = f;
if (f.control === "inverter_list") LIST = f;
}
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
else banner();
render();
validate();
}
function firstBad() {
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
r.el.scrollIntoView({ block: "center" });
const input = r.el.querySelector("input,select,button");
if (input) input.focus({ preventScroll: true });
}
async function save() {
const btn = $("#sb-save");
btn.disabled = true; btn.textContent = W.savebar.saving;
try {
clearTimeout(vtimer);
await validate();
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: V, fingerprint: FP }) });
const b = await r.json().catch(() => ({}));
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
if (r.status === 409 && b.kind === "stale") {
FP = b.fingerprint || FP;
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : ""));
return;
}
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
toast(W.toast.failed);
} catch (e) { toast(W.toast.failed); }
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
}
$("#sb-save").addEventListener("click", save);
$("#sb-discard").addEventListener("click", () => {
V = clone(ORIG); UNLOCKED.clear(); render(); validate(); toast(W.toast.discarded);
if ($("#set-banner .b-bad")) banner();
});
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
document.addEventListener("ux13:tab", (e) => {
active = e.detail === "settings";
if (active && !M) load(); else savebar();
});
// app.js chose the first tab before this file ran.
if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); }
})();