diff --git a/docs/img/ux13/README.md b/docs/img/ux13/README.md index ad3fbd8..9a48082 100644 --- a/docs/img/ux13/README.md +++ b/docs/img/ux13/README.md @@ -4,6 +4,6 @@ Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not edit them by hand: run the script again. - `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup. -- `settings-*.png`: Settings (Installer view), and the time-window editor with an overlap. +- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap. +- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off. - `setup-step-1*.png`: Setup, step 1. -- Battery modes (the Batteries screen and the Simple view): not built yet (UX-13 P3), so no screenshot. diff --git a/docs/img/ux13/batteries-modes-off.png b/docs/img/ux13/batteries-modes-off.png new file mode 100644 index 0000000..ca3ecdf Binary files /dev/null and b/docs/img/ux13/batteries-modes-off.png differ diff --git a/docs/img/ux13/batteries-nl.png b/docs/img/ux13/batteries-nl.png new file mode 100644 index 0000000..96e3a7e Binary files /dev/null and b/docs/img/ux13/batteries-nl.png differ diff --git a/docs/img/ux13/batteries-off-peak.png b/docs/img/ux13/batteries-off-peak.png new file mode 100644 index 0000000..19b0082 Binary files /dev/null and b/docs/img/ux13/batteries-off-peak.png differ diff --git a/docs/img/ux13/batteries-phone.png b/docs/img/ux13/batteries-phone.png new file mode 100644 index 0000000..b22face Binary files /dev/null and b/docs/img/ux13/batteries-phone.png differ diff --git a/docs/img/ux13/batteries.png b/docs/img/ux13/batteries.png new file mode 100644 index 0000000..6e729e4 Binary files /dev/null and b/docs/img/ux13/batteries.png differ diff --git a/docs/img/ux13/settings-installer-nl.png b/docs/img/ux13/settings-installer-nl.png index 74dd7c9..b8bf978 100644 Binary files a/docs/img/ux13/settings-installer-nl.png and b/docs/img/ux13/settings-installer-nl.png differ diff --git a/docs/img/ux13/settings-installer.png b/docs/img/ux13/settings-installer.png index 57258b8..43de13b 100644 Binary files a/docs/img/ux13/settings-installer.png and b/docs/img/ux13/settings-installer.png differ diff --git a/docs/img/ux13/settings-simple-nl.png b/docs/img/ux13/settings-simple-nl.png new file mode 100644 index 0000000..94e986d Binary files /dev/null and b/docs/img/ux13/settings-simple-nl.png differ diff --git a/docs/img/ux13/settings-simple.png b/docs/img/ux13/settings-simple.png new file mode 100644 index 0000000..3be66f4 Binary files /dev/null and b/docs/img/ux13/settings-simple.png differ diff --git a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md index e5d16b3..34cb1ac 100644 --- a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md +++ b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md @@ -87,6 +87,14 @@ What UX-02 made authoritative stays authoritative: Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133 wizard blobs, 17 of them with time windows (`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28). +- P3: Settings, Simple draws the fields the table marks `simple`, with an + optional `note` under each (words in `settings.simple.note`), plus the + batteries' switches and modes. Those are store state set through the + existing `/action` values, not options, so the table places no key for + them. The Batteries screen's Now, Today and Why lines are built in + `homeview.py` from what the add-on knows this cycle; the plan per mode is + OPT-11 P3/P4, so until then Today says "Not planned yet" + (`test_batteries_screen.py`). ## 4. What this costs diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 4f36859..34318e9 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -515,7 +515,7 @@ below. ### Battery modes (`battery_modes_enabled`) -Off by default. Turn it on, restart the add-on, and the Batteries screen gives each battery two controls. They apply at once, with no restart. +Off by default. Turn it on, restart the add-on, and the Batteries screen (and Settings, Simple) gives each battery two controls. They apply at once, with no restart. - **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover. - **Mode.** *Solar first* charges from spare solar before the other batteries. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update. @@ -3245,12 +3245,28 @@ The page is in Dutch when your browser's language is Dutch, and in English otherwise. Status lines, alarms and the start checks' messages stay in English. Screenshots of each screen are in the add-on's repository, under -`docs/img/ux13/`. The battery-mode screens are not built yet, so there are no -screenshots of them. +`docs/img/ux13/`, including the Batteries screen and Settings, Simple. *For installers: regenerate the screenshots with `tools/ux13_screenshots.sh`, which serves the page from test fixtures.* +### The Batteries screen and Settings, Simple + +The **Batteries** tab has a card per battery: its level, and three lines. +**Now** says what it does, **Today** lists the blocks planned for it (price +trades, the monthly cycle, the peak limit), and **Why** says the reason. With +battery modes on, each card also has the On/Off switch and the mode list; both +apply at once. Charging in cheap hours and selling are greyed out until a later +update, and Today reads "Not planned yet" when nothing is planned for the +battery. + +An Off battery that the peak limit still needs says so, with the power it +gives: "Off, but gives 0.5 kW for the peak limit." + +**Settings** opens on the **Simple** view: the peak limit, the batteries' +switches and modes, and shadow mode, each marked "Applies right away" or +"Applies after a restart". **Installer** shows every setting. + ### Setup, for a new install The **Setup** tab walks an installer through six steps: the meter, the diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 01afa62..90d1943 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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")] diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index 6e4d52f..db67144 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -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} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 5606652..2728bb5 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -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")]; diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index 7518ac8..a915a09 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -94,11 +94,23 @@