UX-13 P3: Simple view and Batteries screen
checks / unit (pull_request) Successful in 11m9s
infra-pin / fresh (pull_request) Successful in 2s

- 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:
Claude
2026-09-28 22:39:43 +02:00
parent ed739d4cdb
commit 2d2b6afb07
26 changed files with 1119 additions and 89 deletions
+151 -16
View File
@@ -20,6 +20,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
from .tariff import BILLING_TZ
@@ -284,6 +285,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
if _off(c, inv):
continue # OPT-11: an Off battery does not trade
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
@@ -317,8 +320,8 @@ def maintenance_runs(c, day0, day1, now, shadow):
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
cfg = m.cfg
if not cfg.enabled:
continue
if not cfg.enabled or _off(c, inv):
continue # OPT-11: no monthly cycle while it is off
cap = inv.capacity_kwh
if m.phase != M_IDLE:
hours = _cycle_hours(m.phase, inv.soc, cap, cfg)
@@ -390,12 +393,13 @@ def peak_run(c, day0, day1, now, shadow, limit_w):
for i in range(len(c.inverters))]
def house_runs(n, day0, day1, now, shadow):
""""Follows the house" for the rest of the day, drawn under the others."""
def house_runs(n, day0, day1, now, shadow, skip=()):
""""Follows the house" for the rest of the day, drawn under the others.
`skip`: the batteries that are switched off (OPT-11)."""
return [_run(i, "house", max(now, day0), day1, day0, day1,
run_house_what(shadow), run_house_why(shadow),
run_house_amount())
for i in range(n)]
for i in range(n) if i not in skip]
def region_of(opts):
@@ -429,7 +433,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh)
if running:
runs += house_runs(len(c.inverters), day0, day1, now, shadow)
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
runs += house_runs(len(c.inverters), day0, day1, now, shadow, off)
if verdict is not None:
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
now, shadow)
@@ -507,16 +512,56 @@ def at_zero(inv) -> bool:
return inv.last_write is not None and abs(inv.last_write) < 1.0
def _off(c, inv) -> bool:
"""OPT-11: is this battery switched off? Only with battery modes on."""
return bool(getattr(c, "modes_on", False)) and not c.pack_mode(inv).use
def refusing(c, inv) -> int:
"""SAFETY-52/73: the direction this battery refuses (+1 give, -1 charge,
0 none). Per inverter at two or more, the site's own at one."""
if len(c.inverters) > 1:
return int(getattr(inv, "refused", 0) or 0)
return int(getattr(c, "batt_refused", 0) or 0)
def peak_engaged(c) -> bool:
"""The peak guard is asking the batteries for power this cycle."""
pg = getattr(c, "peak_guard", None)
return bool(pg is not None and pg.engaged)
def battery_rows(c, shadow, running, held=False):
shares = {}
alloc = getattr(c, "allocation", None)
if shadow and alloc is not None and getattr(alloc, "shares", None):
shares = dict(alloc.shares)
modes_on = bool(getattr(c, "modes_on", False))
pms = {inv.name: c.pack_mode(inv) for inv in c.inverters} \
if modes_on else {}
peak = peak_engaged(c)
rows = []
for i, inv in enumerate(c.inverters):
m = c.maints[i] if i < len(c.maints) else None
phase = m.phase if m is not None else M_IDLE
cap = inv.capacity_kwh
pm = pms.get(inv.name)
off = pm is not None and not pm.use
# UX-13 P3: does a Solar first battery that is on come before this one?
sf_ahead = any(o.use and o.mode == SOLAR_FIRST
for n, o in pms.items() if n != inv.name)
# ...and does another battery refuse to give power, so this one
# carries its share (SAFETY-73)?
other = next((o.name for o in c.inverters if o is not inv
and refusing(c, o) > 0 and not _off(c, o)), None)
why = battery_why(
modes_on=modes_on, mode=None if pm is None else pm.mode, off=off,
reading=inv.soc is not None and inv.batt is not None,
phase=phase, shadow=shadow, running=running, held=held,
refused=refusing(c, inv), peak=peak,
moving=flow(inv.batt) in ("giving", "charging"),
giving=flow(inv.batt) == "giving", other_refuses=other,
sf_ahead=sf_ahead)
rows.append({
"name": inv.name,
"level_pct": None if inv.soc is None else float(inv.soc),
@@ -530,21 +575,79 @@ def battery_rows(c, shadow, running, held=False):
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
running=running, held=held,
would_w=shares.get(inv.name),
settled=at_zero(inv)),
# Per-battery reasons arrive with the plan (OPT-11 P3/P4).
"why": None,
settled=at_zero(inv), off=off, peak=peak),
# UX-13 P3: why it does what "now" says, from what the add-on
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=other),
})
if getattr(c, "modes_on", False):
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
# screen. Only with battery modes on, so the page is unchanged
# without them. UX-13 P3 builds the real screen on these keys.
pm = c.pack_mode(inv)
# without them.
rows[-1].update({"use": pm.use, "mode_key": pm.key,
"mode_choice": pm.mode,
"mode": mode_word(pm.key)})
return rows
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
held, refused, peak, moving, sf_ahead, giving=False,
other_refuses=None) -> str:
"""The key of one battery's "why" line (`home_words.why`), most urgent
first. Pure, so every branch is tested without a controller."""
if not reading:
return "no_reading"
if held:
return "held"
if not running:
return "stopped"
if shadow:
return "shadow"
if phase != M_IDLE:
return "cycle_mode" if modes_on else "cycle"
if off:
# Off gives way to the peak limit only (OPT-11 design section 2).
return "off_peak" if peak and moving else "off"
if refused:
return "refuses_give" if refused > 0 else "refuses_charge"
if peak:
return "peak"
if giving and other_refuses:
return "other_refuses"
if not modes_on:
return "modes_off"
if giving:
return "covers_house" # both modes cover the house until P3
if mode == LEFTOVER_SOLAR:
return "leftover" if sf_ahead else "leftover_alone"
return "solar_first"
def attach_today(rows, runs, *, modes_on, running=True, held=False,
shadow=False) -> None:
"""UX-13 P3: each battery's runs today from the plan (the same blocks as
Home's chart), or one line saying there are none. With battery modes on
and no run, the mode's own plan is OPT-11 P3/P4, so it says so."""
for i, row in enumerate(rows):
mine = [r for r in runs if r["battery"] == i and r["kind"] != "house"]
row["today"] = [{"from": r["from"], "to": r["to"], "kind": r["kind"],
"what": r["what"]} for r in mine]
if mine:
row["today_line"] = ""
elif held:
row["today_line"] = today_words("held")
elif not running:
row["today_line"] = today_words("stopped")
elif row.get("use") is False:
row["today_line"] = today_words("off")
elif modes_on:
row["today_line"] = today_words("not_planned")
else:
row["today_line"] = today_words("house_would" if shadow
else "house")
# Row id -> the DOCS.md heading under Notifications, where one exists.
ATTN_DOCS = {
"meter_frozen": messages.T_METER_FROZEN,
@@ -640,6 +743,11 @@ def _build(c, *, level, checks, now):
running = (bool(c.auto) or shadow) and not held
settled = all(at_zero(inv) for inv in c.inverters)
ctl = next((r for r in checks if r.get("id") == "controller"), None)
rows = battery_rows(c, shadow, running, held)
plan = build_plan(c, now, shadow, running, held=held, settled=settled)
attach_today(rows, plan["runs"],
modes_on=bool(getattr(c, "modes_on", False)),
running=running, held=held, shadow=shadow)
return {
"state": {"kind": kind, "pill": pill(kind),
"ran": ctl.get("text", "") if ctl else "",
@@ -647,9 +755,9 @@ def _build(c, *, level, checks, now):
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
has_solar=bool(c.pv_roster), shadow=shadow,
settled=settled),
"batteries": battery_rows(c, shadow, running, held),
"plan": build_plan(c, now, shadow, running, held=held,
settled=settled),
"batteries": rows,
"plan": plan,
"modes_on": bool(getattr(c, "modes_on", False)),
"attention": ([{"severity": INFO, "id": "shadow",
"title": attn_shadow(), "body": attn_shadow_body(),
"docs": None}] if shadow else []) + attention(checks),
@@ -811,7 +919,7 @@ def tile_batt(batt_w, shadow, settled=True) -> str:
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
would_w=None, settled=True) -> str:
would_w=None, settled=True, off=False, peak=False) -> str:
if soc is None or batt_w is None:
return _t("now.no_reading")
zero = _zero(settled)
@@ -828,6 +936,14 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now." + {M_DRAIN: "cycle_drain", M_CHARGE: "cycle_charge",
M_HOLD: "cycle_hold"}.get(phase, "cycle"))
st = flow(batt_w)
if off:
# OPT-11: an Off battery is asked for 0 W, but the peak limit may
# still need it. Say which, and never "0 W" beside a power figure.
if st == "resting":
return _t("now.off_resting")
if st == "giving" and peak:
return _t("now.off_peak", kw=_kw(batt_w))
return _t("now.off_going", kw=_kw(batt_w))
if st == "giving":
return _t("now.giving", kw=_kw(batt_w))
if st == "charging":
@@ -835,6 +951,17 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now.resting")
def why_words(key, name=None) -> str:
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
`name`: the battery that refuses, for `other_refuses`."""
return _t("why." + key, name=name or "")
def today_words(key) -> str:
"""One battery's "today" line when the plan has no run for it."""
return _t("today." + key)
def plan_head(key, shadow, settled=True) -> str:
if key == "held":
return _t("head.held" if settled else "head.held_going")
@@ -1039,6 +1166,14 @@ def _samples() -> list:
("plan", line_region("flanders", 2026)),
("plan", line_region("own", None)),
("plan", line_peak_not_covered())]
for off in (False, True):
for peak in (False, True):
for w in (2500, -2500, 0, 500):
out.append(("plan", battery_now(62, w, phase=M_IDLE,
shadow=False, running=True,
off=off, peak=peak)))
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
out += [("plan", today_words(k)) for k in words["today"]]
out += [("plan", line_no_trade(k)) for k in
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
+16
View File
@@ -228,3 +228,19 @@ input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums}
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
.kettle .row{margin-top:8px}
/* UX-13 P3: the Batteries screen and the Simple settings view */
.bcard .hd{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.bcard .name{font-weight:700;font-size:16px}
.bcard .modebox .lbl{display:block;margin-bottom:4px}
.bcard .modebox .help{font-size:13px;color:var(--muted);margin-top:4px}
.bcard.off .modebox{opacity:.6}
.bcard .kv dd{overflow-wrap:anywhere}
.switch:disabled{opacity:.45;cursor:not-allowed}
select.sel:disabled{opacity:.6}
.simple .card>.row{margin-bottom:4px}
.vhide{display:none!important}
.simple .card>.f{border-top:0;padding-top:0}
.simple .f>.head{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.simple .card .lbl{font-weight:600;font-size:15px}
.simple .inv .hd{border-bottom:0}
.simple .inv .f{border-top:0;padding-top:0}
+103 -35
View File
@@ -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")];
+23 -4
View File
@@ -94,11 +94,23 @@
</section>
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" id="batt-desc" data-t="batteries.description"></p>
<div id="batt-banner"></div>
<div class="card" id="batt-off" hidden>
<h2 data-t="batteries.modes_off.name"></h2>
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
<div id="bcards"></div>
<details class="grp" id="batt-how" hidden>
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">&#8250;</span></summary>
<div class="body small">
<p data-t="batteries.how.solar_first.description"></p>
<p data-t="batteries.how.leftover_solar.description"></p>
<p data-t="batteries.how.off.description"></p>
<p class="muted" data-t="batteries.how.rules.description"></p>
<p><a id="batt-docs" hidden target="_top" rel="noopener" data-t="control.docs"></a></p>
</div>
</details>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
@@ -106,8 +118,15 @@
<h2 style="margin:0" data-s="name"></h2>
<a class="small" href="config" id="set-classic" data-s="classic"></a>
</div>
<p class="small muted" style="margin:0 0 10px" data-s="description"></p>
<div class="row" style="margin-bottom:8px">
<div class="seg" role="radiogroup" id="set-view" data-s-label="view.name">
<button type="button" role="radio" aria-checked="true" data-v="simple" data-s="view.simple"></button>
<button type="button" role="radio" aria-checked="false" data-v="installer" tabindex="-1" data-s="view.installer"></button>
</div>
<span class="tiny muted grow" id="set-desc"></span>
</div>
<div id="set-banner" aria-live="polite"></div>
<div id="simple" class="simple" hidden></div>
<div id="set-body">
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
+81 -4
View File
@@ -32,6 +32,10 @@
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
// P3: "simple" (the default, for home owners) or "installer". Remembered in
// this browser only; a blocked storage just means Simple each time.
let mode = "simple";
try { if (localStorage.getItem("ux13.settings.view") === "installer") mode = "installer"; } catch (e) { /* no storage */ }
let uid = 0;
const ROWS = [];
const OPEN = new Set();
@@ -337,7 +341,7 @@
function fieldRow(f, card, gid) {
if (f.control === "inverter_list") return cardList(f, gid);
const lid = "sf" + (++uid);
const keys = keyText(f, card);
const keys = gid === "simple" ? "" : keyText(f, card); // Simple shows no keys
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 },
@@ -421,10 +425,79 @@
d.append(body);
host.append(d);
}
renderSimple();
paint();
window.scrollTo(0, y);
}
/* ---------------- Settings, Simple (P3) ---------------- */
// The fields the layout marks `simple`, each in its own card with when it
// applies, and the batteries' switches and modes (runtime state, applied at
// once) drawn by app.js from /status. The Installer rows stay built but
// hidden, so the save bar counts every unsaved change in either view.
const chip = (applies) => h("span", { class: "chip " + (applies === "now" ? "now" : "rs") }, ROOT.applies[applies].name);
// A note with {kw} is filled from /status (app.js sends ux13:status).
function fillNotes() {
const st = window.UX13.status();
const mp = st && st.month_peak;
document.querySelectorAll("#simple [data-kw-note]").forEach((e) => {
const ok = mp && mp.peak_w != null;
e.hidden = !ok;
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
});
}
document.addEventListener("ux13:status", fillNotes);
function simpleField(f) {
const row = fieldRow(f, null, "simple");
row.querySelector(".head").append(chip(f.applies));
const n = f.note && W.simple.note[f.note];
if (n && n.description.includes("{kw}")) {
row.append(h("div", { class: "small muted", "data-kw-note": n.description, hidden: true }));
} else if (n) {
// A plain note replaces the option's installer help on this view.
const help = row.querySelector(".help");
if (help) help.textContent = n.description;
}
return h("div", { class: "card" }, row);
}
function renderSimple() {
const host = $("#simple");
host.replaceChildren();
if (mode !== "simple") return;
const cards = allFields().filter((f) => f.simple).map(simpleField);
const batts = h("div", {});
cards.splice(Math.min(1, cards.length), 0, h("div", { class: "card" },
h("div", { class: "row spread" }, h("div", { class: "lbl" }, W.simple.batteries.name), chip("now")), batts));
host.append(...cards, h("p", { class: "small muted", style: "margin:4px 2px" }, W.simple.more.description + " ",
h("button", { type: "button", class: "btn link", onclick: () => setMode("installer", true) }, W.simple.installer)));
window.UX13.batteries(batts);
fillNotes();
}
function setMode(v, redraw) {
mode = v;
try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ }
document.querySelectorAll("#set-view [role=radio]").forEach((b) => {
const on = b.dataset.v === v;
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
});
$("#set-desc").textContent = v === "simple" ? W.simple.description : W.description;
$("#simple").hidden = v !== "simple";
["#set-search", "#groups", "#set-nomatch"].forEach((id) => { $(id).classList.toggle("vhide", v === "simple"); });
if (redraw && M && view === "settings") render();
}
document.querySelectorAll("#set-view [role=radio]").forEach((b, i, all) => {
b.addEventListener("click", () => setMode(b.dataset.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 = all[(i + d + all.length) % all.length];
setMode(j.dataset.v, true); j.focus();
});
});
setMode(mode, false);
window.UX13.settingsView = (v) => setMode(v, true);
/* ---------------- messages, visibility, search, badges ---------------- */
const ICON = { bad: "×", warn: "!", info: "i" };
const RANK = { bad: 0, warn: 1, info: 2 };
@@ -434,11 +507,13 @@
return m.card == null ? r.card === 0 : m.card === r.card;
}
function paint() {
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
const q = view === "setup" || mode === "simple" ? "" : $("#set-search").value.trim().toLowerCase();
// A message counts as shown only beside a row of the view on screen.
const onScreen = (r) => view === "setup" || (mode === "simple") === (r.gid === "simple");
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; })
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible && onScreen(r)) 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 },
@@ -562,7 +637,9 @@
validate();
}
function firstBad() {
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
if (view === "settings" && mode === "simple" && !ROWS.some((x) => x.gid === "simple" && x.tiers && x.tiers.has("bad") && x.visible)
&& ROWS.some((x) => x.tiers && x.tiers.has("bad") && x.visible)) setMode("installer", true);
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible && (view !== "settings" || (mode === "simple") === (x.gid === "simple")));
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" });
+51 -7
View File
@@ -84,13 +84,22 @@
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
"setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"with_modes": {"description": "Each battery follows its mode, after the peak limit and safety stops."},
"use": "Use this battery", "on": "On", "off": "Off",
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Charges from solar the other batteries cannot take, and covers the house."},
"off": {"name": "Off", "description": "The add-on asks this battery for 0 W, unless the peak limit needs it."}
}},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"later": "Cheap hours only, coming later",
"today": "Today", "why": "Why",
"sell": {"name": "Sell in expensive hours", "description": "Selling comes in a later update."},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and also covers the house."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops always come first, and charging from the grid and selling come later."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
@@ -107,7 +116,13 @@
},
"settings": {
"name": "Settings",
"description": "Every setting, grouped by task, and the simple view follows in a later update.",
"description": "Every setting, grouped by task.",
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
"simple": {"description": "The choices a home owner makes.",
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}},
"batteries": {"name": "Batteries"},
"more": {"description": "Looking for the meter, inverters or prices?"},
"installer": "Open the installer view"},
"classic": "Classic settings",
"search": {"name": "Find a setting", "description": "Find a setting, for example peak or meter."},
"no_match": {"description": "No setting matches your search."},
@@ -223,7 +238,6 @@
"again": "Check again",
"blocked": {"description": "Fix the items marked in red, then check again."}
},
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
@@ -290,7 +304,37 @@
"cycle": "Monthly cycle under way.",
"giving": "Giving {kw} now.",
"charging": "Charging {kw} now.",
"resting": "Resting now."
"resting": "Resting now.",
"off_resting": "Off. Resting now.",
"off_peak": "Off, but gives {kw} for the peak limit.",
"off_going": "Off. Still at {kw}, going to 0 W."
},
"why": {
"no_reading": "No reading, so the add-on cannot tell.",
"held": "An alarm stopped control. See Needs attention.",
"stopped": "Control is stopped.",
"shadow": "Shadow mode: the add-on only shows what it would do.",
"cycle": "The monthly cycle keeps its level reading right.",
"cycle_mode": "The monthly cycle comes first. Its mode resumes after.",
"off": "You switched it off.",
"off_peak": "The peak limit comes before Off. It rests again after.",
"refuses_give": "The battery refuses to give power. The add-on tries again every 15 minutes.",
"refuses_charge": "The battery refuses to charge. The add-on tries again every 15 minutes.",
"peak": "The peak limit needs power, so every battery gives.",
"modes_off": "Battery modes are off, so it follows the house.",
"solar_first": "Solar first: it gets spare solar before the others.",
"leftover": "It gets the solar that Solar first batteries cannot take.",
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
"covers_house": "It gives power to cover the house.",
"other_refuses": "{name} refuses to give power, so this battery takes over."
},
"today": {
"not_planned": "Not planned yet.",
"off": "Off, so no monthly cycle today.",
"house": "Follows the house.",
"house_would": "Would follow the house.",
"stopped": "Nothing planned while control is stopped.",
"held": "Nothing planned until the alarm clears."
},
"head": {
"held": "Every inverter is at 0 W until the alarm clears.",
+51 -7
View File
@@ -81,13 +81,22 @@
"offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."},
"setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"},
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
"with_modes": {"description": "Elke batterij volgt haar modus, na de piekgrens en de veiligheidsstops."},
"use": "Deze batterij gebruiken", "on": "Aan", "off": "Uit",
"on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Laadt met zonne-energie die de andere batterijen niet opnemen, en dekt het huis."},
"off": {"name": "Uit", "description": "De add-on vraagt deze batterij 0 W, behalve als de piekgrens haar nodig heeft."}
}},
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"later": "Alleen goedkope uren, volgt later",
"today": "Vandaag", "why": "Waarom",
"sell": {"name": "Verkopen in dure uren", "description": "Verkopen komt in een latere update."},
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en dekt ook het huis."},
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan altijd voor, en laden van het net en verkopen volgen later."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
"settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"}
},
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
@@ -102,7 +111,13 @@
},
"settings": {
"name": "Instellingen",
"description": "Elke instelling, per taak, en de eenvoudige weergave volgt in een latere update.",
"description": "Elke instelling, per taak.",
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
"simple": {"description": "De keuzes die een eigenaar maakt.",
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}},
"batteries": {"name": "Batterijen"},
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
"installer": "De installateursweergave openen"},
"classic": "Klassieke instellingen",
"search": {"name": "Instelling zoeken", "description": "Zoek een instelling, bijvoorbeeld piek of meter."},
"no_match": {"description": "Geen instelling past bij uw zoekopdracht."},
@@ -197,7 +212,6 @@
"needed": {"description": "Doe eerst de waterkokertest, of sla hem over."}
},
"check": {"name": "Wat de add-on nakeek", "running": {"description": "Bezig met de startcontroles van de add-on."}, "pass": {"description": "De startcontroles van de add-on zijn in orde."}, "failed": {"description": "De controle liep niet, dus probeer opnieuw."}, "again": "Opnieuw nakijken", "blocked": {"description": "Los de rood gemarkeerde punten op en kijk dan opnieuw na."}},
"shadow": {"name": "Starten in schaduwmodus", "description": "Aanbevolen, want dan blijft elke omvormer op 0 W tot u de schaduwmodus uitzet onder Instellingen."},
"dialog": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, en de schaduwmodus houdt ze daarna op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_live": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Bewaren en herstarten"},
"dialog_setup": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en de schaduwmodus houdt elke omvormer op 0 W.", "yes": "Bewaren en herstarten"},
@@ -232,7 +246,37 @@
"cycle": "Maandelijkse cyclus bezig.",
"giving": "Levert nu {kw}.",
"charging": "Laadt nu {kw}.",
"resting": "Rust nu."
"resting": "Rust nu.",
"off_resting": "Uit. Rust nu.",
"off_peak": "Uit, maar levert {kw} voor de piekgrens.",
"off_going": "Uit. Nog {kw}, gaat naar 0 W."
},
"why": {
"no_reading": "Geen meting, dus de add-on weet het niet.",
"held": "Een alarm stopte de regeling. Zie Vraagt aandacht.",
"stopped": "De regeling is gestopt.",
"shadow": "Schaduwmodus: de add-on toont alleen wat hij zou doen.",
"cycle": "De maandelijkse cyclus houdt de meting van het laadniveau juist.",
"cycle_mode": "De maandelijkse cyclus gaat voor. Daarna volgt ze weer haar modus.",
"off": "U hebt haar uitgezet.",
"off_peak": "De piekgrens gaat voor Uit. Daarna rust ze weer.",
"refuses_give": "De batterij weigert te leveren. De add-on probeert het elke 15 minuten opnieuw.",
"refuses_charge": "De batterij weigert te laden. De add-on probeert het elke 15 minuten opnieuw.",
"peak": "De piekgrens vraagt vermogen, dus elke batterij levert.",
"modes_off": "Batterijmodi staan uit, dus ze volgt het huis.",
"solar_first": "Eerst zonne-energie: ze krijgt het overschot vóór de andere.",
"leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.",
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
"covers_house": "Ze levert stroom om het huis te dekken.",
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over."
},
"today": {
"not_planned": "Nog niet gepland.",
"off": "Uit, dus vandaag geen maandelijkse cyclus.",
"house": "Volgt het huis.",
"house_would": "Zou het huis volgen.",
"stopped": "Niets gepland zolang de regeling gestopt is.",
"held": "Niets gepland tot het alarm weg is."
},
"head": {
"held": "Elke omvormer staat op 0 W tot het alarm weg is.",
+4 -2
View File
@@ -46,6 +46,7 @@ class Field:
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
mirror: str = "" # a choice also copied to this field, if offered
fills: str = "" # a preset choice fills this field (uimodel)
note: str = "" # P3: a line under it on Settings, Simple
@property
def text(self) -> str:
@@ -180,12 +181,13 @@ GROUPS = (
Group("safety", (
Part("peak", (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5),
simple=True, wizard=5, note="month_peak"),
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
Field("shadow_mode", SEGMENTED, simple=True, wizard=6),
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
note="shadow"),
Field("auto_start", TOGGLE),
Field("estop_fitted", TOGGLE),
Field("stale_input_s", SLIDER, "s"),
+1 -1
View File
@@ -99,7 +99,7 @@ def _field(fld, schema_ui, translations, lang, T):
"help": text, "unit": fld.unit, "scale": fld.scale,
"step": fld.step, "kind": fld.kind,
"show": [list(c) for c in fld.show], "applies": fld.applies,
"wizard": fld.wizard,
"wizard": fld.wizard, "simple": fld.simple, "note": fld.note,
"type": value_type, "optional": not node.get("required", False),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()