diff --git a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md index b23019f..540cbb0 100644 --- a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md +++ b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md @@ -60,6 +60,12 @@ What UX-02 made authoritative stays authoritative: new validator. At P0: 261 calls, 158 refused, no refusal changed. - test_selfconfig.py part 5 still checks the old page script; P1 moves the page into `app/static/` and the same check applies there. +- P2: the Installer view (`app/static/settings.js`) draws from + `app/uipage.py`, which serialises the table and names no key itself + (test_uilayout part 5, test_settings part 2). The view posts its page model + to the existing `/config/validate` and `/config/save`, which turn it into + options with `ui_to_options()` before the shipped validator runs. No route + was added. ## 4. What this costs diff --git a/goodwe_controller/app/hass.py b/goodwe_controller/app/hass.py index 3835e1d..a2546c1 100644 --- a/goodwe_controller/app/hass.py +++ b/goodwe_controller/app/hass.py @@ -132,8 +132,15 @@ class HomeAssistant: except aiohttp.ClientConnectionError as err: OUTAGE.down("api", "no connection", f"read all states: {err}") return None - except (aiohttp.ClientError, TimeoutError, ValueError) as err: - self._moan("read:*", "Reading the entity list failed: %s", err) + # A timeout's own text is empty, so the line names the wait instead + # (UX-13 P1 test, c). + except TimeoutError: + self._moan("read:*", "Reading the entity list failed: no answer " + "within %s seconds.", STATES_TIMEOUT_S) + return None + except (aiohttp.ClientError, ValueError) as err: + self._moan("read:*", "Reading the entity list failed: %s", + str(err) or type(err).__name__) return None if not isinstance(obj, list): self._moan("shape:*", "The entity list is not a list: %.80r", obj) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 4e58505..250720d 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -82,7 +82,7 @@ def flow(w, *, shadow=False): return "resting" -def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow): +def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True): """The four tiles. `house_w` needs all three readings, so without a solar reading it is None rather than a number that leaves the sun out.""" house, disagree = None, False @@ -105,7 +105,7 @@ def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow): "solar": tile_solar(pv_w, has_solar), "house": tile_house(house, has_solar, disagree), "grid": tile_grid(grid_w), - "batt": tile_batt(batt_w, shadow), + "batt": tile_batt(batt_w, shadow, settled), }, } @@ -174,7 +174,7 @@ def hour_bars(points, day0, day1): price = (sum(p.eur_per_kwh for p in inside) / len(inside) if inside else None) bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a), - "end_label": hhmm(b), + "end_label": hhmm(b), "points": len(inside), "eur_kwh": None if price is None else round(price, 4), "estimate": bool(inside) and any(p.provisional for p in inside), @@ -196,6 +196,19 @@ def hour_bars(points, day0, day1): return bars +def day_range(points): + """(lowest, highest) of today's published points, as {eur_kwh, label}, + or (None, None). The feed's own points, like the trade legs, not the + chart's hour averages.""" + firm = [p for p in points if not p.provisional] + if not firm: + return None, None + lo = min(firm, key=lambda p: p.eur_per_kwh) + hi = max(firm, key=lambda p: p.eur_per_kwh) + return ({"eur_kwh": round(lo.eur_per_kwh, 4), "label": hhmm(lo.start)}, + {"eur_kwh": round(hi.eur_per_kwh, 4), "label": hhmm(hi.start)}) + + # --------------------------------------------------------------------------- # # The plan, per battery # --------------------------------------------------------------------------- # @@ -407,7 +420,7 @@ def region_of(opts): if rid in REGION_PRESETS else None} -def build_plan(c, now, shadow, running, held=False): +def build_plan(c, now, shadow, running, held=False, settled=True): day0, day1 = local_day(now) fresh = c.fresh_tariff_plan(now) plan = fresh @@ -477,17 +490,15 @@ def build_plan(c, now, shadow, running, held=False): "day": brussels(now).date().isoformat(), "now_h": round(_hours(now, day0), 3), "hours": int(round(_hours(day1, day0))), - "verdict": VERDICT[verdict_key], + "verdict": (VERDICT[verdict_key] if settled or verdict_key != "held" + else ZERO_GOING), "verdict_key": verdict_key, - "head": plan_head(verdict_key, shadow), + "head": plan_head(verdict_key, shadow, settled), "prices": bars, "runs": runs, "lines": lines, "why": c.why_words, - "price_line": line_prices( - price_now, - min(known, key=lambda b: b["eur_kwh"]) if known else None, - max(known, key=lambda b: b["eur_kwh"]) if known else None), + "price_line": line_prices(price_now, *day_range(seen)), "tomorrow_line": line_tomorrow(tomorrow) if feed else "", "region": region, "region_line": (line_region(region["id"], region["year"]) @@ -499,6 +510,13 @@ def build_plan(c, now, shadow, running, held=False): # --------------------------------------------------------------------------- # # Batteries, attention, month peak, control # --------------------------------------------------------------------------- # +def at_zero(inv) -> bool: + """Has this inverter's last accepted power request reached 0 W? A stop or + an alarm asks for 0 W at once, but the inverter gets it with the next + write, so until then the page says "Going to 0 W" (UX-13 P1 test, a).""" + return abs(inv.standing_w()) < 1.0 + + def battery_rows(c, shadow, running, held=False): shares = {} alloc = getattr(c, "allocation", None) @@ -521,7 +539,8 @@ def battery_rows(c, shadow, running, held=False): "maintenance": phase, "now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow, running=running, held=held, - would_w=shares.get(inv.name)), + would_w=shares.get(inv.name), + settled=at_zero(inv)), # Per-battery reasons arrive with battery modes (OPT-11). "why": None, }) @@ -633,15 +652,18 @@ def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict: # what the add-on would do. held = level == "bad" and not shadow 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) return { "state": {"kind": kind, "pill": pill(kind), "ran": ctl.get("text", "") if ctl else "", "sub": banner_sub(kind)}, "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), + 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), + "plan": build_plan(c, now, shadow, running, held=held, + settled=settled), "attention": ([{"severity": INFO, "id": "shadow", "title": ATTN_SHADOW, "body": attn_shadow_body(), "docs": None}] if shadow else []) + attention(checks), @@ -673,8 +695,9 @@ ATTN_SHADOW = "Shadow mode is on" def attn_shadow_body() -> str: return "The add-on decides everything, but every inverter stays at 0 W." +ZERO_HELD, ZERO_GOING = "Held at 0 W", "Going to 0 W" VERDICT = { - "held": "Held at 0 W", + "held": ZERO_HELD, "stopped": "Stopped", "cycle": "Monthly cycle today", "trade": "Trading on price", @@ -728,26 +751,31 @@ def tile_grid(grid_w) -> str: return "Balanced" -def tile_batt(batt_w, shadow) -> str: +def _zero(settled) -> str: + return ZERO_HELD if settled else ZERO_GOING + + +def tile_batt(batt_w, shadow, settled=True) -> str: if shadow: - return "Held at 0 W" + return _zero(settled) return {"giving": "Giving power", "charging": "Charging", "resting": "Resting", None: "No reading"}[flow(batt_w)] def battery_now(soc, batt_w, *, phase, shadow, running, held=False, - would_w=None) -> str: + would_w=None, settled=True) -> str: if soc is None or batt_w is None: return "No reading from this battery." + zero = _zero(settled) if shadow: if would_w is None or abs(would_w) <= IDLE_W: - return "Held at 0 W. Would rest now." + return f"{zero}. Would rest now." verb = "give" if would_w > 0 else "charge" - return f"Held at 0 W. Would {verb} {_kw(would_w)} now." + return f"{zero}. Would {verb} {_kw(would_w)} now." if held: - return "Held at 0 W until the alarm clears." + return f"{zero} until the alarm clears." if not running: - return "Held at 0 W while control is stopped." + return f"{zero} while control is stopped." if phase != M_IDLE: return {M_DRAIN: "Monthly cycle: emptying.", M_CHARGE: "Monthly cycle: refilling.", @@ -761,9 +789,10 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False, return "Resting now." -def plan_head(key, shadow) -> str: +def plan_head(key, shadow, settled=True) -> str: if key == "held": - return "Every inverter is at 0 W until the alarm clears." + return ("Every inverter is at 0 W until the alarm clears." if settled + else "Every inverter goes to 0 W until the alarm clears.") if key == "stopped": return "Control is stopped, so nothing is planned." if not shadow: @@ -784,8 +813,10 @@ def run_grid_what(kwh, shadow) -> str: f" up to {_kwh(kwh)} from the grid." +# The trade legs and the price line use the feed's own points (a 15-minute +# period on a quarter-hourly feed), so both name the same price and time. def run_grid_why(price) -> str: - return f"Cheapest price in the list, {_eur(price)} per kWh." + return f"Lowest published price, {_eur(price)} per kWh." def run_grid_amount(eur) -> str: @@ -798,7 +829,7 @@ def run_give_what(kwh, shadow) -> str: def run_give_why(price) -> str: - return f"Dearest price in the list, {_eur(price)} per kWh." + return f"Highest published price, {_eur(price)} per kWh." def run_give_amount(eur) -> str: @@ -880,8 +911,8 @@ def line_prices(now_eur, lo, hi) -> str: if now_eur is not None: parts.append(f"Price now {_eur(now_eur)} per kWh, all in.") if lo is not None and hi is not None: - parts.append(f"Today: cheapest {_eur(lo['eur_kwh'])} at " - f"{lo['label']}, dearest {_eur(hi['eur_kwh'])} at " + parts.append(f"Today: lowest {_eur(lo['eur_kwh'])} at " + f"{lo['label']}, highest {_eur(hi['eur_kwh'])} at " f"{hi['label']}.") return " ".join(parts) or "No prices for today." @@ -924,7 +955,7 @@ def text_samples() -> list: """`(kind, text)` for every sentence above, rendered with real values, so test_messages.py can hold each to its STYLE limit. Kinds: title, plan (2 sentences of 12 words), line (1 sentence of 25 words).""" - out = [("title", t) for t in VERDICT.values()] + out = [("title", t) for t in VERDICT.values()] + [("title", ZERO_GOING)] out += [("title", t) for t in ATTN_TITLES.values()] out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW), ("line", attn_shadow_body())] @@ -938,6 +969,7 @@ def text_samples() -> list: out.append(("title", tile_house(None, True, True))) out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)] out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)] + out.append(("title", tile_batt(0, sh, settled=False))) for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD): for w in (2500, -2500, 0): out.append(("plan", battery_now(62, w, phase=phase, @@ -945,8 +977,14 @@ def text_samples() -> list: would_w=-2400))) out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh, running=False))) - out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False, - running=False, held=True))) + for settled in (True, False): + out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False, + running=False, held=True, + settled=settled))) + out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh, + running=False, would_w=-2400, + settled=settled))) + out.append(("plan", plan_head("held", sh, settled=False))) out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh, running=True))) for k in VERDICT: diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index 826b35d..ad582d7 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -135,3 +135,84 @@ dialog::backdrop{background:rgba(0,0,0,.45)} .chart [data-tip]{cursor:default} .chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px} .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +/* UX-13 P2: Settings, the Installer view (mockup version 2) */ +.btn.link{border:0;background:none;color:var(--accent);padding:6px 0;min-height:0} +.search{width:100%;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:10px 12px;min-height:44px;margin-bottom:10px} +.key{font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint);overflow-wrap:anywhere} +.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;border-radius:999px;padding:1px 8px;background:var(--info-bg);color:var(--muted);white-space:nowrap} +.chip.now{background:var(--ok-bg);color:var(--ok)} .chip.rs{background:var(--warn-bg);color:var(--warn)} +.chip.bad{background:var(--bad-bg);color:var(--bad)} .chip.changed{background:var(--accent-soft);color:var(--accent)} +details.grp{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:10px;box-shadow:var(--shadow)} +details.grp>summary{list-style:none;cursor:pointer;padding:14px;display:flex;gap:10px;align-items:center;min-height:56px;flex-wrap:wrap} +details.grp>summary::-webkit-details-marker{display:none} +details.grp>summary .grow{flex:1 1 200px} +details.grp>summary .gdesc{display:block} +details.grp>summary .chev{transition:transform .15s;flex:none;font-size:22px;line-height:1;width:18px;text-align:center;color:var(--muted)} +details.grp[open]>summary .chev{transform:rotate(90deg)} +.gchips,.gbadge{display:inline-flex;flex-wrap:wrap;gap:4px} +details.grp .body{padding:0 14px 8px} +details.sub{border-top:1px solid var(--line);margin-top:4px} +details.sub>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center} +.subhead{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint);margin:14px 0 0;font-weight:700} +.f{padding:12px 0;border-top:1px solid var(--line);min-width:0} +.part>.f:first-child,.subhead+.f{border-top:0} +.f .lbl{font-weight:600;font-size:14px} +.f .help{font-size:13px;color:var(--muted);margin-top:4px} +.f .ctl{margin-top:8px;min-width:0} +.f.inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:start} +.f.inline .ctl{margin-top:0;grid-row:1;grid-column:2} +.f.inline .help,.f.inline .msgs{grid-column:1/-1} +.f.changed>.head .lbl::after{content:" •";color:var(--accent)} +.msg{font-size:13px;margin-top:6px;display:flex;gap:6px;align-items:flex-start;border-radius:8px;padding:6px 8px;overflow-wrap:anywhere} +.msg .mi{font-weight:800;flex:none} +.msg.bad{background:var(--bad-bg);color:var(--bad)} .msg.warn{background:var(--warn-bg);color:var(--warn)} .msg.info{background:var(--info-bg);color:var(--info)} +.f.has-bad .ctl select,.f.has-bad .ctl input,.f.has-bad .ent-btn{border-color:var(--bad)} +.switch{position:relative;width:52px;height:44px;border:0;background:none;cursor:pointer;flex:none;padding:0} +.switch::before{content:"";position:absolute;left:2px;top:8px;width:48px;height:28px;border-radius:14px;background:var(--track);transition:background .15s} +.switch::after{content:"";position:absolute;top:11px;left:5px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:left .15s} +.switch[aria-checked="true"]::before{background:var(--accent)} +.switch[aria-checked="true"]::after{left:25px} +.swrap{display:inline-flex;align-items:center;gap:8px;min-height:44px} +.swtext{font-size:13px;color:var(--muted)} +select.sel,input.txt,input.num{border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 10px;min-height:44px;max-width:100%} +select.sel{width:100%} +.row>select.sel{width:auto;flex:1 1 200px} +input.num{width:110px;text-align:right;font-variant-numeric:tabular-nums} +input.txt{width:100%} +input:disabled{opacity:.6} +.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-width:100%;flex-wrap:wrap} +.seg button{border:0;background:var(--card);padding:8px 12px;min-height:44px;cursor:pointer;border-left:1px solid var(--line)} +.seg button:first-child{border-left:0} +.seg button[aria-checked="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600} +.choice{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;cursor:pointer;width:100%;text-align:left;background:var(--card);min-height:44px} +.choice[aria-checked="true"]{border-color:var(--accent);background:var(--accent-soft)} +.choice .rad{flex:none;width:18px;height:18px;border-radius:50%;border:2px solid var(--faint);margin-top:2px} +.choice[aria-checked="true"] .rad{border:5px solid var(--accent)} +.slider{display:flex;align-items:center;gap:10px;width:100%} +.slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:28px} +.unit{color:var(--muted);font-size:13px;white-space:nowrap} +.ent-btn{width:100%;text-align:left;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:8px 10px;min-height:44px;cursor:pointer;display:flex;gap:8px;align-items:center} +.ent-btn .grow{min-width:0} +.ent-btn .grow>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ent-pop{border:1px solid var(--line);border-radius:10px;margin-top:6px;background:var(--card);overflow:hidden} +.ent-pop input[type=search]{border:0;border-bottom:1px solid var(--line);width:100%;padding:10px;background:var(--card);min-height:44px} +.ent-note{padding:6px 10px} +.ent-list{max-height:240px;overflow:auto} +.ent-opt{display:flex;gap:8px;align-items:center;width:100%;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;padding:8px 10px;cursor:pointer;min-height:44px} +.ent-opt:hover,.ent-opt:focus{background:var(--accent-soft)} +.ent-opt .grow{min-width:0} +.ent-opt .grow>div{overflow-wrap:anywhere} +.ent-opt .v{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);white-space:nowrap} +.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px} +.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)} +.elist>.btn{margin-top:8px} +.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0} +.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap} +.inv>.f:nth-child(2){border-top:0} +.invs>.btn{margin:4px 0 8px} +#savebar{position:fixed;left:0;right:0;bottom:0;z-index:15} +#savebar .in{max-width:820px;margin:0 auto 10px;padding:10px 12px;background:var(--ink);color:var(--bg);border-radius:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,.25);width:calc(100% - 32px)} +#savebar .btn{min-height:44px;padding:6px 12px;background:transparent;color:var(--bg);border-color:rgba(127,127,127,.6)} +#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)} +@media (max-width:640px){#savebar{bottom:62px}} +#s-set{padding-bottom:80px} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index fb9810a..0153c55 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -40,10 +40,12 @@ clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600); } let onYes = null; + // `key` names Home's words in ui_text, or is {name, description, yes}. function ask(key, fn) { - $("#dlg-t").textContent = t("dialog." + key + ".name"); - $("#dlg-b").textContent = t("dialog." + key + ".description"); - $("#dlg-yes").textContent = t("dialog." + key + ".yes"); + const w = typeof key === "string" ? t("dialog." + key) : key; + $("#dlg-t").textContent = w.name; + $("#dlg-b").textContent = w.description; + $("#dlg-yes").textContent = w.yes; onYes = fn; const d = $("#dlg"); if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn(); @@ -98,7 +100,6 @@ renderControl(structured); renderDetails(!structured); renderBatteries(); - renderSettings(); } /* ---------------- Home: now ---------------- */ @@ -162,7 +163,7 @@ const title = b.label + " " + t("plan.time_to") + " " + b.end_label; if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]); const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : ""; - const est = b.estimate ? ", " + t("plan.estimate_tip") : ""; + const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : ""); return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""), "height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%", title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]); @@ -289,7 +290,7 @@ if (open) $("#c-details").open = true; } - /* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */ + /* ---------------- Batteries (placeholder until P3) ---------------- */ function renderBatteries() { if (!S.batteries) { $("#bcards").replaceChildren(); return; } $("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" }, @@ -301,10 +302,6 @@ h("dt", {}, t("batteries.now")), h("dd", {}, b.now), h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode))))); } - function renderSettings() { - const line = S.plan && S.plan.region_line; - $("#set-region").textContent = line || ""; $("#set-region").hidden = !line; - } /* ---------------- control buttons ---------------- */ // The action is a toggle, so the page reads the live state first and sends @@ -334,6 +331,9 @@ $("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start"))); $("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort"))); + // Shared with settings.js, which loads after this file. + window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) }; + /* ---------------- tabs: hash routes, arrow keys ---------------- */ const TABS = [...document.querySelectorAll(".tab")]; function select(tab, focus) { @@ -345,6 +345,7 @@ if (focus) tab.focus(); if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash); schedule(); + document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash })); } TABS.forEach((x, i) => { x.addEventListener("click", () => select(x, false)); diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index cecb7a2..ad2b649 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -96,16 +96,23 @@

