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 @@