"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(); } })();