- +
@@ -126,10 +133,16 @@ +
+ diff --git a/goodwe_controller/app/static/settings.js b/goodwe_controller/app/static/settings.js new file mode 100644 index 0000000..074ad3e --- /dev/null +++ b/goodwe_controller/app/static/settings.js @@ -0,0 +1,555 @@ +"use strict"; +/* UX-13 P2: Settings, the Installer view. It draws from /config/model's + `layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model + from app/uimodel.py), so this file names no option key: keys, labels, + ranges and choices all arrive as data. It posts the page model back; the + add-on turns it into options and runs its own start checks, and nothing + they refuse is saved. Every word is from app/ui_text or the add-on, and + data is set as text, never as HTML. */ +(function () { + const ROOT = JSON.parse(document.getElementById("ui-text").textContent); + const W = ROOT.settings; + const { h, ask, toast, maint } = window.UX13; + const $ = (s) => document.querySelector(s); + const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W); + const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x))); + const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + + document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; }); + document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; }); + document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); }); + $("#savebar").setAttribute("aria-label", W.savebar.label); + + let M = null; // the last /config/model + let V = null; // the page model being edited + let ORIG = null; // the page model as stored + let FP = ""; // the stored blob's fingerprint (compare-and-swap) + let MSG = []; // [{field, card, tier, text}] from the add-on + let DEVICE = ""; + let LIST = null; // the inverter list field + let FILLED = {}; // field key -> the preset field that fills it + let active = false; + let uid = 0; + const ROWS = []; + const OPEN = new Set(); + const UNLOCKED = new Set(); + + /* ---------------- the page model ---------------- */ + const sub = (f) => f.key.slice(LIST.key.length + 1); + function valueIn(model, f, card) { + if (card == null) return model[f.key]; + const c = (model[LIST.key] || [])[card]; + return c ? c[sub(f)] : undefined; + } + const get = (f, card) => valueIn(V, f, card); + function visible(f) { + return (f.show || []).every(([name, op, want]) => { + const have = V[name]; + if (op === "==") return same(have, want); + if (op === "!=") return !same(have, want); + if (op === "in") return want.some((x) => same(x, have)); + if (have == null || have === "" || isNaN(+have)) return false; + return op === ">" ? +have > want : +have < want; + }); + } + function put(f, card, v, redraw) { + if (card == null) V[f.key] = v; + else V[LIST.key][card][sub(f)] = v; + if (f.mirror) { + const m = findField(f.mirror); + if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v; + } + if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value; + if (redraw || f.fills) render(); else paint(); + queueValidate(); + } + function findField(key) { + for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) { + if (f.key === key) return f; + for (const s of f.fields || []) if (s.key === key) return s; + } + return null; + } + + /* ---------------- numbers: stored units <-> shown units ---------------- */ + const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; }; + function shown(f, v) { + if (v == null || v === "") return ""; + return String(+(Number(v) / (f.scale || 1)).toFixed(6)); + } + function stored(f, x) { + const st = f.step || (f.type === "integer" ? 1 : 0); + let v = x * (f.scale || 1); + if (st) v = Math.round(v / st) * st; + if (f.min != null) v = Math.max(f.min, v); + if (f.max != null) v = Math.min(f.max, v); + return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2)); + } + + /* ---------------- controls ---------------- */ + function choicesOf(f, cur) { + const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur)); + if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]); + return out; + } + function toggle(f, card, lid) { + const on = !!get(f, card); + const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid }); + const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off); + b.addEventListener("click", () => { + const n = b.getAttribute("aria-checked") !== "true"; + b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off; + put(f, card, n); + }); + return h("span", { class: "swrap" }, b, txt); + } + function radios(f, card, lid, cards) { + const cur = get(f, card); + const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid }); + const items = choicesOf(f, cur); + const buttons = items.map(([v, label], i) => { + const on = same(v, cur); + const hint = cards && f.hints ? f.hints[v] : ""; + const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on), + tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" }, + cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label); + b.addEventListener("click", () => put(f, card, 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 = (i + d + items.length) % items.length; + put(f, card, items[j][0], true); + const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`); + if (again) again.focus(); + }); + return b; + }); + g.dataset.rk = f.key + "|" + card; + g.append(...buttons); + return g; + } + function select(f, card, lid) { + const cur = get(f, card); + const s = h("select", { class: "sel", "aria-labelledby": lid }); + for (const [v, label] of choicesOf(f, cur)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label)); + for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label)); + s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true)); + const preset = f.presets && f.presets[cur]; + return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s; + } + function slider(f, card, lid) { + if (f.min == null || f.max == null) return number(f, card, lid); + const sc = f.scale || 1; + const st = f.step || (f.type === "integer" ? 1 : 0.01); + const v = get(f, card); + const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid }); + const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid }); + r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); }); + n.addEventListener("change", () => { + if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; } + const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x); + }); + return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null); + } + function number(f, card, lid) { + const sc = f.scale || 1; + const by = FILLED[f.key]; + const preset = by && by.presets[V[by.key]]; + const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any", + min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc, + value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid }); + n.addEventListener("input", () => { + if (n.value === "") { if (f.optional) put(f, card, null); return; } + if (!isNaN(+n.value)) put(f, card, stored(f, +n.value)); + }); + n.addEventListener("change", () => { n.value = shown(f, get(f, card)); }); + return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null), + preset ? h("div", { class: "tiny muted" }, W.preset.description) : null); + } + function text(f, card, lid) { + const v = get(f, card); + const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid }); + t.addEventListener("input", () => put(f, card, t.value)); + return t; + } + function locked(f, card, lid) { + const box = h("div", {}); + const id = f.key + "#" + card; + const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); }; + if (UNLOCKED.has(id)) { open(); return box; } + const v = get(f, card); + box.append(h("div", { class: "row" }, + h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")), + h("span", { class: "chip" }, W.locked), + h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change))); + return box; + } + + /* entity picker: search, the matching kind first, the live reading */ + const ENT = {}; + function entities(kind) { + const now = Date.now(); + const e = ENT[kind]; + if (e && now - e.at < 30000) return e.p; + const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : { available: false, entities: [] })) + .catch(() => ({ available: false, entities: [] })); + ENT[kind] = { at: now, p }; + return p; + } + const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–"); + function picker(f, lid, value, pick) { + let data = null, all = false; + const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid }); + const pop = h("div", { class: "ent-pop", hidden: true }); + const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" }); + const note = h("div", { class: "tiny muted ent-note" }); + const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid }); + const allBox = h("label", { class: "tiny muted ent-all" }, + h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all); + function drawBtn() { + const id = value(); + const e = data && data.entities.find((x) => x.entity_id === id); + btn.replaceChildren(); + if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)), + data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null); + else btn.append(h("span", { class: "grow muted" }, W.picker.choose)); + btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾")); + } + function opt(id, name, val) { + return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()), + onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } }, + h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null); + } + function fill() { + const typed = q.value.trim(); + const t = typed.toLowerCase(); + const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200); + list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e)))); + if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, "")); + if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description)); + note.textContent = data && data.text ? data.text : ""; + note.hidden = !note.textContent; + } + function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); } + btn.addEventListener("click", () => { + if (!pop.hidden) { close(); return; } + pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill(); + setTimeout(() => q.focus(), 0); + }); + q.addEventListener("input", fill); + pop.addEventListener("keydown", (e) => { + if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; } + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const opts = [...list.querySelectorAll(".ent-opt")]; + const i = opts.indexOf(document.activeElement); + const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1; + if (j < 0) q.focus(); else if (opts[j]) opts[j].focus(); + }); + pop.append(q, note, list, allBox); + drawBtn(); + entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); }); + return h("div", { class: "ent" }, btn, pop, + f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null); + } + function entity(f, card, lid) { + return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v)); + } + function entityList(f, card, lid) { + const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []); + const box = h("div", { class: "elist" }); + arr().forEach((_id, i) => { + box.append(h("div", { class: "erow" }, + h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)), + picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }), + h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove))); + }); + box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row)); + return box; + } + function solarList(f, card, lid) { + const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []); + const box = h("div", { class: "elist" }); + arr().forEach((row, i) => { + const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed }); + flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); }); + box.append(h("div", { class: "erow" }, + picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }), + h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)), + h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)))); + }); + box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar)); + return box; + } + const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true), + select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList }; + + /* ---------------- rows, cards and groups ---------------- */ + function keyText(f, card) { + if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f); + return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key; + } + function fieldRow(f, card, gid) { + if (f.control === "inverter_list") return cardList(f, gid); + const lid = "sf" + (++uid); + const keys = keyText(f, card); + 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 }, + h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""), + keys ? h("div", { class: "key" }, keys) : null), + h("div", { class: "ctl" }, make(f, card, lid)), + f.help ? h("div", { class: "help" }, f.help) : null, msg); + ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() }); + return row; + } + function blankCard(f, first) { + const out = {}; + for (const s of f.fields) { + const k = sub(s); + out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : ""; + } + return out; + } + function cardList(f, gid) { + const wrap = h("div", { class: "invs", "data-key": f.key }); + const msg = h("div", { class: "msgs" }); + const cards = V[f.key]; + if (!Array.isArray(cards)) { + wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg); + } else { + cards.forEach((c, i) => { + const box = h("div", { class: "inv" }, h("div", { class: "hd" }, + h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"), + cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => { + V[f.key].splice(i, 1); V[f.count] = V[f.key].length; put(f, null, V[f.key], true); + } }, W.remove) : null)); + for (const s of f.fields) box.append(fieldRow(s, i, gid)); + wrap.append(box); + }); + wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => { + V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length; + put(f, null, V[f.key], true); + } }, "+ " + W.add_inverter)); + } + ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() }); + return wrap; + } + const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›"); + function render() { + const host = $("#groups"); + const y = window.scrollY; + host.replaceChildren(); + ROWS.length = 0; + for (const g of M.layout.groups) { + const d = h("details", { class: "grp", "data-g": g.id }, + h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)), + h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev())); + d.open = OPEN.has(g.id); + d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); }); + const body = h("div", { class: "body" }); + if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description)); + for (const p of g.parts) { + const pid = g.id + "." + p.id; + let box; + if (p.collapsed) { + box = h("details", { class: "sub part" }, h("summary", {}, p.name)); + box.open = OPEN.has(pid); + box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); }); + } else { + box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null); + } + if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description)); + for (const f of p.fields) box.append(fieldRow(f, null, g.id)); + body.append(box); + } + if (g.id === "maintenance") { + body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name), + h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description), + h("div", { class: "row" }, + h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start), + h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort)))); + } + d.append(body); + host.append(d); + } + paint(); + window.scrollTo(0, y); + } + + /* ---------------- messages, visibility, search, badges ---------------- */ + const ICON = { bad: "×", warn: "!", info: "i" }; + const RANK = { bad: 0, warn: 1, info: 2 }; + function mine(r, m) { + if (m.field !== r.f.key) return false; + if (r.list || r.card == null) return true; + return m.card == null ? r.card === 0 : m.card === r.card; + } + function paint() { + const q = $("#set-search").value.trim().toLowerCase(); + 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; }) + .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 }, + h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"), + h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text)))); + r.el.classList.toggle("has-bad", r.tiers.has("bad")); + // A card list counts as one change when cards were added or removed; + // an edit inside a card counts on its own row. + r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length) + : !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card)); + r.el.classList.toggle("changed", r.changed && !r.list); + } + // Search: a row shows when it is visible and matches. A card list shows + // while any of its rows does. + for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q))); + for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden); + let any = false; + document.querySelectorAll("#groups details.grp").forEach((d) => { + const rows = ROWS.filter((r) => r.gid === d.dataset.g); + d.querySelectorAll(".part").forEach((p) => { + const inside = rows.filter((r) => p.contains(r.el)); + if (inside.length) p.hidden = inside.every((r) => r.el.hidden); + }); + const hit = rows.some((r) => !r.el.hidden); + any = any || hit; + d.hidden = !!q && !hit; + if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); } + const shownRows = rows.filter((r) => r.visible && !r.list); + const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length; + const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length; + const changed = rows.filter((r) => r.changed).length; + d.querySelector(".gbadge").replaceChildren(...[ + bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null, + warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null, + changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean)); + }); + $("#set-nomatch").hidden = !q || any; + // A sentence shown beside any field is not repeated here. + const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text)); + const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i); + if (DEVICE) other.push({ tier: "info", text: DEVICE }); + $("#set-other").hidden = !other.length; + $("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") }, + h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text))); + savebar(); + } + function dirtyCount() { + const seen = new Set(); + for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card); + return seen.size; + } + function savebar() { + const n = V ? dirtyCount() : 0; + $("#savebar").hidden = !(active && n); + $("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many); + } + $("#set-search").addEventListener("input", () => { if (V) paint(); }); + + /* ---------------- talking to the add-on ---------------- */ + let vtimer = null, vseq = 0; + function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); } + async function validate() { + const seq = ++vseq; + try { + const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: V }) }); + const b = await r.json(); + if (seq !== vseq) return; + MSG = b.messages || []; + DEVICE = b.device_note || ""; + paint(); + } catch (e) { /* the next change tries again */ } + } + function banner(level, title, sub, button, onClick) { + const host = $("#set-banner"); + if (!title) { host.replaceChildren(); return; } + host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") }, + h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"), + h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null)))); + } + function restart() { + ask(W.dialog, async () => { + try { + await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "restart" }) }); + } catch (e) { /* the add-on stops answering while it restarts */ } + toast(W.toast.restarting.description); + }); + } + async function load(after) { + let m; + try { + const r = await fetch("config/model?lang=en", { cache: "no-store" }); + if (!r.ok) throw new Error("model " + r.status); + m = await r.json(); + if (!m.layout || !m.ui) throw new Error("no layout"); + } catch (e) { + $("#groups").replaceChildren(); + $("#set-search").hidden = true; + banner("bad", W.load_failed.name, W.load_failed.description); + return; + } + M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = ""; + $("#set-search").hidden = false; + FILLED = {}; + LIST = null; + for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) { + if (f.fills) FILLED[f.fills] = f; + if (f.control === "inverter_list") LIST = f; + } + if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id); + if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart); + else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart); + else banner(); + render(); + validate(); + } + function firstBad() { + const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible); + 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" }); + const input = r.el.querySelector("input,select,button"); + if (input) input.focus({ preventScroll: true }); + } + async function save() { + const btn = $("#sb-save"); + btn.disabled = true; btn.textContent = W.savebar.saving; + try { + clearTimeout(vtimer); + await validate(); + if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; } + const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: V, fingerprint: FP }) }); + const b = await r.json().catch(() => ({})); + if (r.ok) { toast(W.toast.saved); await load("saved"); return; } + if (r.status === 409 && b.kind === "stale") { + FP = b.fingerprint || FP; + const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; }); + banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : "")); + return; + } + if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; } + toast(W.toast.failed); + } catch (e) { toast(W.toast.failed); } + finally { btn.disabled = false; btn.textContent = W.savebar.save; } + } + $("#sb-save").addEventListener("click", save); + $("#sb-discard").addEventListener("click", () => { + V = clone(ORIG); UNLOCKED.clear(); render(); validate(); toast(W.toast.discarded); + if ($("#set-banner .b-bad")) banner(); + }); + addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } }); + document.addEventListener("ux13:tab", (e) => { + active = e.detail === "settings"; + if (active && !M) load(); else savebar(); + }); + // app.js chose the first tab before this file ran. + if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); } +})(); diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 6170326..dbb87a0 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -29,7 +29,12 @@ "choices": { "contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"}, "fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"}, - "region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"} + "region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"}, + "meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"}, + "meter_phases": {"1": "1 phase", "3": "3 phases"}, + "shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"}, + "price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"}, + "log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"} }, "applies": { "restart": {"name": "Applies after a restart"}, @@ -58,7 +63,8 @@ "estimate_tip": "estimate from yesterday", "none_tip": "No price known", "no_prices": {"description": "No prices for today, so the chart shows only the plan."}, - "list": "The plan in words" + "list": "The plan in words", + "average_tip": "hour average" }, "peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"}, "attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"}, @@ -74,7 +80,83 @@ "toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."}, "offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."}, "batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"}, - "settings": {"name": "Settings", "description": "The new settings screens arrive in a later update, so use the settings page for now.", "open": "Open the settings page"}, + "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}, "setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."} + }, + "choices_later": { + "region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"} + }, + "choice_help": { + "meter_source": { + "off": {"description": "A Home Assistant entity that reads grid power in W, positive when you take from the grid."}, + "ha_dsmr": {"description": "Two Home Assistant entities from the DSMR integration, one for import and one for export."}, + "mqtt_p1": {"description": "The meter's readings arrive over MQTT on a topic you name."}, + "ha_signed": {"description": "One Home Assistant entity that reads net grid power, positive for import."}, + "homewizard_local": {"description": "The add-on reads a HomeWizard meter on your network, and can spot a stuck meter."} + } + }, + "settings": { + "name": "Settings", + "description": "Every setting, grouped by task, and the simple view follows in a later update.", + "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."}, + "loading": "Loading the settings", + "load_failed": {"name": "Cannot read the settings", "description": "The add-on could not read its stored settings, so use Classic settings or the Configuration tab."}, + "footer": {"description": "Every change is checked by the add-on's own start checks before it is saved."}, + "to_fix": "to fix", + "to_check": "to check", + "changed": "changed", + "on": "On", + "off": "Off", + "not_set": "Not set", + "locked": "Locked", + "change": "Change", + "support": {"description": "These were measured on real hardware, so change them only when support asks."}, + "picker": { + "choose": "Choose an entity", + "search": {"description": "Search by name or entity id."}, + "all": "Show every entity", + "use": "Use this entity id", + "clear": "Clear", + "missing": "Not in Home Assistant", + "none": {"description": "Nothing matches, so check the entity in Developer Tools."} + }, + "add_inverter": "Add an inverter", + "inverter": "Inverter", + "remove": "Remove", + "add_row": "Add a row", + "add_solar": "Add a solar inverter", + "reversed": "Reads negative", + "phase": "Phase", + "raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."}, + "region_year": "{year} figures", + "hour": "{hour}:00 UTC", + "preset": {"description": "Filled in by the region above, so choose your own figure to change it."}, + "cycle": {"name": "Run a cycle by hand", "description": "The same actions as on Home, each asking first."}, + "savebar": {"label": "Unsaved changes", "one": "change not saved", "many": "changes not saved", "discard": "Discard", "save": "Save", "saving": "Saving"}, + "saved": {"name": "Saved, not running yet", "description": "The add-on runs the settings it started with until you restart it."}, + "differs": {"name": "Stored settings are not running", "description": "Some stored settings differ from what runs now, so restart the add-on to apply them."}, + "stale": {"name": "Changed somewhere else", "description": "These settings changed elsewhere while you edited, so save again to keep yours or discard to load theirs."}, + "other": "Other messages", + "restart": "Restart now", + "dialog": {"name": "Restart the add-on?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Restart now"}, + "toast": { + "saved": "Saved. Restart to apply.", + "discarded": "Changes discarded.", + "failed": "That did not work. Try again.", + "blocked": {"description": "Fix the settings marked in red, then save again."}, + "restarting": {"description": "Restart requested, so this page stops answering for a moment."} + }, + "tier": {"bad": "Must fix", "warn": "Check this", "info": "Note"}, + "derive": { + "raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."}, + "no_inverter": {"description": "Add at least one inverter."}, + "flat_named": {"description": "Add a second inverter before naming this one."}, + "no_source": {"description": "Choose where the dynamic prices come from."}, + "custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."}, + "no_price": {"description": "Enter a price per kWh."}, + "unknown": {"description": "The page cannot store this choice, so reload the page."} + } } } diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index 23fec4d..b0288e4 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -43,6 +43,8 @@ class Field: applies: str = RESTART simple: bool = False # also on Settings, Simple 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) @property def text(self) -> str: @@ -73,17 +75,20 @@ _MAINT = (("maintenance_enabled", "==", True),) _ONE = (("inverter_count", "==", 1),) _SEVERAL = (("inverter_count", ">", 1),) _PHASES = (("meter_phases", ">", 1),) +# One inverter stored flat has no name or capacity (uimodel._roster_encode). +_LISTED = (("inverter_shape", "!=", "flat"),) # The per-inverter card. Its keys are `inverters.` in config.yaml. ROSTER_FIELDS = ( - Field("inverters.name", TEXT, wizard=2), + Field("inverters.name", TEXT, show=_LISTED, wizard=2), Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2), Field("inverters.max_w", SLIDER, "W", step=100, wizard=2), Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2), Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2), Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2), Field("inverters.batt_invert", TOGGLE, wizard=2), - Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, wizard=2), + Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED, + wizard=2), ) GROUPS = ( @@ -123,7 +128,9 @@ GROUPS = ( )), Group("tariff", ( Part("", ( - Field("ui.contract", SEGMENTED, wizard=4), + # A contract without a feed is also the price used when a feed + # is down, so the fallback follows it (uimodel._tariff_encode). + Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"), Field("price_provider", SELECT, show=_DYNAMIC, wizard=4), Field("price_formula", TEXT, show=_DYNAMIC + (("price_provider", "==", "custom_formula"),), @@ -131,7 +138,8 @@ GROUPS = ( Field("price_csv_path", TEXT, show=_DYNAMIC + (("price_provider", "==", "manual_csv"),), wizard=4), - Field("ui.region", SELECT, wizard=4), + Field("ui.region", SELECT, wizard=4, + fills="price_taxes_eur_kwh"), Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001, wizard=4), Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001, diff --git a/goodwe_controller/app/uimodel.py b/goodwe_controller/app/uimodel.py index fddf36e..08523ed 100644 --- a/goodwe_controller/app/uimodel.py +++ b/goodwe_controller/app/uimodel.py @@ -21,11 +21,23 @@ from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH from .static_tariff import ProfileInvalid, parse_static_profile __all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS", - "arbitrage_from_batteries", "DeriveError"] + "arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"] class DeriveError(ValueError): - """The page model asks for something no options blob can say.""" + """The page model asks for something no options blob can say. `code` names + the sentence the page shows (app/ui_text `derive.`), and `field` the + page field it is shown beside.""" + + def __init__(self, text, code="", field=""): + super().__init__(text) + self.code, self.field = code, field + + +# The page model's inverter count, which show conditions read, and what adding +# a card sets: a second inverter turns the one flat inverter into a list. +COUNT = "inverter_count" +GROWN = {"inverter_shape": "list"} # --------------------------------------------------------------------------- # @@ -80,13 +92,16 @@ def _roster_encode(m, keep): out = {} shape, cards = m.get("inverter_shape"), m.get("inverters") if shape == "raw": - raise DeriveError("the inverter list is edited in the Configuration tab") + raise DeriveError("the inverter list is edited in the Configuration tab", + "raw_roster", "inverters") if not cards: - raise DeriveError("at least one inverter is needed") + raise DeriveError("at least one inverter is needed", "no_inverter", + "inverters") if shape == "flat" and len(cards) == 1: card = cards[0] if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")): - raise DeriveError("one inverter stored flat has no name or capacity") + raise DeriveError("one inverter stored flat has no name or " + "capacity", "flat_named", "inverters") for k in _ROSTER_FLAT: if card.get(k) is not None or k in keep: out[k] = card.get(k) @@ -96,8 +111,9 @@ def _roster_encode(m, keep): for c in cards] # The flat keys beside a list are not read: they stay as stored. out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep}) - if shape == "flat": - # CFG-04 rule 9e: a flat power request entity beside a list is refused. + if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat": + # CFG-04 rule 9e: a flat power request entity beside a list is refused, + # so a list that replaces the flat inverter clears it. out["setpoint_entity"] = "" return out @@ -210,7 +226,8 @@ def _tariff_encode(m, keep): contract = m.get("ui.contract") if contract == "dynamic": if _blank(m.get("price_provider")) or m.get("price_provider") == "off": - raise DeriveError("a dynamic contract needs a price source") + raise DeriveError("a dynamic contract needs a price source", + "no_source", "price_provider") out["price_provider"] = m["price_provider"] kind = m.get("ui.fallback") else: @@ -226,17 +243,19 @@ def _tariff_encode(m, keep): elif kind == "custom": if "static_profile" not in keep: raise DeriveError("odd tariff windows are edited in the " - "Configuration tab") + "Configuration tab", "custom_profile", + "ui.contract") out["static_profile"] = keep["static_profile"] else: - raise DeriveError(f"unknown contract {kind!r}") + raise DeriveError(f"unknown contract {kind!r}", "unknown", + "ui.contract") return out def _price_of(m, name): value = _num(m.get(name)) if value is None: - raise DeriveError(f"{name} needs a price") + raise DeriveError(f"{name} needs a price", "no_price", name) return value @@ -259,7 +278,7 @@ def _region_encode(m, keep): out["price_taxes_profile"] = keep["price_taxes_profile"] return out if region not in REGION_PRESETS: - raise DeriveError(f"unknown region {region!r}") + raise DeriveError(f"unknown region {region!r}", "unknown", "ui.region") p = REGION_PRESETS[region] out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]} if p["taxes_profile"] or "price_taxes_profile" in keep: diff --git a/goodwe_controller/app/uipage.py b/goodwe_controller/app/uipage.py new file mode 100644 index 0000000..9a84e4b --- /dev/null +++ b/goodwe_controller/app/uipage.py @@ -0,0 +1,275 @@ +"""UX-13 P2: the settings view's server half. It names no option key. + +It turns app/uilayout.py's table into the JSON the page draws from (labels +from the Supervisor's translations, ranges and choices from its schema, page +words from app/ui_text), turns a posted page model into options through +app/uimodel.py, and says beside which page field each validator sentence +goes. The rules stay in validate.py and the sentences are passed on as they +are (DECISIONS/UX-13-one-layout-table.md). +""" + +import copy +import io +import json +import os +import re + +from . import uilayout as L +from . import uimodel as U +from .configui import help_of, label_of, problems_by_field, split_group + +UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", + "en.json") +# A choice the page cannot make, only keep: odd tariff windows (P5 editor). +ONLY_IF_HELD = ("custom",) +_TIERS = ("bad", "warn", "info") + + +def ui_text() -> dict: + with io.open(UI_TEXT, encoding="utf-8") as f: + return json.load(f) + + +def _roster(): + """The inverter list field's key, read off the table.""" + return next(f.key for _g, _p, f in L.fields() + if f.control == L.INVERTER_LIST) + + +def _node(schema_ui, key): + """The Supervisor's schema entry for `key` (`list.sub` too), or {}.""" + nodes, path = schema_ui or [], key.split(".") + for step in path[:-1]: + parent = next((n for n in nodes if n.get("name") == step), None) + nodes = (parent or {}).get("schema") or [] + return next((n for n in nodes if n.get("name") == path[-1]), None) or {} + + +def _words(translations, lang, key, T): + """(label, help) for a field: ui_text for `ui.` fields, else the + translation without its `Group · ` prefix, and the raw key last.""" + if key.startswith("ui."): + entry = T["fields"].get(key[3:], {}) + return entry.get("name", key), entry.get("description", "") + path = tuple(key.split(".")[:-1]) + name = key.split(".")[-1] + label, _lang = label_of(translations, lang, name, path) + text, _lang = help_of(translations, lang, name, path) + return split_group(label)[1], text + + +def _choices(fld, node, T, value_type): + """[[value, label, only_if_held], ...] for a choice control, or None.""" + if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE): + return None + name = fld.key[3:] if fld.key.startswith("ui.") else fld.key + words = T["choices"].get(name) or {} + if fld.key.startswith("ui."): + values = list(words) + elif node.get("options"): + values = [v for v in node["options"] if not words or v in words] + elif value_type == "integer" and node.get("lengthMax") is not None: + values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1)) + if words: + values = [v for v in values if str(v) in words] + else: + return [] + out = [] + for v in values: + label = words.get(str(v)) + if label is None: + label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}") + if isinstance(v, int) else str(v)) + preset = U.REGION_PRESETS.get(v) if fld.fills else None + if preset: + label = f"{label}, " + T["settings"]["region_year"].replace( + "{year}", str(preset["year"])) + out.append([v, label, v in ONLY_IF_HELD]) + return out + + +def _field(fld, schema_ui, translations, lang, T): + node = _node(schema_ui, fld.key) + label, text = _words(translations, lang, fld.key, T) + value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.") + else node.get("type") or "") + out = {"key": fld.key, "control": fld.control, "label": label, + "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, + "type": value_type, "optional": not node.get("required", False), + "writes": sorted(k for k, how in L.DERIVED.items() + if how == fld.key)} + if node.get("lengthMin") is not None: + out["min"] = node["lengthMin"] + if node.get("lengthMax") is not None: + out["max"] = node["lengthMax"] + choices = _choices(fld, node, T, value_type) + if choices is not None: + out["choices"] = choices + name = fld.key[3:] if fld.key.startswith("ui.") else fld.key + later = T.get("choices_later", {}).get(name) + if later: + out["later"] = [[v, words] for v, words in later.items()] + hints = T.get("choice_help", {}).get(name) + if hints: + out["hints"] = {v: e.get("description", "") + for v, e in hints.items()} + if fld.mirror: + out["mirror"] = fld.mirror + if fld.fills: + out["fills"] = fld.fills + out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]} + for rid, p in U.REGION_PRESETS.items()} + roster = _roster() + if fld.key.startswith(roster + "."): + sub = fld.key[len(roster) + 1:] + if L.DERIVED.get(sub) == roster: + out["flat"] = sub # how the one flat inverter stores it + if fld.control == L.INVERTER_LIST: + out["fields"] = [_field(sub, schema_ui, translations, lang, T) + for sub in L.ROSTER_FIELDS] + out["count"] = U.COUNT + out["grow"] = dict(U.GROWN) + out["shape"] = next(iter(U.GROWN)) + return out + + +def layout(schema_ui, translations, lang="en") -> dict: + """Every group, part and field of the Installer view, as the page draws + it. Built from the table only: a field the table places is on the page.""" + T = ui_text() + groups = [] + for g in L.GROUPS: + parts = [] + for p in g.parts: + words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {} + parts.append({"id": p.id, "name": words.get("name", ""), + "description": words.get("description", ""), + "collapsed": p.collapsed, + "fields": [_field(f, schema_ui, translations, lang, T) + for f in p.fields]}) + words = T["groups"].get(g.id, {}) + applies = sorted({f.applies for p in g.parts for f in p.fields}) + groups.append({"id": g.id, "name": words.get("name", g.id), + "description": words.get("description", ""), + "support_only": g.support_only, + "applies": [T["applies"][a]["name"] for a in applies], + "parts": parts}) + return {"groups": groups} + + +# --------------------------------------------------------------------------- # +# The page model -> options, for the validator and the save +# --------------------------------------------------------------------------- # +def _same(a, b) -> bool: + """Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not.""" + if isinstance(a, bool) or isinstance(b, bool): + return type(a) is type(b) and a == b + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return a == b + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b)) + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) + return type(a) is type(b) and a == b + + +def to_options(model: dict, stored: dict | None = None) -> dict: + """The options blob for a posted page model. A value equal to the stored + one is written as stored, in the stored key order, so a save that changed + nothing writes an identical blob (the browser reads 3.0 as 3).""" + out = U.ui_to_options(model) + stored = stored or {} + for k, v in out.items(): + if k in stored and _same(v, stored[k]): + out[k] = copy.deepcopy(stored[k]) + return ({k: out[k] for k in stored if k in out} + | {k: v for k, v in out.items() if k not in stored}) + + +def derive_message(err: U.DeriveError) -> dict: + words = ui_text()["settings"]["derive"] + text = words.get(err.code, words["unknown"])["description"] + return {"field": err.field or None, "card": None, "tier": "bad", + "text": text} + + +# --------------------------------------------------------------------------- # +# Where a sentence goes on the page +# --------------------------------------------------------------------------- # +# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card. +_SUB = re.compile(r"(? list: + """[(page field, card)] for a sentence that names option `key`. `card` is + the inverter card's index, or None.""" + roster = _roster() + cards = model.get(roster) if isinstance(model.get(roster), list) else [] + names = [c.get("name") for c in cards if isinstance(c, dict)] + # messages.setting(): “Inverter rating” for inverter-b (inverters.max_w), + # or a roster rule's own `inverters[1] (inverter-b)`. + card = next((i for i, n in enumerate(names) + if n and f"for {n} ({roster}." in text), None) + index = re.search(re.escape(roster) + r"\[(\d+)\]", text) + if card is None and index and int(index.group(1)) < len(cards): + card = int(index.group(1)) + if key == roster: + subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card) + for r, i, s in _SUB.findall(text) if r == roster] + subs = [x for x in dict.fromkeys(subs) if L.field(x[0])] + return subs or [(roster, card)] + if key in L.DERIVED: + how = L.DERIVED[key] + if how == roster: + return [(f"{roster}.{key}", 0 if card is None else card)] + fld = L.field(how) + if fld is not None and not L.visible(fld, model) and _mirror_of(how): + return [(_mirror_of(how), None)] + return [(how, None)] + if L.field(key) is not None: + return [(key, None)] + return [(None, None)] + + +def anchors(problems, notes, model, keys) -> list: + """[{field, card, tier, text}] for the refusals and notes. `field` is + None for a sentence that names no setting on the page.""" + out, seen = [], set() + + def add(field, card, tier, text): + if (field, card, text) not in seen: + seen.add((field, card, text)) + out.append({"field": field, "card": card, "tier": tier, + "text": text}) + + by_key, unanchored = problems_by_field(problems, keys) + for key, texts in by_key.items(): + for text in texts: + for field, card in targets(key, text, model): + add(field, card, "bad", text) + for text in unanchored: + add(None, None, "bad", text) + for note in notes: + tier = note.tier if note.tier in _TIERS else "info" + for key in note.keys or (None,): + for field, card in (targets(key, note.text, model) if key + else [(None, None)]): + add(field, card, tier, note.text) + # A sentence placed on one inverter's field is not also put on the list. + roster = _roster() + placed = {m["text"] for m in out + if (m["field"] or "").startswith(roster + ".")} + return [m for m in out if not (m["field"] == roster + and m["text"] in placed)] + + +def fields_of(keys, model) -> list: + """The page fields that show these option keys (a stale save's list).""" + return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model) + if f)) diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index 648b7ca..e599ce0 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -37,12 +37,13 @@ import os from aiohttp import web -from . import messages, selfconfig +from . import messages, selfconfig, uipage from .haentities import EntityCache from .homeview import region_of from .validate import config_notes from .config_page import CONFIG_PAGE from .configui import changed_keys, field_model, problems_by_field +from .uimodel import DeriveError, options_to_ui _LOG = logging.getLogger("goodwe.web") @@ -65,13 +66,16 @@ def page() -> str: """The assembled index page. ` web.Application: async def config_index(_request): return web.Response(text=CONFIG_PAGE, content_type="text/html") - async def _proposed(request): + async def _proposed(request, stored=None): + """(body, options, model). UX-13's settings view posts its page + `model`, which app/uimodel.py turns into the options blob here, so the + validator and the Supervisor only ever see a real blob. The classic + page posts `options`. Raises DeriveError for a model no blob can say.""" try: body = await request.json() except ValueError: raise web.HTTPBadRequest(text="expected JSON") + model = body.get("model") + if isinstance(model, dict): + return body, uipage.to_options(model, stored), model options = body.get("options") if not isinstance(options, dict): raise web.HTTPBadRequest(text="expected an options object") - return body, options + return body, options, None async def config_model(request): """Everything the page needs to draw itself, from ONE Supervisor call. @@ -161,6 +172,10 @@ def build_app(controller, selfcfg=None) -> web.Application: # UX-13 P1: the levies preset and its year, for the P2 Settings # screens, which show the year beside the region choice. "region": region_of(options), + # UX-13 P2: the Installer view draws from these two. + "layout": uipage.layout(info.get("schema") or [], + info.get("translations") or {}, lang), + "ui": options_to_ui(options), }) return web.json_response(model) @@ -177,7 +192,12 @@ def build_app(controller, selfcfg=None) -> web.Application: product this card is delivering; anything this layer did to them would be a second opinion about a rule it does not own. """ - _body, options = await _proposed(request) + try: + _body, options, model = await _proposed(request) + except DeriveError as err: + return web.json_response({"problems": [], "by_key": {}, + "unanchored": [], "notes": [], + "messages": [uipage.derive_message(err)]}) notes = [] problems, unread = await selfconfig.problems_and_ranges( options, controller.hass, notes=notes) @@ -190,6 +210,9 @@ def build_app(controller, selfcfg=None) -> web.Application: "notes": [n._asdict() for n in notes]} if unread: payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread))) + if model is not None: + payload["messages"] = uipage.anchors(problems, notes, model, + _keys_of(options)) return web.json_response(payload) def _keys_of(options): @@ -202,7 +225,27 @@ def build_app(controller, selfcfg=None) -> web.Application: async def config_save(request): """Validate, compare-and-swap, write. Or refuse and write NOTHING.""" - body, options = await _proposed(request) + # The page model carries no stored values of the keys it shows, so the + # blob is settled against what is stored: an unchanged value is written + # exactly as stored (uipage.to_options). + try: + posted = await request.json() + except ValueError: + raise web.HTTPBadRequest(text="expected JSON") + stored_now = None + if isinstance(posted, dict) and isinstance(posted.get("model"), dict): + try: + stored_now = (await selfcfg.info()).get("options") or {} + except selfconfig.SupervisorUnavailable as err: + _LOG.error("saving options failed: %s", err) + return web.json_response({"error": str(err)}, status=502) + try: + body, options, model = await _proposed(request, stored_now) + except DeriveError as err: + return web.json_response( + {"kind": "invalid", "problems": [], "by_key": {}, + "unanchored": [], "messages": [uipage.derive_message(err)]}, + status=422) expect = str(body.get("fingerprint") or "") try: result = await selfconfig.save(selfcfg, options, expect, @@ -212,6 +255,9 @@ def build_app(controller, selfcfg=None) -> web.Application: stored = refused.stored or {} return web.json_response( {"kind": "stale", "changed": refused.changed, + "changed_fields": (uipage.fields_of(refused.changed, + model) + if model is not None else []), "stored": stored, # The page needs the CURRENT token to be able to save # again after the operator has read the difference. That @@ -221,9 +267,12 @@ def build_app(controller, selfcfg=None) -> web.Application: status=409) by_key, unanchored = problems_by_field(refused.problems, _keys_of(options)) - return web.json_response( - {"kind": "invalid", "problems": refused.problems, - "by_key": by_key, "unanchored": unanchored}, status=422) + payload = {"kind": "invalid", "problems": refused.problems, + "by_key": by_key, "unanchored": unanchored} + if model is not None: + payload["messages"] = uipage.anchors(refused.problems, [], + model, _keys_of(options)) + return web.json_response(payload, status=422) except selfconfig.SupervisorUnavailable as err: _LOG.error("saving options failed: %s", err) return web.json_response({"error": str(err)}, status=502) diff --git a/goodwe_controller/test_haentities.py b/goodwe_controller/test_haentities.py index 30ffb81..6db27e8 100644 --- a/goodwe_controller/test_haentities.py +++ b/goodwe_controller/test_haentities.py @@ -10,6 +10,7 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). import asyncio import json +import logging import sys import time @@ -96,9 +97,29 @@ for name, handler in (("502 while HA restarts", bad_gateway), ("a body that is not JSON", garbage), ("JSON that is not a list", not_a_list)): check(f"{name} -> None", asyncio.run(read_with(handler)) is None) +class _Lines(logging.Handler): + def __init__(self): + super().__init__() + self.lines = [] + + def emit(self, record): + if record.levelno >= logging.WARNING: + self.lines.append(record.getMessage()) + + +_lines = _Lines() +logging.getLogger().addHandler(_lines) check("an answer slower than the timeout -> None, at the timeout", asyncio.run(read_with(slow, timeout_s=0.3)) is None and read_with.took < 1.0) +# UX-13 P1 test finding (c): a timeout's own text is empty, which left +# "Reading the entity list failed: " with no reason. +check("...and its WARNING line gives the reason, not an empty one " + f"({_lines.lines})", any( + x == "Reading the entity list failed: no answer within 0.3 seconds." + for x in _lines.lines) + and not any(x.rstrip().endswith("failed:") for x in _lines.lines)) +logging.getLogger().removeHandler(_lines) check("no server at all -> None", asyncio.run(closed_port()) is None) check("the timeout for the list is the same 10 s as every other read", "STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read()) diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 40e21c2..1adf98f 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -321,11 +321,11 @@ check("the price bars are today's 24 hours, from the real series, with the " and s_arb["plan"]["prices"][19]["kind"] == "dear" and s_arb["plan"]["prices"][0]["kind"] == "mid" and s_arb["plan"]["prices"][13]["label"] == "13:00") -check("the price line names the price now, today's cheapest and dearest, and " +check("the price line names the price now, today's lowest and highest, and " "that tomorrow's prices are in", s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. " - "Today: cheapest €0.14 at 13:00, " - "dearest €0.36 at 19:00.") + "Today: lowest €0.14 at 13:00, " + "highest €0.36 at 19:00.") and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.") c_late = make(ARB) @@ -454,6 +454,59 @@ check("...and every red, amber and settling row reaches Needs attention with " + [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]] + [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]]) +# UX-13 P1 test finding (a): the page said "Held at 0 W" about 15 s before +# the inverter's power request reached 0 W. Until the last accepted request +# is 0 W it says "Going to 0 W". +c_al.inverters[0].last_write = 20.0 +s_go = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW) +c_al.inverters[0].last_write = 0.0 +s_at = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW) +check("an alarm while the last power request is still 20 W says 'Going to " + "0 W', and 'Held at 0 W' once 0 W was accepted", + s_go["plan"]["verdict"] == "Going to 0 W" + and s_go["batteries"][0]["now"] == "Going to 0 W until the alarm clears." + and s_go["plan"]["head"].startswith("Every inverter goes to 0 W") + and s_at["plan"]["verdict"] == "Held at 0 W" + and s_at["batteries"][0]["now"] == "Held at 0 W until the alarm clears.") +c_sp = make(TWO, auto=False) +c_sp.inverters[1].last_write = -1500.0 +s_sp = status(c_sp) +check("...and a stop does the same, per battery", + [b["now"] for b in s_sp["batteries"]] + == ["Held at 0 W while control is stopped.", + "Going to 0 W while control is stopped."]) + +# Finding (b): a quarter-hourly feed. The block's price and the price line +# name the same 15-minute point, and nothing says "the list". +_q = [] +for _k, _p in enumerate(MOCK): + for _m in range(4): + _q.append(PricePoint(DAY0 + timedelta(hours=_k, minutes=15 * _m), + round(_p + (0.0 if _m != 3 else -0.01), 4), False)) +_q += [PricePoint(p.start + timedelta(days=1), p.eur_per_kwh, False) + for p in list(_q)] +c_q = make(ARB) +c_q.tariff_plan = TariffPlan(PriceRead.series(tuple(_q), PriceProduct.DAY_AHEAD, + "generic_dayahead", NOW), None, + "provider") +c_q.tariff_at = NOW +c_q.peak_guard = quiet_guard() +s_q = status(c_q) +_grid = [r for r in s_q["plan"]["runs"] if r["kind"] == "grid"] +_lo = min(_q[:96], key=lambda p: p.eur_per_kwh) +check("quarter-hour prices: the charge block, its price and the price line's " + "lowest are the same 15-minute period", + _grid and _grid[0]["from"] == homeview.hhmm(_lo.start) + and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} " + "per kWh." + and f"Today: lowest €{_lo.eur_per_kwh:.2f} at " + f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"] + and "the list" not in json.dumps(s_q["plan"])) +check("...and the hour bar says it is an hour average", + s_q["plan"]["prices"][13]["points"] == 4 + and '"plan.average_tip"' in io.open(os.path.join( + HERE, "app", "static", "app.js"), encoding="utf-8").read()) + # --------------------------------------------------------------------------- # print("\n4. a fault in the Home view never costs the page its rows") # --------------------------------------------------------------------------- # diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index 33d3e72..f75653d 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -883,6 +883,8 @@ DATA = { "hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err", "hass.py:obj", "hass.py:raw", "hass.py:domain", "hass.py:service", "hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what", + # UX-13 P2: the entity list's wait, and an error's text or else its name + "hass.py:STATES_TIMEOUT_S", "hass.py:str(err) or type(err).__name__", # main.py "main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'", "main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH", diff --git a/goodwe_controller/test_settings.py b/goodwe_controller/test_settings.py new file mode 100644 index 0000000..c312adf --- /dev/null +++ b/goodwe_controller/test_settings.py @@ -0,0 +1,437 @@ +"""UX-13 P2: the Installer settings view. `python3 test_settings.py` + +The view is drawn from app/uilayout.py through app/uipage.py and saves the +page model through app/uimodel.py and the shipped validator. Part 1 checks +that every group and every mapped field reaches the page with its words, +range and choices. Part 2 checks the page script names no option key. Parts +3-5 drive the real routes over HTTP against a fake Supervisor: the save round +trip, where a refusal lands, and a stale save. Part 6 holds the view's words +to STYLE. The headless-browser click-through is in the PR, not in CI (the CI +image has no browser). +Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). +""" + +import asyncio +import copy +import glob +import io +import json +import logging +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.WARNING) # the validator's own log lines + +from aiohttp import ClientSession, web as aioweb # noqa: E402 + +import translations_lint as TL # noqa: E402 +import ux13_demo as D # noqa: E402 +from app import selfconfig, uilayout as L, uimodel as U, uipage, web # noqa: E402 +from app.validate import validate_startup_config # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +SCHEMA = D.schema_ui(D.CONFIG) +TRANSLATIONS = {"en": TL.parse_translation_file( + os.path.join(HERE, "translations", "en.yaml"))} +DEFAULTS = D.defaults(D.CONFIG) +SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint", + soc_entity="sensor.soc", batt_entity="sensor.batt") +TWO = dict(SITE, setpoint_entity="", inverters=[ + {"name": "inverter-a", "setpoint_entity": "number.a", "max_w": 2500, + "bms_soc_floor": 10}, + {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 2500, + "bms_soc_floor": 10}]) +UI_TEXT = uipage.ui_text() +W = UI_TEXT["settings"] + + +def browser(value): + """What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3.""" + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, list): + return [browser(v) for v in value] + if isinstance(value, dict): + return {k: browser(v) for k, v in value.items()} + return value + + +# --------------------------------------------------------------------------- # +print("1. every group and every mapped field reaches the page") +# --------------------------------------------------------------------------- # +LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS) +_flat = [] +for _g in LAYOUT["groups"]: + for _p in _g["parts"]: + for _f in _p["fields"]: + _flat.append((_g["id"], _f)) + _flat += [(_g["id"], _s) for _s in _f.get("fields", [])] +_keys = [f["key"] for _g, f in _flat] +check("the six groups, in the design's order", + [g["id"] for g in LAYOUT["groups"]] + == ["meter", "inverters", "tariff", "safety", "maintenance", "tuning"] + and [g["name"] for g in LAYOUT["groups"]] + == ["Meter", "Inverters and batteries", "Tariff and prices", + "Safety limits", "Maintenance", "Control tuning"]) +check(f"every field of the table is on the page once ({len(_keys)} fields)", + sorted(_keys) == sorted(f.key for _g, _p, f in L.fields()) + and len(_keys) == len(set(_keys))) +_counts = {g["id"]: sum(1 for gid, _f in _flat if gid == g["id"]) + for g in LAYOUT["groups"]} +print(f" fields per group: {_counts}") +check("every field has a label, and every option field's label is its " + "translation without the group prefix", + all(f["label"] and " · " not in f["label"] for _g, f in _flat) + and next(f for _g, f in _flat if f["key"] == "meter_source")["label"] + == "Where the meter reading comes from") +_ranged = [(f["key"], f.get("min"), f.get("max")) for _g, f in _flat + if f["control"] in (L.SLIDER, L.LOCKED)] +check(f"every slider and locked value has its range from the schema " + f"({len(_ranged)})", all(lo is not None and hi is not None + for _k, lo, hi in _ranged) + and ("peak_cap_w", 500.0, 15000.0) in _ranged) +check("the peak limit is a kW slider over a value stored in W", + next(f for _g, f in _flat if f["key"] == "peak_cap_w")["scale"] == 1000) +_choice = [f for _g, f in _flat + if f["control"] in (L.SEGMENTED, L.SELECT, L.CHOICE)] +check("every choice control has choices with words", + all(f["choices"] and all(c[1] for c in f["choices"]) for f in _choice)) +_ms = next(f for _g, f in _flat if f["key"] == "meter_source") +check("the meter source is radio cards with a line of help on each", + _ms["control"] == L.CHOICE and set(_ms["hints"]) == {c[0] for c in + _ms["choices"]}) +_reg = next(f for _g, f in _flat if f["key"] == "ui.region") +check("the region shows its preset year beside the choice (P0 review)", + ["flanders", "Flanders, Fluvius Antwerpen, 2026 figures", False] + in _reg["choices"] and _reg["presets"]["flanders"]["year"] == 2026) +check("...and says plainly that Wallonia and Brussels have no preset yet " + "(TARIFF-22)", _reg["later"] == [["wallonia", "Wallonia, no preset yet"], + ["brussels", "Brussels, no preset yet"]] + and "Wallonia or Brussels" in _reg["help"]) +_locked = sorted(f["key"] for _g, f in _flat if f["control"] == L.LOCKED) +check(f"measured and derived values are locked behind 'Change' " + f"({len(_locked)}): every control tuning value, every safety stop and " + "the peak reserve", + {f.key for p in L.GROUPS[-1].parts for f in p.fields} <= set(_locked) + and "sign_disagree_w" in _locked and "peak_soc_floor" in _locked + and LAYOUT["groups"][-1]["support_only"]) +check("each group says when its settings apply", + all(g["applies"] == [UI_TEXT["applies"]["restart"]["name"]] + for g in LAYOUT["groups"])) +_inv = next(f for _g, f in _flat if f["key"] == "inverters") +check("the inverter cards carry their count, and adding one makes a list", + _inv["count"] == U.COUNT and _inv["grow"] == U.GROWN + and [s["key"] for s in _inv["fields"]] + == [f.key for f in L.ROSTER_FIELDS]) +check("a page-only field names the option keys it writes, for the grey key " + "line", _reg["writes"] == ["price_taxes_profile"] + and next(f for _g, f in _flat if f["key"] == "ui.solar")["writes"] + == ["pv_entities", "pv_entity", "pv_invert_entities"]) +check("without a Supervisor schema the page still draws, with no ranges", + len(uipage.layout([], {})["groups"]) == 6) + +# --------------------------------------------------------------------------- # +print("\n2. the page script names no option key") +# --------------------------------------------------------------------------- # +JS = io.open(os.path.join(HERE, "app", "static", "settings.js"), + encoding="utf-8").read() +ORDER, _nested = TL.config_surface(D.CONFIG) +_leak = sorted(k for k in ORDER if re.search( + r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS)) +check(f"settings.js names no option key ({_leak})", not _leak) +check("data is set as text, never as HTML", + "innerHTML" not in JS and "insertAdjacentHTML" not in JS) +_fetch = sorted(set(re.findall(r'fetch\("([^"?]+)', JS))) +check(f"every request is relative, to the add-on's own routes ({_fetch})", + _fetch == ["config/model", "config/restart", "config/save", + "config/validate", "ha/entities"]) + + +def _resolve(path, node=W): + for k in path.split("."): + if not isinstance(node, dict) or k not in node: + return None + node = node[k] + return node + + +_used = {re.sub(r"\.(replace|join|length)$", "", k) for k in + re.findall(r'\bW\.([a-z_]+(?:\.[a-z_]+)*)', JS)} +_used |= set(re.findall(r'data-s(?:-ph|-label)?="([\w.]+)"', io.open( + os.path.join(HERE, "app", "static", "index.html"), encoding="utf-8").read())) +_miss = sorted(k for k in _used if _resolve(k) is None) +check(f"every word the view asks for is in app/ui_text ({len(_used)} keys, " + f"missing: {_miss})", len(_used) > 25 and not _miss) +check("search matches a field's label, its key and its help", + "(f.label + \" \" + keys + \" \" + f.key + \" \" + (f.help || \"\"))" + in JS) +check("a locked value shows no input until 'Change' is pressed", + re.search(r"function locked\(.*?\n }\n", JS, re.S) + and "UNLOCKED.has(id)) { open(); return box; }" in JS) +check("nothing is saved while the add-on's own checks refuse the model", + "if (MSG.some((m) => m.tier === \"bad\")) { toast(" in JS) +check("the old settings page stays reachable (until P5)", + 'href="config" id="set-classic"' in web.page()) + + +# --------------------------------------------------------------------------- # +# The real routes, over HTTP, against a fake Supervisor +# --------------------------------------------------------------------------- # +class Supervisor: + def __init__(self, options): + self.options = copy.deepcopy(options) + self.writes = [] + + async def info(self): + return {"options": copy.deepcopy(self.options), "schema": SCHEMA, + "translations": TRANSLATIONS} + + async def write_options(self, options): + self.writes.append(copy.deepcopy(options)) + self.options = copy.deepcopy(options) + + async def restart(self, confirm): + raise AssertionError("no restart here") + + +class Hass: + session = None + token = "" + + async def limits(self, _e): + return (-5000.0, 5000.0, 1.0) + + async def states(self): + return [] + + +class Controller: + def __init__(self, opts): + self.o, self.hass, self.inverters = dict(opts), Hass(), [] + + def log_event(self, text, hint=None): + pass + + def status_payload(self): + return {"banner": "", "level": "ok", "rows": [], "checks": []} + + async def handle_action(self, what): + raise AssertionError(f"the settings view reached {what!r}") + + +async def session(stored, steps): + """Run `steps(s, root)` against the app over a fake Supervisor.""" + sup = Supervisor(stored) + runner = aioweb.AppRunner(web.build_app(Controller(stored), sup)) + await runner.setup() + site = aioweb.TCPSite(runner, "127.0.0.1", 0) + await site.start() + root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/" + try: + async with ClientSession() as s: + out = await steps(s, root, sup) + finally: + await runner.cleanup() + return out, sup + + +async def _get(s, url): + async with s.get(url) as r: + return await r.json() + + +async def _post(s, url, body): + async with s.post(url, json=body) as r: + return r.status, await r.json() + + +# --------------------------------------------------------------------------- # +print("\n3. the save round trip") +# --------------------------------------------------------------------------- # +check("the fixture site starts (so a refusal below is the edit's)", + not validate_startup_config(TWO) and not validate_startup_config(SITE)) + + +async def _round(s, root, sup): + out = {} + m = await _get(s, root + "config/model?lang=en") + out["model"] = m + model = browser(m["ui"]) + out["same"] = await _post(s, root + "config/save", + {"model": model, "fingerprint": m["fingerprint"]}) + out["after_same"] = copy.deepcopy(sup.options) + m = await _get(s, root + "config/model?lang=en") + model = browser(m["ui"]) + model["peak_cap_w"] = 4200 + model["inverters"][1]["max_w"] = 2200 + out["edit"] = await _post(s, root + "config/save", + {"model": model, "fingerprint": m["fingerprint"]}) + return out + + +_stored = dict(TWO, gain=0.5, target_grid_w=-10.0, peak_cap_w=3500) +_r, _sup = asyncio.run(session(_stored, _round)) +check("config/model carries the layout and the page model", + _r["model"]["layout"] == json.loads(json.dumps(uipage.layout( + SCHEMA, TRANSLATIONS))) and _r["model"]["ui"]["inverter_count"] == 2) +check("a save that changed nothing writes an identical blob: same values, " + "types and key order, although the browser turned -10.0 into -10", + _r["same"][0] == 200 and json.dumps(_r["after_same"]) + == json.dumps(_stored) and isinstance(_r["after_same"]["target_grid_w"], + float)) +_written = _sup.writes[-1] +print(f" the edit: {_r['edit']}, changed " + f"{sorted(k for k in _stored if _written[k] != _stored[k])}") +check("an edit writes exactly the fields edited, the peak limit in W", + _r["edit"][0] == 200 + and sorted(k for k in _stored if _written[k] != _stored[k]) + == ["inverters", "peak_cap_w"] and _written["peak_cap_w"] == 4200 + and _written["inverters"][1]["max_w"] == 2200 + and _written["inverters"][0] == _stored["inverters"][0]) +_blobs = [("config.yaml", DEFAULTS), ("site", SITE), ("two", TWO)] + [ + (os.path.basename(f), json.load(open(f))) for f in sorted(glob.glob( + os.path.join(HERE, "scenarios", "ux13", "test23", "*.json")))] +_bad = [n for n, o in _blobs if json.dumps(uipage.to_options(browser( + json.loads(json.dumps(U.options_to_ui(o)))), o)) != json.dumps(o)] +check(f"every known blob comes back byte for byte through the browser " + f"({len(_blobs)} blobs, not: {_bad})", not _bad and len(_blobs) >= 8) +_grown = U.options_to_ui(SITE) +_grown["inverters"].append(dict(_grown["inverters"][0], name="b", + setpoint_entity="number.b")) +_grown["inverters"][0]["name"] = "a" +_grown.update(U.GROWN) +_g = U.ui_to_options(_grown) +check("'Add an inverter' on a flat site writes a list and clears the flat " + "power request entity, which the validator refuses beside a list", + [e["name"] for e in _g["inverters"]] == ["a", "b"] + and _g["setpoint_entity"] == "" and not validate_startup_config(_g)) + +# --------------------------------------------------------------------------- # +print("\n4. a refusal lands beside the field it is about") +# --------------------------------------------------------------------------- # + + +async def _refuse(s, root, sup): + out = {} + m = await _get(s, root + "config/model?lang=en") + base = m["ui"] + + async def validate(**edits): + model = copy.deepcopy(base) + for k, v in edits.items(): + if k == "card": + model["inverters"][v[0]][v[1]] = v[2] + else: + model[k.replace("__", ".")] = v + return (await _post(s, root + "config/validate", {"model": model}))[1] + + out["card"] = await validate(card=(1, "setpoint_entity", "")) + out["same"] = await validate(card=(1, "setpoint_entity", "number.a")) + out["floor"] = await validate(peak_soc_floor=5) + out["derive"] = await validate(ui__contract="dynamic", price_provider="") + model = copy.deepcopy(base) + model["inverters"][1]["setpoint_entity"] = "" + out["save"] = await _post(s, root + "config/save", + {"model": model, "fingerprint": m["fingerprint"]}) + return out + + +_r, _sup = asyncio.run(session(TWO, _refuse)) + + +def _at(payload, tier="bad"): + return {(m["field"], m["card"]) for m in payload["messages"] + if m["tier"] == tier} + + +print(f" blank power request entity on card 2: {_r['card']['messages']}") +check("a blank power request entity on the second card is refused beside " + "that card's field, and not beside the list as well", + ("inverters.setpoint_entity", 1) in _at(_r["card"]) + and ("inverters", None) not in _at(_r["card"])) +print(f" two cards on one entity: {_r['same']['messages']}") +check("two cards on one entity are refused beside that field on both cards", + {("inverters.setpoint_entity", 0), ("inverters.setpoint_entity", 1)} + == _at(_r["same"])) +check("a peak reserve under the cut-out level lands on the peak reserve and " + "on each card's cut-out level", + {("peak_soc_floor", None), ("inverters.bms_soc_floor", 0), + ("inverters.bms_soc_floor", 1)} <= _at(_r["floor"])) +check("a model no blob can say is refused beside its field, in plain words", + _r["derive"]["messages"] == [{ + "field": "price_provider", "card": None, "tier": "bad", + "text": W["derive"]["no_source"]["description"]}]) +check("a refused save answers 422 with the same placement and writes nothing", + _r["save"][0] == 422 and ("inverters.setpoint_entity", 1) + in _at(_r["save"][1]) and _sup.writes == []) +_fixed = U.options_to_ui(dict(TWO, static_profile="not json")) +check("a sentence about a key the page derives goes to the field that writes " + "it, or to the visible choice that sets it", + uipage.targets("static_profile", "", _fixed) == [("ui.contract", None)] + and uipage.targets("max_w", "", U.options_to_ui(SITE)) + == [("inverters.max_w", 0)] + and uipage.targets("pv_entity", "", _fixed) == [("ui.solar", None)]) +check("a sentence naming no setting on the page is kept, with no field", + uipage.anchors(["Something odd."], [], _fixed, ["gain"]) + == [{"field": None, "card": None, "tier": "bad", + "text": "Something odd."}]) + +# --------------------------------------------------------------------------- # +print("\n5. a stale save names the fields that changed elsewhere") +# --------------------------------------------------------------------------- # + + +async def _stale(s, root, sup): + m = await _get(s, root + "config/model?lang=en") + sup.options["peak_cap_w"] = 5000 # the Configuration tab saves + model = copy.deepcopy(m["ui"]) + model["gain"] = 0.4 + return await _post(s, root + "config/save", + {"model": model, "fingerprint": m["fingerprint"]}) + + +_r, _sup = asyncio.run(session(TWO, _stale)) +check("409 with the page fields to look at, and nothing written", + _r[0] == 409 and _r[1]["kind"] == "stale" + and "peak_cap_w" in _r[1]["changed_fields"] and _sup.writes == [] + and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options)) + +# --------------------------------------------------------------------------- # +print("\n6. the view's words keep to STYLE") +# --------------------------------------------------------------------------- # + + +def _texts(node, path=""): + for k, v in node.items(): + if isinstance(v, dict): + yield from _texts(v, f"{path}{k}.") + elif isinstance(v, str): + yield f"{path}{k}", v + + +_long = [(p, t) for p, t in _texts(W) + if len(t.split()) > (25 if p.endswith("description") else 6) + or (p.endswith("description") and len(re.findall(r"[.!?](?:\s|$)", t)) > 1) + or ";" in t] +check(f"every settings string is one sentence of at most 25 words, or a " + f"name of at most 6 ({_long})", not _long) +_labels = [c[1] for _g, f in _flat for c in f.get("choices", [])] +check("every choice, including the region with its year, is at most 6 words", + all(len(x.split()) <= 6 for x in _labels)) + +print() +if fails: + print(f"{len(fails)} FAILED") + sys.exit(1) +print("all passed") diff --git a/goodwe_controller/test_uilayout.py b/goodwe_controller/test_uilayout.py index 5d04ec5..70c4bdb 100644 --- a/goodwe_controller/test_uilayout.py +++ b/goodwe_controller/test_uilayout.py @@ -82,8 +82,9 @@ check("every control, entity kind and show operator is a known one", all(f.control in L.CONTROLS and (not f.kind or f.kind in L.ENTITY_KINDS) and all(op in L.SHOW_OPS for _n, op, _v in f.show) for _g, _p2, f in ROWS)) -_names = set(ORDER) | set(KEYS) | {"inverter_count"} -check("every show condition reads an option, a page field or inverter_count", +_names = set(ORDER) | set(KEYS) | {uimodel.COUNT} | set(uimodel.GROWN) +check("every show condition reads an option, a page field, or the inverter " + "count or shape", all(n in _names for _g, _p2, f in ROWS for n, _o, _v in f.show)) check("every entity control has a kind, and no other control has one", all((f.control in (L.ENTITY, L.ENTITY_LIST, L.SOLAR_LIST)) == bool(f.kind) @@ -189,11 +190,13 @@ _owned = set(L.DERIVED) | {"inverters", "price_provider", _extra = sorted({k for k in _KEY.findall(_src) if k in ORDER} - _owned) check(f"uimodel.py names only the keys its derivations write (others: " f"{_extra})", not _extra) -_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py") +_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py", + "uipage.py") for k in _KEY.findall(io.open(os.path.join(HERE, "app", name), encoding="utf-8").read()) if k in ORDER}) -check(f"haentities.py and web.py name no option key ({_leak})", not _leak) +check(f"haentities.py, web.py and uipage.py name no option key ({_leak})", + not _leak) print() if fails: diff --git a/goodwe_controller/tools/ux13_demo.py b/goodwe_controller/tools/ux13_demo.py index 5b9067b..028f4c3 100644 --- a/goodwe_controller/tools/ux13_demo.py +++ b/goodwe_controller/tools/ux13_demo.py @@ -1,19 +1,29 @@ -"""UX-13 P1: serve the real page and routes over a fixture controller, for a -headless browser check. `python3 tools/ux13_demo.py STATE PORT` +"""UX-13: serve the real page and routes over a fixture controller, for a +headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]` STATE is running, shadow or alarm. 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 (no JS errors, tooltips, phone width, dark mode) described in the PR. + +P2: the fixture Supervisor serves config.yaml's defaults with this site's +options, a schema read off config.yaml the way the Supervisor renders it +(`schema_ui()`) and translations/en.yaml, and keeps what the settings view +saves in memory, so the Installer view can be clicked through end to end. """ import asyncio +import io +import json import os +import re import sys from datetime import datetime, timedelta, timezone -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) from aiohttp import web as aioweb # noqa: E402 @@ -22,6 +32,7 @@ from app.main import Controller # noqa: E402 from app.peakguard import PeakDecision # noqa: E402 from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402 from app.static_tariff import TariffPlan # noqa: E402 +import translations_lint as TL # noqa: E402 PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24] @@ -73,12 +84,119 @@ class Hass: return True async def states(self): - return [] + return STATES + + +def _state(eid, name, state, unit=None, cls=None): + return {"entity_id": eid, "state": state, + "attributes": {"friendly_name": name, "unit_of_measurement": unit, + "device_class": cls}} + + +STATES = [ + _state("sensor.grid", "Grid power", "-10", "W", "power"), + _state("sensor.grid_kw", "Grid power in kW", "-0.01", "kW", "power"), + _state("sensor.pv", "Solar power", "3900", "W", "power"), + _state("sensor.soc_a", "Battery A level", "62", "%", "battery"), + _state("sensor.soc_b", "Battery B level", "35", "%", "battery"), + _state("sensor.batt_a", "Battery A power", "-2500", "W", "power"), + _state("sensor.batt_b", "Battery B power", "-300", "W", "power"), + _state("number.a", "Battery A power request", "0", "W"), + _state("number.b", "Battery B power request", "0", "W"), + _state("sensor.peak_forecast", "Quarter-hour peak forecast", "1240", "W", + "power"), +] + + +# --- config.yaml as the Supervisor renders it (App.schema_ui) --------------- # +def _node(name, spec): + spec = spec.strip() + optional = spec.endswith("?") + spec = spec.rstrip("?") + out = {"name": name, "required": not optional, "optional": optional} + m = re.match(r"(int|float)\(([^,]*),([^)]*)\)$", spec) + if m: + out.update(type="integer" if m.group(1) == "int" else "float", + lengthMin=float(m.group(2)), lengthMax=float(m.group(3))) + elif spec.startswith("list("): + out.update(type="select", options=spec[5:-1].split("|")) + elif spec in ("bool",): + out["type"] = "boolean" + elif spec in ("int", "float"): + out["type"] = "integer" if spec == "int" else "float" + else: + out["type"] = "string" + return out + + +def schema_ui(text): + """config.yaml's `schema:` block in the Supervisor's schema_ui shape: the + fixture for tests and this demo, which cannot run the Supervisor.""" + block = text[text.index("\nschema:"):].splitlines()[1:] + out, i = [], 0 + while i < len(block): + line = block[i] + m = re.match(r"^ ([a-z_0-9]+):\s*(\S.*?)?\s*$", line) + i += 1 + if not m: + continue + name, spec = m.group(1), m.group(2) + if spec: + out.append(_node(name, spec)) + continue + items = [] + while i < len(block) and (block[i].startswith(" ") or not block[i].strip()): + if block[i].strip() and not block[i].strip().startswith("#"): + items.append(block[i].strip()) + i += 1 + if items and items[0].startswith("- ") and ":" in items[0]: + fields = [items[0][2:]] + items[1:] + out.append({"name": name, "type": "schema", "optional": True, + "multiple": True, + "schema": [_node(*f.split(":", 1)) for f in fields]}) + else: + node = _node(name, items[0][2:] if items else "str") + node.update(multiple=True, required=False, optional=True) + out.append(node) + return out + + +def defaults(text): + """config.yaml's `options:` block (as test_uimodel.py reads it).""" + block = text[text.index("\noptions:"):text.index("\nschema:")] + out = {} + for key, raw in re.findall(r"^ ([a-z_0-9]+):(.*)$", block, re.M): + raw = raw.split(" #")[0].strip() + try: + out[key] = json.loads(raw) + except ValueError: + out[key] = raw + return out + + +CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read() class Supervisor: + """The stored options, kept in memory: a save here writes nothing else.""" + + def __init__(self, options): + self.options = options + self.writes = 0 + async def info(self): - return {"options": {}, "schema": [], "translations": {}} + return {"options": json.loads(json.dumps(self.options)), + "schema": schema_ui(CONFIG), + "translations": {"en": TL.parse_translation_file( + os.path.join(HERE, "translations", "en.yaml"))}} + + async def write_options(self, options): + self.options = json.loads(json.dumps(options)) + self.writes += 1 + print(f"options saved ({self.writes})", flush=True) + + async def restart(self, confirm): + print(f"restart asked ({confirm!r})", flush=True) OPTS = {"meter_entity": "sensor.grid", "meter_source": "off", @@ -119,13 +237,21 @@ def controller(state): return c -async def main(state, port): - runner = aioweb.AppRunner(web.build_app(controller(state), Supervisor())) +async def main(state, port, host="127.0.0.1"): + c = controller(state) + stored = dict(defaults(CONFIG), **OPTS) + stored.update(setpoint_entity="", meter_source="ha_signed", + p1_net_entity="sensor.grid", price_provider="generic_dayahead", + peak_forecast_entity="sensor.peak_forecast", + shadow_mode=c.o.get("shadow_mode", "off"), + inverters=[dict(i, bms_soc_floor=10) + for i in OPTS["inverters"]]) + runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored))) await runner.setup() - await aioweb.TCPSite(runner, "127.0.0.1", port).start() + await aioweb.TCPSite(runner, host, port).start() print(f"serving {state} on http://127.0.0.1:{port}/", flush=True) await asyncio.Event().wait() if __name__ == "__main__": - asyncio.run(main(sys.argv[1], int(sys.argv[2]))) + asyncio.run(main(sys.argv[1], int(sys.argv[2]), *sys.argv[3:4]))