1. Discard reloads /config/model: after a stale save the page kept the
new fingerprint with the old values, so Discard + Save overwrote the
other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
"Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
562 lines
30 KiB
JavaScript
562 lines
30 KiB
JavaScript
"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 ---------------- */
|
||
// A choice the page can only keep stays offered while it is the stored value.
|
||
function choicesOf(f, card) {
|
||
const cur = get(f, card), was = valueIn(ORIG, f, card);
|
||
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
|
||
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, card);
|
||
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, card)) 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;
|
||
// Back to one card, the stored shape returns: a flat inverter stays flat.
|
||
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
|
||
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 = "";
|
||
UNLOCKED.clear();
|
||
$("#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);
|
||
// Discard reloads what is stored: after a stale save ORIG is no longer it.
|
||
$("#sb-discard").addEventListener("click", async () => {
|
||
await load(); toast(W.toast.discarded);
|
||
});
|
||
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(); }
|
||
})();
|