Files
goodwe-addon/goodwe_controller/app/static/app.js
T
adminandClaude Opus 5.5 be57b87c34
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s
UX-13 P4: the setup wizard for installers
The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 05:31:18 +02:00

378 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 () {
const T = JSON.parse(document.getElementById("ui-text").textContent).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 kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
const eur = (v) => "€" + 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", { 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();
}
async function refresh() {
if (busy) return;
busy = true;
try { S = await getStatus(); render(); }
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" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
}
function render() {
renderPill();
const structured = S.now && S.plan && !S.home_error;
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", 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);
renderDetails(!structured);
renderBatteries();
}
/* ---------------- 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) {
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" : "");
$("#ctl-text").textContent = structured ? S.control.text : "";
$("#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 (placeholder until P3) ---------------- */
function renderBatteries() {
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
}
/* ---------------- 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, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
/* ---------------- 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();
})();