The Supervisor proxy answers 502 to every add-on read, write and websocket
until Core is RUNNING (proxy.py api()/websocket() -> check_api_state()), so
the add-on cannot control while a slow integration holds HA's start, even
with its entities live. Measured on a safety112 repro stack with the
2026.08.0 proxy code: first write 1.0-4.4 s after RUNNING, 238-300 s after
live readings. Not routed around (DECISIONS/SAFETY-112-...).
While the API does not answer, the first status row is red and is the
banner ("Waiting for Home Assistant to finish starting"), Home shows a
"waiting" state in EN/NL instead of "Needs you now", and the log adds a
"still waiting" line every 5 minutes and the wait's length at the end.
A safety stop, a stalled loop or shadow mode keeps its own headline.
Control, writes, heartbeat and the stand-down are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
26 KiB
JavaScript
491 lines
26 KiB
JavaScript
"use strict";
|
||
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
|
||
external files. Every URL is relative, because ingress serves the add-on
|
||
under a generated path prefix. Every word comes from app/ui_text (fixed
|
||
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
|
||
is checked in Python. Data is always set as text, never as HTML. */
|
||
(function () {
|
||
// P5: English or Dutch, by the browser's language. A word missing in Dutch
|
||
// falls back to English, key by key (app/uitext.py does the same).
|
||
const ALL = JSON.parse(document.getElementById("ui-text").textContent);
|
||
const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en";
|
||
const merge = (a, b) => {
|
||
if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a;
|
||
const out = Object.assign({}, a);
|
||
for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k];
|
||
return out;
|
||
};
|
||
const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]);
|
||
document.documentElement.lang = LANG;
|
||
const T = WORDS.home;
|
||
const $ = (s) => document.querySelector(s);
|
||
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
|
||
function h(tag, attrs, ...kids) {
|
||
const e = document.createElement(tag);
|
||
for (const k in attrs || {}) {
|
||
const v = attrs[k];
|
||
if (v === false || v == null) continue;
|
||
if (k === "class") e.className = v;
|
||
else if (k === "style") e.style.cssText = v;
|
||
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
||
else e.setAttribute(k, v === true ? "" : v);
|
||
}
|
||
for (const c of kids.flat()) {
|
||
if (c == null || c === false) continue;
|
||
e.append(c.nodeType ? c : document.createTextNode(String(c)));
|
||
}
|
||
return e;
|
||
}
|
||
const dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x);
|
||
const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1));
|
||
const eur = (v) => "€" + dec(Number(v).toFixed(2));
|
||
|
||
// Fixed labels from ui_text.
|
||
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
|
||
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
|
||
|
||
let S = null; // the last /status
|
||
let busy = false;
|
||
|
||
/* ---------------- toast and confirm dialog ---------------- */
|
||
function toast(text) {
|
||
const e = $("#toast"); e.textContent = text; e.classList.add("show");
|
||
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
|
||
}
|
||
let onYes = null;
|
||
// `key` names Home's words in ui_text, or is {name, description, yes}.
|
||
function ask(key, fn) {
|
||
const w = typeof key === "string" ? t("dialog." + key) : key;
|
||
$("#dlg-t").textContent = w.name;
|
||
$("#dlg-b").textContent = w.description;
|
||
$("#dlg-yes").textContent = w.yes;
|
||
onYes = fn;
|
||
const d = $("#dlg");
|
||
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
|
||
}
|
||
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
|
||
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
|
||
|
||
/* ---------------- data ---------------- */
|
||
async function getStatus() {
|
||
const r = await fetch("status?lang=" + LANG, { cache: "no-store" });
|
||
if (!r.ok) throw new Error("status " + r.status);
|
||
return r.json();
|
||
}
|
||
async function act(what) {
|
||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: what }) });
|
||
if (!r.ok) throw new Error("action " + r.status);
|
||
return r.json();
|
||
}
|
||
// `force`: redraw the battery cards even under a focused mode list, after
|
||
// the person used it (P3).
|
||
async function refresh(force) {
|
||
if (busy) return;
|
||
busy = true;
|
||
try { S = await getStatus(); render(force === true); }
|
||
catch (e) { offline(); }
|
||
finally { busy = false; }
|
||
}
|
||
|
||
/* ---------------- shell ---------------- */
|
||
function banner(el, level, title, sub, role) {
|
||
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
|
||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||
h("span", {}, title, sub ? h("small", {}, sub) : null)));
|
||
}
|
||
function offline() {
|
||
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
|
||
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
|
||
}
|
||
function renderPill() {
|
||
const st = S.state || null;
|
||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
|
||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
|
||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||
}
|
||
|
||
function render(force) {
|
||
renderPill();
|
||
const structured = S.now && S.plan && !S.home_error;
|
||
// UX-15: setup-only mode commands nothing and has no control to offer.
|
||
const setup = S.setup_only === true;
|
||
banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
|
||
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||
renderControl(structured, setup);
|
||
renderDetails(!structured && !setup);
|
||
renderBatteries(force);
|
||
document.dispatchEvent(new CustomEvent("ux13:status"));
|
||
}
|
||
|
||
/* ---------------- Home: now ---------------- */
|
||
function val(id, w, show) {
|
||
const e = $(id);
|
||
if (w == null || !show) { e.textContent = "–"; return; }
|
||
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
|
||
}
|
||
function levelBar(pct, label) {
|
||
const known = pct != null;
|
||
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
|
||
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
|
||
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
|
||
}
|
||
function renderNow() {
|
||
const n = S.now;
|
||
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
|
||
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
|
||
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
|
||
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
|
||
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
|
||
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
|
||
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||
levelBar(b.level_pct, b.name),
|
||
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
|
||
}
|
||
|
||
/* ---------------- Home: today's plan ---------------- */
|
||
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
|
||
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
|
||
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
|
||
["peak", "background:var(--bad)"], ["house", null]];
|
||
function tipText(lines) { return lines.filter(Boolean).join(" "); }
|
||
function chartItem(tag, cls, style, title, lines) {
|
||
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
|
||
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
|
||
}
|
||
function roving(group) {
|
||
const items = [...group.querySelectorAll("[data-tip]")];
|
||
items.forEach((el, i) => {
|
||
el.tabIndex = i === 0 ? 0 : -1;
|
||
el.addEventListener("keydown", (e) => {
|
||
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
|
||
if (d === undefined) return;
|
||
e.preventDefault();
|
||
const j = Math.max(0, Math.min(items.length - 1, i + d));
|
||
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
|
||
});
|
||
});
|
||
}
|
||
function renderPlan() {
|
||
const p = S.plan;
|
||
$("#verdict").textContent = p.verdict;
|
||
$("#plan-head").textContent = p.head;
|
||
const hours = p.hours || 24;
|
||
const bars = $("#bars");
|
||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||
bars.setAttribute("aria-label", t("plan.name"));
|
||
bars.replaceChildren(...p.prices.map((b) => {
|
||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||
}));
|
||
roving(bars);
|
||
$("#no-prices").hidden = known.length > 0;
|
||
const lanes = $("#lanes");
|
||
lanes.replaceChildren();
|
||
S.batteries.forEach((b, i) => {
|
||
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
|
||
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
|
||
for (const r of runs) {
|
||
if (r.end_h <= r.start_h) continue;
|
||
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
|
||
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
|
||
}
|
||
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
|
||
roving(lane);
|
||
});
|
||
const nl = $("#nowline");
|
||
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
|
||
const used = new Set(p.runs.map((r) => r.kind));
|
||
known.forEach((b) => used.add(b.kind));
|
||
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
|
||
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
|
||
const list = [];
|
||
S.batteries.forEach((b, i) => {
|
||
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
|
||
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
|
||
}
|
||
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
|
||
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
|
||
});
|
||
for (const line of p.lines) list.push(h("li", {}, line));
|
||
$("#plan-list").replaceChildren(...list);
|
||
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
|
||
}
|
||
|
||
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
|
||
(function () {
|
||
const tip = $("#tip"); let hot = null;
|
||
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
|
||
function show(el, x, y) {
|
||
if (hot && hot !== el) hot.classList.remove("hot");
|
||
hot = el; el.classList.add("hot");
|
||
const lines = JSON.parse(el.dataset.tip);
|
||
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
|
||
tip.classList.add("show");
|
||
const r = tip.getBoundingClientRect();
|
||
let L = x + 12, T2 = y - r.height - 12;
|
||
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
|
||
if (L < 8) L = 8;
|
||
if (T2 < 8) T2 = y + 16;
|
||
tip.style.left = L + "px"; tip.style.top = T2 + "px";
|
||
}
|
||
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
|
||
document.addEventListener("pointermove", (e) => {
|
||
if (e.pointerType === "touch") return;
|
||
const el = target(e);
|
||
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
|
||
});
|
||
document.addEventListener("pointerdown", (e) => {
|
||
const el = target(e);
|
||
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
|
||
else if (!el) hide();
|
||
});
|
||
document.addEventListener("focusin", (e) => {
|
||
const el = target(e);
|
||
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
|
||
});
|
||
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
||
addEventListener("scroll", hide, { passive: true });
|
||
})();
|
||
|
||
/* ---------------- Home: peak, attention, control, details ---------------- */
|
||
function renderPeak() {
|
||
const m = S.month_peak;
|
||
$("#peak-month").textContent = m.month;
|
||
const covered = m.state !== "not_covered";
|
||
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
|
||
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
|
||
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
|
||
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
|
||
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
|
||
const bar = $("#peak-meter i");
|
||
bar.style.width = pct + "%";
|
||
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
|
||
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
|
||
$("#peak-line").textContent = m.line;
|
||
}
|
||
function renderAttention() {
|
||
const list = S.attention;
|
||
const icon = { alarm: "×", warning: "!", info: "i" };
|
||
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
|
||
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
|
||
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
|
||
h("div", { class: "small" }, a.body),
|
||
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
|
||
const n = list.filter((a) => a.severity !== "info").length;
|
||
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
|
||
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
|
||
const badge = $("#attn-badge");
|
||
badge.textContent = n; badge.hidden = !n;
|
||
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
||
}
|
||
function renderControl(structured, setup) {
|
||
const running = structured ? S.control.running : null;
|
||
const btn = $("#btn-ctl");
|
||
btn.textContent = running === false ? t("control.start") : t("control.stop");
|
||
btn.className = "btn" + (running === false ? " primary" : "");
|
||
btn.hidden = !!setup;
|
||
$("#btn-setup").hidden = !setup;
|
||
$("#c-ctl .more").hidden = !!setup;
|
||
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
|
||
$("#hint").textContent = S.hint || "";
|
||
const docs = $("#docs-link");
|
||
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||
}
|
||
function renderDetails(open) {
|
||
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
|
||
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
|
||
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
|
||
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
|
||
return h("li", { class: cls }, mark + " " + c.text);
|
||
}));
|
||
if (open) $("#c-details").open = true;
|
||
}
|
||
|
||
/* ---------------- Batteries (UX-13 P3) ---------------- */
|
||
// One card per battery. With battery modes on (`mode_key` in /status) each
|
||
// card has an On/Off switch and a mode list, sent as `pack_use` and
|
||
// `pack_mode` through /action (OPT-11 P1). Selling has no switch while it
|
||
// is off (owner decision 2026-09-29). The Now, Today and Why lines are
|
||
// homeview.py's sentences. The Simple settings view draws the same switch
|
||
// and list into its own card (simpleHost).
|
||
const MODES = ["solar_first", "leftover_solar", "cheap_hours"];
|
||
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
|
||
let packBusy = false;
|
||
let simpleHost = null;
|
||
async function packAct(what, b, value) {
|
||
if (packBusy) return;
|
||
packBusy = true;
|
||
try {
|
||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: what, unit: b.name, value: value }) });
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) toast(j.error || t("toast.failed"));
|
||
else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name }));
|
||
else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
|
||
} catch (e) { toast(t("toast.failed")); }
|
||
finally { packBusy = false; }
|
||
await refresh(true);
|
||
}
|
||
function onSwitch(b, i, where, lid) {
|
||
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
|
||
"aria-checked": String(b.use), "aria-labelledby": lid,
|
||
onclick: () => packAct("pack_use", b, !b.use) });
|
||
return h("span", { class: "swrap" }, sw, h("span", { class: "swtext", "aria-hidden": "true" }, b.use ? t("batteries.on") : t("batteries.off")));
|
||
}
|
||
function modeSelect(b, i, where, lid) {
|
||
return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use,
|
||
onchange: (e) => packAct("pack_mode", b, e.target.value) },
|
||
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
|
||
}
|
||
function todayLines(b) {
|
||
if (!b.today || !b.today.length) return b.today_line || "–";
|
||
return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what));
|
||
}
|
||
function batteryCard(b, i) {
|
||
const modes = b.mode_key != null;
|
||
const lid = "bname-" + i;
|
||
const lvl = b.level_pct == null ? "–" : Math.round(b.level_pct) + " %";
|
||
const stored = b.stored_kwh != null ? " · " + dec(b.stored_kwh.toFixed(1)) + " kWh " + t("batteries.stored").toLowerCase() : "";
|
||
return h("div", { class: "card bcard" + (modes && !b.use ? " off" : "") },
|
||
h("div", { class: "hd" },
|
||
h("div", { class: "grow" }, h("div", { class: "name", id: lid }, b.name),
|
||
h("div", { class: "tiny muted" }, b.capacity_kwh ? dec(Number(b.capacity_kwh).toFixed(1)) + " kWh" : "")),
|
||
modes ? onSwitch(b, i, "c", lid) : null),
|
||
modes ? h("div", { class: "modebox" },
|
||
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
|
||
modeSelect(b, i, "c", null),
|
||
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null,
|
||
levelBar(b.level_pct, b.name),
|
||
h("dl", { class: "kv" },
|
||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
|
||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
|
||
}
|
||
function simpleRow(b, i) {
|
||
const lid = "bsname-" + i;
|
||
return h("div", { class: "inv" },
|
||
h("div", { class: "hd" }, h("span", { class: "grow", id: lid }, b.name), onSwitch(b, i, "s", lid)),
|
||
h("div", { class: "f" }, h("label", { class: "lbl", for: "bsel-s" + i }, t("batteries.mode")),
|
||
h("div", { class: "ctl" }, modeSelect(b, i, "s", null)),
|
||
h("div", { class: "help" }, b.now + " " + (b.why || ""))));
|
||
}
|
||
// Rebuild without losing the focused control, and never under an open list
|
||
// or a request in flight.
|
||
function keepFocus(fn) {
|
||
const id = document.activeElement && document.activeElement.id;
|
||
fn();
|
||
if (id && /^bs(w|el)-/.test(id)) { const e = document.getElementById(id); if (e) e.focus(); }
|
||
}
|
||
// The modes-off card's button opens Settings on the Installer view, where
|
||
// the "Battery modes" switch is (settings.js sets window.UX13.settingsView).
|
||
$("#batt-off a").addEventListener("click", () => { if (window.UX13.settingsView) window.UX13.settingsView("installer"); });
|
||
function renderBatteries(force) {
|
||
if (!S || !S.batteries) { $("#bcards").replaceChildren(); return; }
|
||
const a = document.activeElement;
|
||
if (!force && (packBusy || (a && a.tagName === "SELECT" && /^bsel-/.test(a.id)))) return;
|
||
const modes = S.modes_on === true || S.batteries.some((b) => b.mode_key != null);
|
||
$("#batt-off").hidden = modes;
|
||
$("#batt-how").hidden = !modes;
|
||
const docs = $("#batt-docs");
|
||
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||
if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||
else $("#batt-banner").replaceChildren();
|
||
keepFocus(() => {
|
||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||
// OPT-11 P4: the solar forecast line under the cards.
|
||
const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : "";
|
||
if (sl) { sl.textContent = line || ""; sl.hidden = !line; }
|
||
if (simpleHost && simpleHost.isConnected) {
|
||
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
|
||
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---------------- control buttons ---------------- */
|
||
// The action is a toggle, so the page reads the live state first and sends
|
||
// nothing when control is already where the person asked it to be.
|
||
// One request at a time: a second confirm while the first is in flight
|
||
// would read the same state and toggle it back.
|
||
let toggling = false;
|
||
async function setControl(want) {
|
||
if (toggling) return;
|
||
toggling = true;
|
||
try {
|
||
const now = await getStatus();
|
||
if (!now.control || now.control.running !== want) await act("auto_toggle");
|
||
toast(want ? t("toast.started") : t("toast.stopped"));
|
||
} catch (e) { toast(t("toast.failed")); }
|
||
finally { toggling = false; }
|
||
refresh();
|
||
}
|
||
$("#btn-ctl").addEventListener("click", () => {
|
||
const running = S && S.control ? S.control.running : true;
|
||
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
|
||
});
|
||
async function simple(what) {
|
||
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
|
||
refresh();
|
||
}
|
||
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
|
||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||
|
||
// Shared with settings.js, which loads after this file.
|
||
// `first`: the page opened with no route, so settings.js may open the setup
|
||
// wizard on a new install (UX-13 P4).
|
||
window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash,
|
||
// P3: the Simple settings view's battery card, drawn from /status.
|
||
batteries: (host) => { simpleHost = host; if (S) renderBatteries(); refresh(); },
|
||
status: () => S, kw };
|
||
|
||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||
const TABS = [...document.querySelectorAll(".tab")];
|
||
function select(tab, focus) {
|
||
TABS.forEach((x) => {
|
||
const on = x === tab;
|
||
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
|
||
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
|
||
});
|
||
if (focus) tab.focus();
|
||
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
|
||
schedule();
|
||
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
|
||
}
|
||
TABS.forEach((x, i) => {
|
||
x.addEventListener("click", () => select(x, false));
|
||
x.addEventListener("keydown", (e) => {
|
||
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
|
||
if (j === undefined) return;
|
||
e.preventDefault(); select(TABS[j % TABS.length], true);
|
||
});
|
||
});
|
||
function fromHash() {
|
||
const want = (location.hash || "#home").slice(1);
|
||
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
|
||
}
|
||
addEventListener("hashchange", fromHash);
|
||
|
||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||
let timer = null;
|
||
function schedule() {
|
||
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
|
||
$("#t-batt").getAttribute("aria-selected") === "true");
|
||
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
|
||
if (!live && timer) { clearInterval(timer); timer = null; }
|
||
}
|
||
document.addEventListener("visibilitychange", schedule);
|
||
fromHash();
|
||
refresh();
|
||
})();
|