UX-13 P3: Simple view and Batteries screen
- Batteries: one card per battery with On/Off, the mode list (cheap hours and selling greyed out until OPT-11 P3), level, and Now / Today / Why lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses the existing pack_use / pack_mode actions and store keys. - An Off battery the peak limit still needs says so with its figure; no line shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned yet" until OPT-11 P3/P4. - Home's plan leaves Off batteries out, except for the peak limit. - Settings opens on Simple: the layout's simple fields with an applies chip and a note, plus the battery switches and modes. Installer is one tap. - English and Dutch words in app/ui_text; test_batteries_screen.py; browser check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and changed screenshots only. No control, allocator, arbiter or command module changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
This commit is contained in:
@@ -78,10 +78,12 @@
|
||||
if (!r.ok) throw new Error("action " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function refresh() {
|
||||
// `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(); }
|
||||
try { S = await getStatus(); render(force === true); }
|
||||
catch (e) { offline(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
@@ -104,7 +106,7 @@
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
}
|
||||
|
||||
function render() {
|
||||
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.
|
||||
@@ -114,7 +116,8 @@
|
||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
renderControl(structured, setup);
|
||||
renderDetails(!structured && !setup);
|
||||
renderBatteries();
|
||||
renderBatteries(force);
|
||||
document.dispatchEvent(new CustomEvent("ux13:status"));
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
@@ -308,50 +311,112 @@
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries (placeholder until UX-13 P3) ---------------- */
|
||||
// OPT-11 P1: with battery modes on, each card gets an On/Off switch and a
|
||||
// mode choice (`mode_key` in /status). UX-13 P3 replaces these cards.
|
||||
/* ---------------- 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 and cheap-hours charging
|
||||
// are shown greyed out until OPT-11 P3. 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"];
|
||||
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
|
||||
let packBusy = false;
|
||||
async function packAct(what, unit, value) {
|
||||
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: unit, value: value }) });
|
||||
body: JSON.stringify({ action: what, unit: b.name, value: value }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
toast(r.ok ? t("toast.done") : (j.error || t("toast.failed")));
|
||||
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; }
|
||||
refresh();
|
||||
await refresh(true);
|
||||
}
|
||||
function modeControls(b) {
|
||||
const sw = h("button", { class: "btn" + (b.use ? " primary" : ""), type: "button", role: "switch",
|
||||
"aria-checked": String(b.use), "aria-label": b.name + ": " + t("batteries.use"),
|
||||
onclick: () => packAct("pack_use", b.name, !b.use) },
|
||||
b.use ? t("batteries.on") : t("batteries.off"));
|
||||
const sel = h("select", { class: "sel", "aria-label": b.name + ": " + t("batteries.mode"), disabled: !b.use,
|
||||
onchange: (e) => packAct("pack_mode", b.name, e.target.value) },
|
||||
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
|
||||
return [h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.use")), sw),
|
||||
h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.mode")), sel),
|
||||
h("p", { class: "tiny muted", style: "margin:6px 0 0" }, t("batteries.choice." + b.mode_key + ".description"))];
|
||||
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 renderBatteries() {
|
||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
// Do not rebuild a card under an open mode list or a request in flight.
|
||||
if (packBusy || ($("#bcards").contains(document.activeElement) && document.activeElement.tagName === "SELECT")) return;
|
||||
const modes = S.batteries.some((b) => b.mode_key != null);
|
||||
$("#batt-desc").textContent = t(modes ? "batteries.with_modes.description" : "batteries.description");
|
||||
$("#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" : "")),
|
||||
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"))),
|
||||
h("option", { value: "", disabled: true }, t("batteries.later")));
|
||||
}
|
||||
function sellRow(i, where) {
|
||||
const lid = "bsl-" + where + i;
|
||||
return h("div", { class: "f inline" },
|
||||
h("div", {}, h("div", { class: "lbl", id: lid }, t("batteries.sell.name")), h("div", { class: "help" }, t("batteries.sell.description"))),
|
||||
h("div", { class: "ctl" }, h("button", { type: "button", class: "switch", role: "switch", "aria-checked": "false",
|
||||
"aria-labelledby": lid, disabled: true })));
|
||||
}
|
||||
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")),
|
||||
sellRow(i, "c")) : null,
|
||||
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", {}, dec(b.stored_kwh.toFixed(1)) + " kWh")] : null,
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
b.mode_key == null ? [h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)] : null),
|
||||
b.mode_key != null ? modeControls(b) : null)));
|
||||
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"].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));
|
||||
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 ---------------- */
|
||||
@@ -385,7 +450,10 @@
|
||||
// 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 };
|
||||
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")];
|
||||
|
||||
Reference in New Issue
Block a user