"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. P4: the Setup tab's wizard is the same page model, drawn one step at a time from each field's `wizard` step, and saved through the same routes. */ (function () { const ROOT = window.UX13.words; // English or Dutch (app.js) const W = ROOT.settings; const { h, ask, toast, maint, lang } = 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 view = null; // "settings" or "setup": the tab drawing V let STEPS = {}; // wizard step -> refusals that block it 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) redrawView(); 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; } // P5: time windows, each a start, an end, its days and a price. The add-on // builds the tariff from the rows and names the first overlap or gap. function periodList(f, card) { const P = W.periods; const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []); const box = h("div", { class: "elist plist" }); if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description); arr().forEach((row, i) => { const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); }; const say = (what) => P.window + " " + (i + 1) + ", " + what; const time = (k) => { const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00", value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) }); e.addEventListener("input", () => set(k, e.value.trim())); return e; }; const days = h("select", { class: "sel", "aria-label": say(P.days) }); for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label)); days.addEventListener("change", () => set("days", days.value)); const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01", value: row.price == null ? "" : row.price, "aria-label": say(P.price) }); price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value)); const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids); box.append(h("div", { class: "erow prow" }, h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)), h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)), h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")), cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))), cell(P.days, days))); }); box.append(h("button", { type: "button", class: "btn", onclick: () => { const a = arr(), last = a[a.length - 1]; put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true); } }, "+ " + P.add)); 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, period_list: periodList }; /* ---------------- rows, cards and groups ---------------- */ function keyText(f, card) { if (view === "setup") return ""; // the wizard shows no keys 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 && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + 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)), view === "setup" ? null : 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 = view === "setup" ? "" : $("#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 = !(view === "settings" && 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); } // `extra` asks for more, e.g. the wizard's live checks. Returns the answer, // or null when it failed or a newer check overtook it. async function validate(extra) { const seq = ++vseq; try { const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) }); const b = await r.json(); if (seq !== vseq) return null; MSG = b.messages || []; DEVICE = b.device_note || ""; STEPS = b.steps || {}; paint(); return b; } catch (e) { return null; /* 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=" + lang, { 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(); if (view === "setup") drawStep(); else 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 }); } // The one save path, for the Installer view and the wizard. After a stale // refusal the page holds the new token, so a second press saves. `base` is // the model the page opened: only what changed since then is written, onto // what is stored now (UX-15 F2). async function postSave(extra) { const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) }); const b = await r.json().catch(() => ({})); if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP; return { r, b }; } // The settings another surface changed (not this page's own edits). function staleText(b) { const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; }); return W.stale.description + (names.length ? " " + names.join(", ") + "." : ""); } 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, b } = await postSave(); if (r.ok) { toast(W.toast.saved); await load("saved"); return; } if (r.status === 409 && b.kind === "stale") { banner("bad", W.stale.name, staleText(b)); 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 = ""; } }); /* ---------------- the setup wizard (P4) ---------------- */ const WS = W.setup; const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null }; function redrawView() { if (view === "setup") drawStep(); else render(); } function allFields() { const out = []; for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f); return out; } const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text); function watts(w, signed) { const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " "); return (signed && w < 0 ? "-" : "") + n + " W"; } function line(tier, text) { const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i"; return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon), h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text)); } // Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is // uipage.kettle() (test_setup.py grades that one) on the page. function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; } function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); } function kettleOutcome(src, before, after) { const d = after - before; if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d }; const o = src[d > 0 ? "up" : "down"]; return { say: o.say, sets: o.sets, d }; } function kettleOk() { const src = kettleSource(); return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok); } async function readMeter(src) { let total = 0; for (const [key, sign] of src.read) { if (!V[key]) return null; const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" }) .then((x) => (x.ok ? x.json() : null)).catch(() => null); if (!r || r.watts == null) return null; total += sign * r.watts; } return total; } function kettleBox() { const src = kettleSource(); const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name)); if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; } const sig = kettleSig(src); if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again const k = WZ.kettle || { sig }; box.append(h("div", { class: "help" }, WS.kettle.description)); // A skip after the reading went down still says it went down (UX-15). const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say; const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad"; const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null; if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" }, say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" }))); const busy = (b) => { b.disabled = true; }; const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read); read.addEventListener("click", async () => { busy(read); const sig = kettleSig(src); // the entity may have changed since drawing if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; } const w = await readMeter(src); if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" }); else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong }; else { const o = kettleOutcome(src, k.before, w); // `wrong`: the reading went the way no option can fix. A skip after // that keeps saying so on the last step (review R2). const wrong = o.say === "none" ? !!k.wrong : o.sets == null; WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong }; if (o.sets) { Object.assign(V, o.sets); queueValidate(); } } drawStep(); }); const row = h("div", { class: "row" }, read); if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => { WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep(); } }, WS.kettle.skip)); box.append(row); return box; } // Step 6: the shadow switch, the checks, and Save and restart. // The shadow switch goes only on the model the wizard posts, never into V, // which the Installer view saves too (review R3). function shadowValue() { const s = M.setup.shadow; return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off); } function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); } function shadowRow() { const lid = "wzs"; const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid }); b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); }); return h("div", { class: "f inline" }, h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)), h("div", { class: "ctl" }, b)); } async function runChecks() { const list = $("#wz-checks"); if (list) list.replaceChildren(line("info", WS.check.running.description)); clearTimeout(vtimer); const b = await validate({ setup: true, model: posted() }); WZ.checked = b; if (!list || !list.isConnected) return b; if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; } const items = [], seen = new Set(); const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } }; const bad = (b.messages || []).filter((m) => m.tier === "bad"); if (!bad.length) add("ok", WS.check.pass.description); for (const m of bad) add("bad", m.text); for (const m of b.setup || []) add(m.tier, m.text); if (b.device_note) add("info", b.device_note); const src = kettleSource(); if (!src) add("info", WS.kettle.self.description); else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description); else if (kettleOk()) add("ok", WS.kettle.tested.description); const rank = { bad: 0, warn: 1, ok: 2, info: 3 }; items.sort((a, z) => rank[a[0]] - rank[z[0]]); list.replaceChildren(...items.map(([t, x]) => line(t, x))); WZ.blocked = items.some(([t]) => t === "bad"); return b; } function finalBox() { const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again); setTimeout(runChecks, 0); return [h("div", { class: "subhead" }, WS.check.name), h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again), shadowRow()]; } function wzBanner(level, title, sub, button, onClick) { const host = $("#wz-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)))); } async function finish() { const b = await runChecks(); if (!b) { toast(WS.check.failed.description); return; } if (WZ.blocked) { toast(WS.check.blocked.description); return; } const shadow = shadowValue() === M.setup.shadow.on; // In setup-only mode nothing runs yet, so no inverter is set to 0 W first. const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live); ask(dlg, async () => { wzBanner(); let res; try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; } const { r, b: body } = res; if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; } if (r.status === 422) { MSG = body.messages || []; STEPS = body.steps || {}; const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0); if (first) WZ.step = first; drawStep(); toast(W.toast.blocked.description); firstBad(); return; } if (!r.ok) { toast(W.toast.failed); return; } let restarted = true; try { const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: "restart" }) }); restarted = rr.ok; } catch (e) { /* the add-on stops answering while it restarts */ } WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed"; drawStep(); if (!restarted) await load("saved"); // still running: read what is stored }); } function drawDone() { const w = WS[WZ.done]; $("#wz-count").textContent = ""; $("#wz-steps").replaceChildren(); $("#wz-desc").textContent = ""; $("#wz-nav").hidden = true; ROWS.length = 0; if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart); else wzBanner("ok", w.name, w.description); $("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home)); } function drawStep() { if (!M) return; if (WZ.done) { drawDone(); return; } const S = M.setup, n = WZ.step, words = WS.steps[n]; $("#wz-nav").hidden = false; $("#wz-title").textContent = words.name; $("#wz-count").textContent = say(WS.step, { n, total: S.steps }); $("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) => h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null }))); $("#wz-desc").textContent = words.description; ROWS.length = 0; const body = []; if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description)); for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz")); if (n === 1) body.push(kettleBox()); if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description)); if (n === S.final) body.push(...finalBox()); $("#wz-body").replaceChildren(...body); $("#wz-back").textContent = n === 1 ? WS.not_now : WS.back; $("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next; paint(); } $("#wz-back").addEventListener("click", () => { if (WZ.step === 1) { location.hash = "#home"; return; } WZ.step -= 1; drawStep(); window.scrollTo(0, 0); }); $("#wz-next").addEventListener("click", async () => { if (!M) return; const btn = $("#wz-next"); btn.disabled = true; try { const n = WZ.step; if (n === M.setup.final) { await finish(); return; } clearTimeout(vtimer); const b = await validate(); if (!b) { toast(W.toast.failed); return; } if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; } if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; } WZ.step = n + 1; drawStep(); window.scrollTo(0, 0); } finally { btn.disabled = false; } }); document.addEventListener("ux13:tab", (e) => { view = e.detail === "settings" || e.detail === "setup" ? e.detail : null; if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar(); }); // app.js chose the first tab before this file ran. const sel = document.querySelector(".tab[aria-selected=true]"); if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); } // First run: opened with no route, on a site the add-on says is new // (uipage.setup_needed), the page goes to Setup by itself. else if (window.UX13.first) { load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; }); } })();