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
+2 -2
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -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
+19 -3
View File
@@ -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
+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()
+367
View File
@@ -0,0 +1,367 @@
"""UX-13 P3: the Batteries screen and Settings, Simple. `python3 test_batteries_screen.py`
1. Each battery's Now, Today and Why lines, in English and Dutch, for the six
states the brief names: modes off, modes on, a battery Off, an Off battery
giving way to the peak limit, a refusal hold, and the monthly cycle.
2. The OPT-11 P1 test's F2 contradictions stay gone: a battery that moves
power never reads "0 W" beside it, the Off help names no watts, and no
battery line says the peak limit "holds back" a battery it asks for power.
3. The plan leaves an Off battery out, except for the peak limit.
4. `battery_why()` is pure and picks the most urgent reason first.
5. The screen's `/action` round trips over the real web app: the switch and
the mode list post `pack_use` and `pack_mode`, `/status` shows the result
in both languages, and a refused value comes back as a plain 400.
6. The page: the cards, the Simple view and its fields come from the layout
table and ui_text, and the scripts post only the existing actions.
7. The screen changes no control path: homeview.py reads the controller and
calls nothing that writes, and the page adds no /action value.
The headless-browser check is in the PR, not in CI (the CI image has no
browser). Mutating a module this file grades? Read MUTATION.md first.
"""
import ast
import asyncio
import io
import json
import os
import re
import socket
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import aiohttp # noqa: E402
from aiohttp import web as aioweb # noqa: E402
import ux13_demo as D # noqa: E402
from app import homeview, packmode, uilayout as L, uipage, web # noqa: E402
from app.maintenance import IDLE, DRAIN # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def words(lang):
with io.open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
encoding="utf-8") as f:
return json.load(f)
EN, NL = words("en"), words("nl")
LANGS = {"en": EN, "nl": NL}
def build(state, lang="en", c=None):
c = c or D.controller(state)
s = c.status_payload()
return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang)
# The six states and what each battery's Why line must be.
WANT = {
"running": ("modes_off", "modes_off"),
"modes": ("solar_first", "leftover"),
"off": ("solar_first", "off"),
"off_peak": ("peak", "off_peak"),
"refusal": ("refuses_give", "other_refuses"),
"maint": ("cycle_mode", "leftover"),
}
# ============================================================================ #
print("1. Now, Today and Why per battery, English and Dutch")
ROWS = {(st, lang): build(st, lang) for st in WANT for lang in LANGS}
for st, want in WANT.items():
got = tuple(b["why_key"] for b in ROWS[(st, "en")]["batteries"])
en = ROWS[(st, "en")]["batteries"]
nl = ROWS[(st, "nl")]["batteries"]
print(f" {st}: " + " / ".join(f"{b['now']} | {b['why']} | "
f"{b['today_line'] or b['today']}"
for b in en))
check(f"{st}: the Why lines are {want}, in each language's own words",
got == want
and all(b["why"] == homeview.uitext.say(
W["home_words"]["why"][b["why_key"]],
name=D.OPTS["inverters"][0]["name"])
for W, rows in ((EN, en), (NL, nl)) for b in rows))
check(f"{st}: every battery has a Now, a Why and a Today, and the Dutch "
f"lines differ from the English",
all(b["now"] and b["why"] and (b["today"] or b["today_line"])
for b in en + nl)
and all(e["why"] != n["why"] and e["now"] != n["now"]
for e, n in zip(en, nl)))
_modes = ROWS[("modes", "en")]
check("modes on: each row carries its switch and mode, and Today says the "
"mode's plan is not made yet (OPT-11 P3/P4), without inventing one",
_modes["modes_on"] is True
and [(b["use"], b["mode_key"]) for b in _modes["batteries"]]
== [(True, "solar_first"), (True, "leftover_solar")]
and all(b["today_line"] == EN["home_words"]["today"]["not_planned"]
for b in _modes["batteries"]))
_off = ROWS[("running", "en")]
check("modes off: no switch, no mode, and Today and Why say it follows the "
"house", _off["modes_on"] is False
and all("mode_key" not in b and "use" not in b
and b["today_line"] == EN["home_words"]["today"]["house"]
for b in _off["batteries"]))
_b = ROWS[("off", "en")]["batteries"][1]
check("a battery Off: 'Off. Resting now.', 'You switched it off.', and no "
"monthly cycle today", (_b["mode_key"], _b["now"], _b["why"],
_b["today_line"])
== ("off", EN["home_words"]["now"]["off_resting"],
EN["home_words"]["why"]["off"], EN["home_words"]["today"]["off"]))
_b = ROWS[("off_peak", "en")]["batteries"][1]
_bn = ROWS[("off_peak", "nl")]["batteries"][1]
check("Off giving way to the peak limit says so plainly, with the figure: "
"'Off, but gives 0.5 kW for the peak limit.'",
_b["now"] == "Off, but gives 0.5 kW for the peak limit."
and _bn["now"] == "Uit, maar levert 0,5 kW voor de piekgrens."
and _b["why"] == EN["home_words"]["why"]["off_peak"])
_a, _b = ROWS[("refusal", "en")]["batteries"]
check("a refusal hold: the refusing battery says it refuses and when it "
"tries again, the other says it takes over",
_a["why"] == EN["home_words"]["why"]["refuses_give"]
and _b["why"] == "Battery A refuses to give power, so this battery "
"takes over.")
_a = ROWS[("maint", "en")]["batteries"][0]
check("maintenance: the cycle comes first, the mode resumes after, and Today "
"shows the cycle block", _a["maintenance"] == DRAIN
and _a["why"] == EN["home_words"]["why"]["cycle_mode"]
and [r["kind"] for r in _a["today"]] == ["cycle"])
_sh = build("shadow")["batteries"]
_al = build("alarm")["batteries"]
check("shadow mode and an alarm: Why says which, and Today plans nothing "
"the add-on does not do",
all(b["why_key"] == "shadow" and b["today_line"]
== EN["home_words"]["today"]["house_would"] for b in _sh)
and all(b["why_key"] == "held" and b["today_line"]
== EN["home_words"]["today"]["held"] for b in _al))
# ============================================================================ #
print("\n2. the OPT-11 P1 test's F2: no line contradicts the power shown")
ZERO = re.compile(r"\b0 W\b")
GOING = (EN["home_words"]["zero_going"], NL["home_words"]["zero_going"],
"going to 0 W", "gaat naar 0 W")
_bad = []
for (st, lang), payload in sorted(ROWS.items()):
for b in payload["batteries"]:
moving = b["power_w"] is not None and abs(b["power_w"]) > homeview.IDLE_W
if moving and b.get("maintenance", IDLE) == IDLE:
kw = homeview._dec(f"{abs(b['power_w']) / 1000:.1f}")
kw = kw.replace(".", ",") if lang == "nl" else kw
if kw not in b["now"]:
_bad.append(f"{st}/{lang}/{b['name']}: no figure in {b['now']!r}")
for line in (b["now"], b["why"]):
if ZERO.search(line) and not any(g in line for g in GOING):
_bad.append(f"{st}/{lang}/{b['name']}: 0 W beside power: "
f"{line!r}")
if "held back" in b["why"] or "tegengehouden" in b["why"]:
_bad.append(f"{st}/{lang}/{b['name']}: {b['why']!r}")
print(f" {_bad}")
check("a battery that moves power shows the figure, and no line beside it "
"says 0 W", not _bad)
check("the Off help names no watts, in either language, and says the peak "
"limit may use the battery",
all(not ZERO.search(W["home"]["batteries"]["choice"]["off"]
["description"]) for W in LANGS.values())
and "peak limit" in EN["home"]["batteries"]["choice"]["off"]
["description"])
_going = homeview.battery_now(62, 900, phase=IDLE, shadow=False, running=True,
off=True, peak=False)
check("an Off battery still moving without the peak limit reads 'going to "
"0 W' with its figure, never a bare 0 W",
_going == "Off. Still at 0.9 kW, going to 0 W.")
# ============================================================================ #
print("\n3. the plan leaves an Off battery out, except for the peak limit")
_c = D.controller("off")
_c.maints[1].cfg = _c.maints[0].cfg # both due, so both would be drawn
_p = build("off", c=_c)
_kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i}
for i in (0, 1)}
print(f" {_kinds}")
check("Off: no 'follows the house', no trade, no monthly cycle for B",
not _kinds[1] and "house" in _kinds[0])
_p = build("off_peak")
check("...but the peak block stays on it while the peak limit needs it",
{r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 1}
== {"peak"})
# ============================================================================ #
print("\n4. battery_why(): pure, most urgent first")
BASE = dict(modes_on=True, mode=packmode.SOLAR_FIRST, off=False, reading=True,
phase=IDLE, shadow=False, running=True, held=False, refused=0,
peak=False, moving=False, sf_ahead=False)
def why(**kw):
return homeview.battery_why(**dict(BASE, **kw))
_cases = [
(dict(reading=False, held=True), "no_reading"),
(dict(held=True, shadow=True), "held"),
(dict(running=False, off=True), "stopped"),
(dict(shadow=True, phase=DRAIN), "shadow"),
(dict(phase=DRAIN, off=True), "cycle_mode"),
(dict(phase=DRAIN, modes_on=False), "cycle"),
(dict(off=True, peak=True, moving=True), "off_peak"),
(dict(off=True, peak=True, moving=False), "off"),
(dict(off=True, moving=True), "off"),
(dict(refused=1, peak=True), "refuses_give"),
(dict(refused=-1), "refuses_charge"),
(dict(peak=True, giving=True, other_refuses="B"), "peak"),
(dict(giving=True, other_refuses="B", modes_on=False), "other_refuses"),
(dict(modes_on=False, giving=True), "modes_off"),
(dict(giving=True, mode=packmode.LEFTOVER_SOLAR), "covers_house"),
(dict(mode=packmode.LEFTOVER_SOLAR, sf_ahead=True), "leftover"),
(dict(mode=packmode.LEFTOVER_SOLAR), "leftover_alone"),
({}, "solar_first"),
]
_wrong = [(kw, want, why(**kw)) for kw, want in _cases if why(**kw) != want]
print(f" {_wrong}")
check(f"{len(_cases)} cases, each the expected reason", not _wrong)
check("every reason it can return has words in both languages",
{w for _k, w in _cases} == set(EN["home_words"]["why"])
== set(NL["home_words"]["why"]))
# ============================================================================ #
print("\n5. /action round trips from the screen, over the real web app")
async def _serve(state, calls):
c = D.controller(state)
stored = dict(D.defaults(D.CONFIG), **D.OPTS)
runner = aioweb.AppRunner(web.build_app(c, D.Supervisor(stored)))
await runner.setup()
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
out = []
try:
async with aiohttp.ClientSession() as s:
root = f"http://127.0.0.1:{port}/"
for body, lang in calls:
if body is not None:
async with s.post(root + "action", json=body) as r:
out.append((r.status,
await r.json(content_type=None)))
async with s.get(root + "status?lang=" + lang) as r:
out.append((r.status, await r.json(content_type=None)))
finally:
await runner.cleanup()
return out
A, B = (i["name"] for i in D.OPTS["inverters"])
# Exactly what app.js posts: {action, unit, value}.
_r = asyncio.run(_serve("modes", [
({"action": "pack_use", "unit": B, "value": False}, "en"),
({"action": "pack_mode", "unit": A, "value": packmode.LEFTOVER_SOLAR},
"nl"),
({"action": "pack_use", "unit": B, "value": True}, "en"),
({"action": "pack_mode", "unit": A, "value": "cheap_hours"}, "en"),
]))
(_s1, _j1), (_, _st1), (_s2, _j2), (_, _st2), (_s3, _), (_, _st3), \
(_s4, _j4), (_, _st4) = _r
check("switch B off: 200, and /status shows B Off with 'You switched it off.'",
_s1 == 200 and _j1["result"]["use"] is False
and _st1["batteries"][1]["mode_key"] == "off"
and _st1["batteries"][1]["why"] == EN["home_words"]["why"]["off"])
check("set A to Leftover solar: 200, and /status?lang=nl shows it in Dutch",
_s2 == 200 and _st2["batteries"][0]["mode_choice"]
== packmode.LEFTOVER_SOLAR
and _st2["batteries"][0]["mode"]
== NL["home_words"]["modes"]["leftover_solar"])
check("switch B back on: its stored mode returns",
_s3 == 200 and _st3["batteries"][1]["mode_key"]
== packmode.LEFTOVER_SOLAR)
check("a mode the list only shows greyed out is refused with a plain 400, and "
"nothing changes", _s4 == 400 and _j4["ok"] is False and _j4["error"]
and _st4["batteries"][0]["mode_choice"] == packmode.LEFTOVER_SOLAR)
_r = asyncio.run(_serve("running", [
({"action": "pack_use", "unit": B, "value": False}, "en")]))
check("with battery modes off the switch is refused with the plain reason, "
"and the batteries stay on", _r[0][0] == 400 and _r[0][1]["error"]
and "mode_key" not in _r[1][1]["batteries"][1])
# ============================================================================ #
print("\n6. the page")
PAGE = web.page()
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
encoding="utf-8").read()
SJS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
check("the Batteries screen has its cards, the modes-off card and 'How the "
"modes work'", all(f'id="{i}"' in PAGE for i in
("bcards", "batt-off", "batt-how", "batt-banner")))
check("Settings has a Simple / Installer choice as a radio group, and Simple "
"comes first", re.search(r'id="set-view"[^>]*>\s*<button[^>]*'
r'data-v="simple"', PAGE) is not None
and PAGE.count('role="radio"') >= 2 and 'id="simple"' in PAGE)
_acts = set(re.findall(r'packAct\("(\w+)"', JS))
check(f"the cards post only the existing actions ({sorted(_acts)}), as "
f"{{action, unit, value}}", _acts == set(web.PACK_ACTIONS)
and "action: what, unit: b.name, value: value" in JS)
check("the switch is a real switch and the list names cheap hours as coming "
"later, not choosable", 'role: "switch"' in JS and '"aria-checked"' in JS
and 'h("option", { value: "", disabled: true }, t("batteries.later"))'
in JS)
check("the selling switch is greyed out until OPT-11 P3 (no pack_sell is "
"posted)", "pack_sell" not in JS and "disabled: true })" in JS)
_T = uipage.ui_text("en")
_lay = uipage.layout(D.schema_ui(D.CONFIG), {"en": {}})
_simple = [f for g in _lay["groups"] for p in g["parts"] for f in p["fields"]
if f.get("simple")]
check("Settings, Simple draws the fields the layout marks, and each note "
"has words in both languages",
sorted(f["key"] for f in _simple)
== sorted(f.key for g in L.GROUPS for p in g.parts for f in p.fields
if f.simple)
and all(f["note"] in EN["settings"]["simple"]["note"]
and f["note"] in NL["settings"]["simple"]["note"]
for f in _simple if f["note"]))
check("settings.js picks them by the `simple` flag and names no option key",
"f.simple" in SJS and not any(f'"{f.key}"' in SJS for g in L.GROUPS
for p in g.parts for f in p.fields))
# ============================================================================ #
print("\n7. no control path changes: the screen reads, it does not write")
SRC = io.open(os.path.join(HERE, "app", "homeview.py"), encoding="utf-8").read()
WRITES = {"set_number", "store_set", "set", "set_time", "handle_action",
"log_event", "abort", "tick", "publish", "call", "write",
"_pack_action", "_maint_stop_off"}
_calls = sorted({n.func.attr for n in ast.walk(ast.parse(SRC))
if isinstance(n, ast.Call)
and isinstance(n.func, ast.Attribute)
and n.func.attr in WRITES})
check(f"homeview.py calls nothing that writes state or commands an inverter "
f"({_calls})", not _calls)
_imports = sorted({(n.module or "") for n in ast.walk(ast.parse(SRC))
if isinstance(n, ast.ImportFrom)})
check(f"...and imports no module that sends power requests ({_imports})",
not {"main", "inverter", "hass", "control", "allocator",
"arbiter"} & {m.lstrip(".") for m in _imports})
_c = D.controller("off_peak")
_before = (json.dumps(_c.store.data, sort_keys=True, default=str),
[(i.last_write, i.target) for i in _c.inverters])
for lang in LANGS:
build("off_peak", lang, c=_c)
check("building the screen twice leaves the store and every power request "
"as it was", _before
== (json.dumps(_c.store.data, sort_keys=True, default=str),
[(i.last_write, i.target) for i in _c.inverters]))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
@@ -0,0 +1,184 @@
"""UX-13 P3: the headless-browser check of the Batteries screen and Settings,
Simple. Run by `CHECK=1 tools/ux13_screenshots.sh`, like the screenshots.
For every battery-mode fixture (tools/ux13_demo.py) it opens both screens at
phone and desktop width, light and dark, in English and Dutch, and fails on a
page error, a console error or a sideways scroll. Then it clicks the switch
and the mode list on both screens and checks what was posted and what the
cards show after.
python3 ux13_browser_check.py # DEMO_<STATE> = the fixture's URL
"""
import json
import os
import sys
from playwright.sync_api import sync_playwright
STATES = ("running", "modes", "off", "off_peak", "refusal", "maint")
SIZES = {"phone": {"width": 390, "height": 844},
"desktop": {"width": 1000, "height": 900}}
FAILS = []
def url(state):
return os.environ[f"DEMO_{state.upper()}"]
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
FAILS.append(name)
def open_page(browser, state, route, *, size, scheme, lang):
ctx = browser.new_context(locale=lang, viewport=SIZES[size],
color_scheme=scheme, reduced_motion="reduce")
page = ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(f"pageerror {e}"))
page.on("console", lambda m: errors.append(f"console {m.text}")
if m.type == "error" else None)
page.goto(url(state) + route)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1200)
return ctx, page, errors
def sweep(browser):
n = 0
for state in STATES:
for route in ("#batteries", "#settings"):
for size in SIZES:
for scheme in ("light", "dark"):
for lang in ("en-GB", "nl-BE"):
ctx, page, errors = open_page(
browser, state, route, size=size, scheme=scheme,
lang=lang)
wide = page.evaluate(
"document.documentElement.scrollWidth > "
"document.documentElement.clientWidth")
if route == "#batteries":
cards = page.locator("#bcards .bcard").count()
texts = page.locator("#bcards dd").all_inner_texts()
ok = cards == 2 and all(t.strip() for t in texts)
else:
ok = (page.locator("#simple .card").count() >= 3
and page.locator("#groups").is_hidden())
where = f"{state} {route} {size} {scheme} {lang}"
if errors or wide or not ok:
FAILS.append(f"{where}: errors={errors} "
f"wide={wide} drawn={ok}")
print(f" FAIL {where}: {errors} wide={wide} "
f"drawn={ok}")
n += 1
ctx.close()
check(f"{n} views: 0 page errors, 0 console errors, no sideways scroll, "
f"every card drawn", not [f for f in FAILS if " #" in f])
def posts(page):
sent = []
page.on("request", lambda r: sent.append(json.loads(r.post_data))
if r.url.endswith("/action") and r.method == "POST" else None)
return sent
def actions(browser):
# The Batteries screen: switch B off, then set A to Leftover solar.
ctx, page, errors = open_page(browser, "modes", "#batteries",
size="desktop", scheme="light", lang="en-GB")
sent = posts(page)
page.locator("#bsw-c1").click()
page.wait_for_timeout(1500)
card_b = page.locator("#bcards .bcard").nth(1)
check("Batteries: B's switch posts pack_use false, and B's card turns Off "
"with its mode list greyed out",
sent[-1:] == [{"action": "pack_use", "unit": "Battery B",
"value": False}]
and "off" in (card_b.get_attribute("class") or "")
and page.locator("#bsel-c1").is_disabled()
and "You switched it off." in card_b.inner_text())
page.locator("#bsel-c0").focus()
page.locator("#bsel-c0").select_option("leftover_solar")
page.wait_for_timeout(1500)
check("...A's list posts pack_mode leftover_solar and the card keeps it",
sent[-1:] == [{"action": "pack_mode", "unit": "Battery A",
"value": "leftover_solar"}]
and page.locator("#bsel-c0").input_value() == "leftover_solar"
and "Leftover solar" in page.locator("#bcards .bcard").nth(0)
.inner_text())
check("...the cheap-hours choice and the selling switch are greyed out",
page.locator("#bsel-c0 option[disabled]").count() == 1
and page.locator("#bcards .bcard .f .switch[disabled]").count() == 2)
check("...and the focus stays on the control that was used",
page.evaluate("document.activeElement.id") == "bsel-c0")
check("...with 0 page errors", not errors)
ctx.close()
# Settings, Simple: the same switch, and the Installer view is one tap.
ctx, page, errors = open_page(browser, "modes", "#settings",
size="phone", scheme="dark", lang="nl-BE")
sent = posts(page)
page.locator("#bsw-s1").click()
page.wait_for_timeout(1500)
check("Simple (NL, phone, dark): B's switch posts pack_use true and reads "
"Aan", sent[-1:] == [{"action": "pack_use", "unit": "Battery B",
"value": True}]
and page.locator("#bsw-s1").get_attribute("aria-checked") == "true")
page.locator("#set-view [data-v=installer]").click()
page.wait_for_timeout(500)
check("...Installer shows the groups and hides Simple",
page.locator("#groups").is_visible()
and page.locator("#simple").is_hidden())
check("...with 0 page errors", not errors)
ctx.close()
def modes_off(browser):
ctx, page, errors = open_page(browser, "running", "#batteries",
size="phone", scheme="light", lang="en-GB")
page.locator("#batt-off a").click()
page.wait_for_timeout(1500)
check("modes off: the card's button opens Settings on the Installer view",
page.url.endswith("#settings") and page.locator("#groups")
.is_visible() and not errors)
ctx.close()
def month_peak(browser):
# The fixture has no month peak yet, so give /status one on the way in.
ctx = browser.new_context(locale="en-GB", viewport=SIZES["desktop"])
page = ctx.new_page()
def with_peak(route):
r = route.fetch()
body = r.json()
body["month_peak"]["peak_w"] = 2860.0
route.fulfill(response=r, json=body)
page.route("**/status*", with_peak)
page.goto(url("modes") + "#settings")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500)
check("Simple: the peak card shows this month's peak from /status",
"This month's peak so far: 2.9 kW." in page.locator("#simple")
.inner_text())
ctx.close()
def main():
with sync_playwright() as p:
b = p.chromium.launch()
sweep(b)
actions(b)
month_peak(b)
modes_off(b)
b.close()
if FAILS:
print(f"{len(FAILS)} FAILED")
sys.exit(1)
print("all passed")
if __name__ == "__main__":
main()
+35 -2
View File
@@ -2,7 +2,9 @@
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
STATE is running, shadow, alarm, new (P4) or setup (UX-15's setup-only
mode, P5). The controller is the shipped `Controller`
mode, P5). P3 adds the battery-mode states in `P3`: modes (on, B on Leftover
solar), off (B switched off), off_peak (B off but giving for the peak limit),
refusal (A refuses to give power) and maint (A in its monthly cycle). The controller is the shipped `Controller`
with its readings set by hand (two batteries, solar, today's prices in the
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
inverter: `/action` changes only this fixture. Used for the P1 render check
@@ -34,8 +36,9 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
from aiohttp import web as aioweb # noqa: E402
from app import homeview, web # noqa: E402
from app import homeview, packmode, web # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN # noqa: E402
from app.setuponly import SetupOnly # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
@@ -231,8 +234,14 @@ OPTS = {"meter_entity": "sensor.grid", "meter_source": "off",
"max_w": 2500, "capacity_kwh": 5.4}]}
# UX-13 P3: the battery-mode states, each with battery modes on.
P3 = ("modes", "off", "off_peak", "refusal", "maint")
def controller(state):
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
if state in P3:
opts["battery_modes_enabled"] = True
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
c.auto = True
now = datetime.now(timezone.utc)
@@ -255,6 +264,29 @@ def controller(state):
c.pv_w = 3900.0
if state == "alarm":
c.inverters[0].sign_wd.tripped = True
if state in P3:
a, b = c.inverters
b.store_set(packmode.MODE_KEY, packmode.LEFTOVER_SOLAR)
if state in ("off", "off_peak"):
b.store_set(packmode.USE_KEY, False)
b.batt = 0.0
if state == "off_peak":
# A cloudy evening: the house takes 6 kW and the peak limit needs
# both batteries, so B gives 0.5 kW although it is switched off.
c.pv_w, c.grid = 0.0, 3490.0
a.batt, b.batt = 2000.0, 510.0
c.peak_guard = PeakDecision(engaged=True, floor_w=2500.0,
ceiling_w=3500.0, ceiling_from="cap",
projected_w=3600.0)
if state == "refusal":
# A is at its cut-out level and refuses: asked 0 W, B takes over.
c.pv_w, c.grid = 0.0, 5.0
a.soc, a.batt, a.refused = 9.0, -4.0, 1
b.batt = 1210.0
if state == "maint":
c.maints[0].store.set("phase", DRAIN) # MAINT-05: A's own key
c.pv_w, c.grid = 0.0, 0.0
a.batt, b.batt = 2000.0, 0.0
return c
@@ -290,6 +322,7 @@ async def main(state, port, host="127.0.0.1"):
p1_net_entity="sensor.grid", price_provider="generic_dayahead",
peak_forecast_entity="sensor.peak_forecast",
shadow_mode=c.o.get("shadow_mode", "off"),
battery_modes_enabled=state in P3,
inverters=[dict(i, bms_soc_floor=10)
for i in OPTS["inverters"]])
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
+18 -4
View File
@@ -6,8 +6,8 @@ error, so a broken screen is never pictured.
python3 ux13_screenshots.py OUT_DIR # DEMO_<STATE> = the fixture's URL
The battery-mode screens (P3) are not built, so there is no shot of them:
DOCS.md says so instead of showing a mock-up.
P3 adds the Batteries screen and Settings, Simple (the default Settings
view), so the Installer shots click Installer first.
"""
import os
@@ -48,7 +48,12 @@ def shot(browser, name, state, route, *, lang="en-GB", size=DESKTOP,
print(f" {name}.png")
def installer(page):
page.locator("#set-view [data-v=installer]").click()
def open_tariff(page):
installer(page)
page.locator("details.grp[data-g=tariff] > summary").click()
page.locator("details.grp[data-g=meter] > summary").click() # close it
# The fixture site is on a dynamic contract: its fallback gets windows.
@@ -80,8 +85,17 @@ def main():
shot(b, "home-shadow", "shadow", "#home")
shot(b, "home-phone", "running", "#home", size=PHONE)
shot(b, "home-setup-only", "setup", "#home", full=False)
shot(b, "settings-installer", "running", "#settings")
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE")
shot(b, "settings-installer", "running", "#settings", act=installer)
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE",
act=installer)
shot(b, "batteries", "modes", "#batteries")
shot(b, "batteries-nl", "modes", "#batteries", lang="nl-BE")
shot(b, "batteries-off-peak", "off_peak", "#batteries")
shot(b, "batteries-phone", "refusal", "#batteries", size=PHONE,
full=False)
shot(b, "batteries-modes-off", "running", "#batteries", full=False)
shot(b, "settings-simple", "modes", "#settings")
shot(b, "settings-simple-nl", "modes", "#settings", lang="nl-BE")
tariff = "details.grp[data-g=tariff]"
shot(b, "settings-time-windows", "running", "#settings",
act=windows, only=tariff)
+5 -2
View File
@@ -2,6 +2,7 @@
# UX-13 P5: regenerate the DOCS screenshots in docs/img/ux13/ from the real page.
#
# sh goodwe_controller/tools/ux13_screenshots.sh [OUT_DIR]
# CHECK=1 sh goodwe_controller/tools/ux13_screenshots.sh # P3 browser check
#
# Serves tools/ux13_demo.py's fixtures (running, shadow, new, setup) in the CI
# test image, then runs tools/ux13_screenshots.py in the Playwright image on a
@@ -14,7 +15,9 @@ HERE=$(cd "$(dirname "$0")/.." && pwd)
OUT=${1:-$HERE/../docs/img/ux13}
PW=mcr.microsoft.com/playwright/python:v1.55.0-noble
NET=ux13-shots-$$
STATES="running shadow new setup"
STATES="running shadow new setup modes off off_peak refusal maint"
RUN=/t/ux13_screenshots.py
[ "${CHECK:-}" = 1 ] && RUN=/t/ux13_browser_check.py
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
@@ -39,5 +42,5 @@ docker run --rm --network "$NET" --user "$(id -u):$(id -g)" -e HOME=/tmp \
$ENVS -v "$HERE/tools:/t:ro" -v "$OUT:/out" "$PW" \
sh -euc 'pip install -q --disable-pip-version-check --target /tmp/pw \
playwright==1.55.0
PYTHONPATH=/tmp/pw python3 /t/ux13_screenshots.py /out'
PYTHONPATH=/tmp/pw python3 '"$RUN"' /out'
echo "screenshots in $OUT"