Merge pull request 'UX-13 P2: the Installer settings view' (#145) from UX-13-p2-installer into release/1.0
infra-pin / fresh (push) Successful in 1s

Merge UX-13 P2: the installer settings view (#145)
This commit was merged in pull request #145.
This commit is contained in:
2026-09-28 03:54:23 +02:00
18 changed files with 1921 additions and 96 deletions
@@ -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
+9 -2
View File
@@ -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)
+70 -30
View File
@@ -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,15 @@ def build_plan(c, now, shadow, running, held=False):
# --------------------------------------------------------------------------- #
# Batteries, attention, month peak, control
# --------------------------------------------------------------------------- #
def at_zero(inv) -> bool:
"""Has this inverter accepted a 0 W power request? 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). Before the first
accepted write nothing is known to be held; the heartbeat writes every
`heartbeat_s`, stopped or not, so this does not stick."""
return inv.last_write is not None and abs(inv.last_write) < 1.0
def battery_rows(c, shadow, running, held=False):
shares = {}
alloc = getattr(c, "allocation", None)
@@ -521,7 +541,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 +654,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 +697,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 +753,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 +791,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 +815,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 +831,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 +913,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 +957,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 +971,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 +979,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:
+81
View File
@@ -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}
+11 -10
View File
@@ -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));
+19 -6
View File
@@ -96,16 +96,23 @@
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="config" data-t="batteries.settings"></a>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="card">
<h2 data-t="settings.name"></h2>
<p class="small muted" data-t="settings.description"></p>
<p class="small" id="set-region" hidden></p>
<a class="btn primary" href="config" data-t="settings.open"></a>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
<a class="small" href="config" id="set-classic" data-s="classic"></a>
</div>
<p class="small muted" style="margin:0 0 10px" data-s="description"></p>
<div id="set-banner" aria-live="polite"></div>
<div id="set-body">
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
<div id="groups"><p class="small muted" data-s="loading"></p></div>
<p class="small muted" id="set-nomatch" hidden data-s="no_match.description"></p>
<p class="tiny muted" data-s="footer.description"></p>
</div>
</section>
@@ -126,10 +133,16 @@
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="savebar" role="region" hidden><div class="in">
<span class="grow" id="sb-text" aria-live="polite"></span>
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
</div></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
<script>/*SETTINGS_JS*/</script>
</body>
</html>
+561
View File
@@ -0,0 +1,561 @@
"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 ---------------- */
// A choice the page can only keep stays offered while it is the stored value.
function choicesOf(f, card) {
const cur = get(f, card), was = valueIn(ORIG, f, card);
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
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, card);
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, card)) 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;
// Back to one card, the stored shape returns: a flat inverter stays flat.
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
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 = "";
UNLOCKED.clear();
$("#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);
// Discard reloads what is stored: after a stale save ORIG is no longer it.
$("#sb-discard").addEventListener("click", async () => {
await load(); toast(W.toast.discarded);
});
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(); }
})();
+85 -3
View File
@@ -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."}
}
}
}
+12 -4
View File
@@ -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.<field>` 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,
+31 -12
View File
@@ -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.<code>`), 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:
+280
View File
@@ -0,0 +1,280 @@
"""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)
if fld.unit:
# The page shows the unit beside the control, in its own scale (kW),
# so the translation's "(W)" goes; "(optional)" stays.
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
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),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()
if how in (fld.key, fld.mirror))}
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"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
def _mirror_of(key):
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
def targets(key: str, text: str, model: dict) -> 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))
+61 -12
View File
@@ -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. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
script = _read(os.path.join(STATIC, "app.js"))
if "</script" in script.lower():
raise ValueError("app.js may not contain a closing script tag")
scripts = {}
for name in ("app.js", "settings.js"):
scripts[name] = _read(os.path.join(STATIC, name))
if "</script" in scripts[name].lower():
raise ValueError(f"{name} may not contain a closing script tag")
return (_read(os.path.join(STATIC, "index.html"))
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
.replace("/*APP_JS*/", script))
.replace("/*APP_JS*/", scripts["app.js"])
.replace("/*SETTINGS_JS*/", scripts["settings.js"]))
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
@@ -121,15 +125,22 @@ def build_app(controller, selfcfg=None) -> 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)
+21
View File
@@ -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())
+64 -4
View File
@@ -123,11 +123,13 @@ MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
auto=True):
auto=True, wrote=0.0):
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt = soc[i], batt[i]
inv.last_write = wrote
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.grid = grid
return c
@@ -321,11 +323,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 +456,64 @@ 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)
s_new = status(make(TWO, auto=False, wrote=None))
check("...and before any power request was accepted (just started, or every "
"write refused) it says 'Going to 0 W', not 'Held'",
[b["now"] for b in s_new["batteries"]]
== ["Going to 0 W while control is stopped."] * 2)
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")
# --------------------------------------------------------------------------- #
+2
View File
@@ -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",
+464
View File
@@ -0,0 +1,464 @@
"""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
from named_absence import field # 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]
FIELD = {f["key"]: f 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 field(FIELD, "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, and its label "
"does not say W (the unit is shown beside the control)",
field(FIELD, "peak_cap_w")["scale"] == 1000
and field(FIELD, "peak_cap_w")["label"] == "Peak limit"
and not [f["key"] for _g, f in _flat
if f["unit"] and re.search(r"\([^()\s]{1,6}\)", f["label"])])
_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 = field(FIELD, "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 = field(FIELD, "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 = field(FIELD, "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 field(FIELD, "ui.solar")["writes"]
== ["pv_entities", "pv_entity", "pv_invert_entities"]
and "static_profile" in field(FIELD, "ui.contract")["writes"])
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("Discard reloads the stored settings (after a stale save the page's "
"copy is no longer what is stored)",
re.search(r'"#sb-discard"\)\.addEventListener\("click", async \(\) => '
r'\{\s*await load\(\);', JS) is not None)
check("a load locks every locked value again, and a choice the page can only "
"keep stays offered while it is the stored value",
"UNLOCKED.clear();" in JS.split("async function load")[1].split("render();")[0]
and "same(c[0], was)" in JS)
check("removing cards back to one restores the stored shape (a flat inverter "
"is not turned into a list of one)",
"if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);"
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.update({"ui.contract": "dynamic", "price_provider": ""})
out["derive_save"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"]})
out["derive_writes"] = len(sup.writes)
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 save no blob can say answers 422 with its sentence and writes nothing",
_r["derive_save"][0] == 422 and _r["derive_writes"] == 0
and _r["derive_save"][1]["messages"] == _r["derive"]["messages"])
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")
+7 -4
View File
@@ -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:
+137 -9
View File
@@ -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",
@@ -112,6 +230,8 @@ def controller(state):
for inv, soc, batt in zip(c.inverters, (62.0, 35.0), (-2500.0, -300.0)):
inv.soc, inv.batt = soc, (0.0 if state == "shadow" else batt)
inv.dev_min, inv.dev_max = -5000.0, 5000.0
if state != "running": # 0 W accepted: Home says "Held"
inv.last_write = 0.0
c.grid = -10.0 if state != "shadow" else -2810.0
c.pv_w = 3900.0
if state == "alarm":
@@ -119,13 +239,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]))