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
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:
@@ -60,6 +60,12 @@ What UX-02 made authoritative stays authoritative:
|
|||||||
new validator. At P0: 261 calls, 158 refused, no refusal changed.
|
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
|
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
|
||||||
into `app/static/` and the same check applies there.
|
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
|
## 4. What this costs
|
||||||
|
|
||||||
|
|||||||
@@ -132,8 +132,15 @@ class HomeAssistant:
|
|||||||
except aiohttp.ClientConnectionError as err:
|
except aiohttp.ClientConnectionError as err:
|
||||||
OUTAGE.down("api", "no connection", f"read all states: {err}")
|
OUTAGE.down("api", "no connection", f"read all states: {err}")
|
||||||
return None
|
return None
|
||||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
# A timeout's own text is empty, so the line names the wait instead
|
||||||
self._moan("read:*", "Reading the entity list failed: %s", err)
|
# (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
|
return None
|
||||||
if not isinstance(obj, list):
|
if not isinstance(obj, list):
|
||||||
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
|
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def flow(w, *, shadow=False):
|
|||||||
return "resting"
|
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
|
"""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."""
|
reading it is None rather than a number that leaves the sun out."""
|
||||||
house, disagree = None, False
|
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),
|
"solar": tile_solar(pv_w, has_solar),
|
||||||
"house": tile_house(house, has_solar, disagree),
|
"house": tile_house(house, has_solar, disagree),
|
||||||
"grid": tile_grid(grid_w),
|
"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)
|
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
|
||||||
if inside else None)
|
if inside else None)
|
||||||
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
|
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),
|
"eur_kwh": None if price is None else round(price, 4),
|
||||||
"estimate": bool(inside) and any(p.provisional
|
"estimate": bool(inside) and any(p.provisional
|
||||||
for p in inside),
|
for p in inside),
|
||||||
@@ -196,6 +196,19 @@ def hour_bars(points, day0, day1):
|
|||||||
return bars
|
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
|
# The plan, per battery
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -407,7 +420,7 @@ def region_of(opts):
|
|||||||
if rid in REGION_PRESETS else None}
|
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)
|
day0, day1 = local_day(now)
|
||||||
fresh = c.fresh_tariff_plan(now)
|
fresh = c.fresh_tariff_plan(now)
|
||||||
plan = fresh
|
plan = fresh
|
||||||
@@ -477,17 +490,15 @@ def build_plan(c, now, shadow, running, held=False):
|
|||||||
"day": brussels(now).date().isoformat(),
|
"day": brussels(now).date().isoformat(),
|
||||||
"now_h": round(_hours(now, day0), 3),
|
"now_h": round(_hours(now, day0), 3),
|
||||||
"hours": int(round(_hours(day1, day0))),
|
"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,
|
"verdict_key": verdict_key,
|
||||||
"head": plan_head(verdict_key, shadow),
|
"head": plan_head(verdict_key, shadow, settled),
|
||||||
"prices": bars,
|
"prices": bars,
|
||||||
"runs": runs,
|
"runs": runs,
|
||||||
"lines": lines,
|
"lines": lines,
|
||||||
"why": c.why_words,
|
"why": c.why_words,
|
||||||
"price_line": line_prices(
|
"price_line": line_prices(price_now, *day_range(seen)),
|
||||||
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),
|
|
||||||
"tomorrow_line": line_tomorrow(tomorrow) if feed else "",
|
"tomorrow_line": line_tomorrow(tomorrow) if feed else "",
|
||||||
"region": region,
|
"region": region,
|
||||||
"region_line": (line_region(region["id"], region["year"])
|
"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
|
# 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):
|
def battery_rows(c, shadow, running, held=False):
|
||||||
shares = {}
|
shares = {}
|
||||||
alloc = getattr(c, "allocation", None)
|
alloc = getattr(c, "allocation", None)
|
||||||
@@ -521,7 +541,8 @@ def battery_rows(c, shadow, running, held=False):
|
|||||||
"maintenance": phase,
|
"maintenance": phase,
|
||||||
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
|
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
|
||||||
running=running, held=held,
|
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).
|
# Per-battery reasons arrive with battery modes (OPT-11).
|
||||||
"why": None,
|
"why": None,
|
||||||
})
|
})
|
||||||
@@ -633,15 +654,18 @@ def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
|
|||||||
# what the add-on would do.
|
# what the add-on would do.
|
||||||
held = level == "bad" and not shadow
|
held = level == "bad" and not shadow
|
||||||
running = (bool(c.auto) or shadow) and not held
|
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)
|
ctl = next((r for r in checks if r.get("id") == "controller"), None)
|
||||||
return {
|
return {
|
||||||
"state": {"kind": kind, "pill": pill(kind),
|
"state": {"kind": kind, "pill": pill(kind),
|
||||||
"ran": ctl.get("text", "") if ctl else "",
|
"ran": ctl.get("text", "") if ctl else "",
|
||||||
"sub": banner_sub(kind)},
|
"sub": banner_sub(kind)},
|
||||||
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
|
"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),
|
"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",
|
"attention": ([{"severity": INFO, "id": "shadow",
|
||||||
"title": ATTN_SHADOW, "body": attn_shadow_body(),
|
"title": ATTN_SHADOW, "body": attn_shadow_body(),
|
||||||
"docs": None}] if shadow else []) + attention(checks),
|
"docs": None}] if shadow else []) + attention(checks),
|
||||||
@@ -673,8 +697,9 @@ ATTN_SHADOW = "Shadow mode is on"
|
|||||||
def attn_shadow_body() -> str:
|
def attn_shadow_body() -> str:
|
||||||
return "The add-on decides everything, but every inverter stays at 0 W."
|
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 = {
|
VERDICT = {
|
||||||
"held": "Held at 0 W",
|
"held": ZERO_HELD,
|
||||||
"stopped": "Stopped",
|
"stopped": "Stopped",
|
||||||
"cycle": "Monthly cycle today",
|
"cycle": "Monthly cycle today",
|
||||||
"trade": "Trading on price",
|
"trade": "Trading on price",
|
||||||
@@ -728,26 +753,31 @@ def tile_grid(grid_w) -> str:
|
|||||||
return "Balanced"
|
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:
|
if shadow:
|
||||||
return "Held at 0 W"
|
return _zero(settled)
|
||||||
return {"giving": "Giving power", "charging": "Charging",
|
return {"giving": "Giving power", "charging": "Charging",
|
||||||
"resting": "Resting", None: "No reading"}[flow(batt_w)]
|
"resting": "Resting", None: "No reading"}[flow(batt_w)]
|
||||||
|
|
||||||
|
|
||||||
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
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:
|
if soc is None or batt_w is None:
|
||||||
return "No reading from this battery."
|
return "No reading from this battery."
|
||||||
|
zero = _zero(settled)
|
||||||
if shadow:
|
if shadow:
|
||||||
if would_w is None or abs(would_w) <= IDLE_W:
|
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"
|
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:
|
if held:
|
||||||
return "Held at 0 W until the alarm clears."
|
return f"{zero} until the alarm clears."
|
||||||
if not running:
|
if not running:
|
||||||
return "Held at 0 W while control is stopped."
|
return f"{zero} while control is stopped."
|
||||||
if phase != M_IDLE:
|
if phase != M_IDLE:
|
||||||
return {M_DRAIN: "Monthly cycle: emptying.",
|
return {M_DRAIN: "Monthly cycle: emptying.",
|
||||||
M_CHARGE: "Monthly cycle: refilling.",
|
M_CHARGE: "Monthly cycle: refilling.",
|
||||||
@@ -761,9 +791,10 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
|||||||
return "Resting now."
|
return "Resting now."
|
||||||
|
|
||||||
|
|
||||||
def plan_head(key, shadow) -> str:
|
def plan_head(key, shadow, settled=True) -> str:
|
||||||
if key == "held":
|
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":
|
if key == "stopped":
|
||||||
return "Control is stopped, so nothing is planned."
|
return "Control is stopped, so nothing is planned."
|
||||||
if not shadow:
|
if not shadow:
|
||||||
@@ -784,8 +815,10 @@ def run_grid_what(kwh, shadow) -> str:
|
|||||||
f" up to {_kwh(kwh)} from the grid."
|
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:
|
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:
|
def run_grid_amount(eur) -> str:
|
||||||
@@ -798,7 +831,7 @@ def run_give_what(kwh, shadow) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run_give_why(price) -> 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:
|
def run_give_amount(eur) -> str:
|
||||||
@@ -880,8 +913,8 @@ def line_prices(now_eur, lo, hi) -> str:
|
|||||||
if now_eur is not None:
|
if now_eur is not None:
|
||||||
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
|
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
|
||||||
if lo is not None and hi is not None:
|
if lo is not None and hi is not None:
|
||||||
parts.append(f"Today: cheapest {_eur(lo['eur_kwh'])} at "
|
parts.append(f"Today: lowest {_eur(lo['eur_kwh'])} at "
|
||||||
f"{lo['label']}, dearest {_eur(hi['eur_kwh'])} at "
|
f"{lo['label']}, highest {_eur(hi['eur_kwh'])} at "
|
||||||
f"{hi['label']}.")
|
f"{hi['label']}.")
|
||||||
return " ".join(parts) or "No prices for today."
|
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
|
"""`(kind, text)` for every sentence above, rendered with real values, so
|
||||||
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
|
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
|
||||||
(2 sentences of 12 words), line (1 sentence of 25 words)."""
|
(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", t) for t in ATTN_TITLES.values()]
|
||||||
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
|
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
|
||||||
("line", attn_shadow_body())]
|
("line", attn_shadow_body())]
|
||||||
@@ -938,6 +971,7 @@ def text_samples() -> list:
|
|||||||
out.append(("title", tile_house(None, True, True)))
|
out.append(("title", tile_house(None, True, True)))
|
||||||
out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)]
|
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 += [("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 phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD):
|
||||||
for w in (2500, -2500, 0):
|
for w in (2500, -2500, 0):
|
||||||
out.append(("plan", battery_now(62, w, phase=phase,
|
out.append(("plan", battery_now(62, w, phase=phase,
|
||||||
@@ -945,8 +979,14 @@ def text_samples() -> list:
|
|||||||
would_w=-2400)))
|
would_w=-2400)))
|
||||||
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh,
|
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh,
|
||||||
running=False)))
|
running=False)))
|
||||||
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False,
|
for settled in (True, False):
|
||||||
running=False, held=True)))
|
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,
|
out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh,
|
||||||
running=True)))
|
running=True)))
|
||||||
for k in VERDICT:
|
for k in VERDICT:
|
||||||
|
|||||||
@@ -135,3 +135,84 @@ dialog::backdrop{background:rgba(0,0,0,.45)}
|
|||||||
.chart [data-tip]{cursor:default}
|
.chart [data-tip]{cursor:default}
|
||||||
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
|
.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}
|
.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}
|
||||||
|
|||||||
@@ -40,10 +40,12 @@
|
|||||||
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
|
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
|
||||||
}
|
}
|
||||||
let onYes = null;
|
let onYes = null;
|
||||||
|
// `key` names Home's words in ui_text, or is {name, description, yes}.
|
||||||
function ask(key, fn) {
|
function ask(key, fn) {
|
||||||
$("#dlg-t").textContent = t("dialog." + key + ".name");
|
const w = typeof key === "string" ? t("dialog." + key) : key;
|
||||||
$("#dlg-b").textContent = t("dialog." + key + ".description");
|
$("#dlg-t").textContent = w.name;
|
||||||
$("#dlg-yes").textContent = t("dialog." + key + ".yes");
|
$("#dlg-b").textContent = w.description;
|
||||||
|
$("#dlg-yes").textContent = w.yes;
|
||||||
onYes = fn;
|
onYes = fn;
|
||||||
const d = $("#dlg");
|
const d = $("#dlg");
|
||||||
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
|
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
|
||||||
@@ -98,7 +100,6 @@
|
|||||||
renderControl(structured);
|
renderControl(structured);
|
||||||
renderDetails(!structured);
|
renderDetails(!structured);
|
||||||
renderBatteries();
|
renderBatteries();
|
||||||
renderSettings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Home: now ---------------- */
|
/* ---------------- Home: now ---------------- */
|
||||||
@@ -162,7 +163,7 @@
|
|||||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
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") + "."]);
|
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 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" : ""),
|
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||||
@@ -289,7 +290,7 @@
|
|||||||
if (open) $("#c-details").open = true;
|
if (open) $("#c-details").open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */
|
/* ---------------- Batteries (placeholder until P3) ---------------- */
|
||||||
function renderBatteries() {
|
function renderBatteries() {
|
||||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||||
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
|
$("#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.now")), h("dd", {}, b.now),
|
||||||
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
|
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 ---------------- */
|
/* ---------------- control buttons ---------------- */
|
||||||
// The action is a toggle, so the page reads the live state first and sends
|
// 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-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
|
||||||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
$("#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 ---------------- */
|
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||||
const TABS = [...document.querySelectorAll(".tab")];
|
const TABS = [...document.querySelectorAll(".tab")];
|
||||||
function select(tab, focus) {
|
function select(tab, focus) {
|
||||||
@@ -345,6 +345,7 @@
|
|||||||
if (focus) tab.focus();
|
if (focus) tab.focus();
|
||||||
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
|
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
|
||||||
schedule();
|
schedule();
|
||||||
|
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
|
||||||
}
|
}
|
||||||
TABS.forEach((x, i) => {
|
TABS.forEach((x, i) => {
|
||||||
x.addEventListener("click", () => select(x, false));
|
x.addEventListener("click", () => select(x, false));
|
||||||
|
|||||||
@@ -96,16 +96,23 @@
|
|||||||
<div id="bcards"></div>
|
<div id="bcards"></div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
|
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
|
||||||
<div class="card">
|
<div class="row spread" style="margin-bottom:6px">
|
||||||
<h2 data-t="settings.name"></h2>
|
<h2 style="margin:0" data-s="name"></h2>
|
||||||
<p class="small muted" data-t="settings.description"></p>
|
<a class="small" href="config" id="set-classic" data-s="classic"></a>
|
||||||
<p class="small" id="set-region" hidden></p>
|
</div>
|
||||||
<a class="btn primary" href="config" data-t="settings.open"></a>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -126,10 +133,16 @@
|
|||||||
<button class="btn primary" id="dlg-yes" type="button"></button>
|
<button class="btn primary" id="dlg-yes" type="button"></button>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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="toast" role="status" aria-live="polite"></div>
|
||||||
<div id="tip" class="tip" aria-hidden="true"></div>
|
<div id="tip" class="tip" aria-hidden="true"></div>
|
||||||
|
|
||||||
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
|
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
|
||||||
<script>/*APP_JS*/</script>
|
<script>/*APP_JS*/</script>
|
||||||
|
<script>/*SETTINGS_JS*/</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
})();
|
||||||
@@ -29,7 +29,12 @@
|
|||||||
"choices": {
|
"choices": {
|
||||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
|
"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"},
|
"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": {
|
"applies": {
|
||||||
"restart": {"name": "Applies after a restart"},
|
"restart": {"name": "Applies after a restart"},
|
||||||
@@ -58,7 +63,8 @@
|
|||||||
"estimate_tip": "estimate from yesterday",
|
"estimate_tip": "estimate from yesterday",
|
||||||
"none_tip": "No price known",
|
"none_tip": "No price known",
|
||||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
"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"},
|
"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"},
|
"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."},
|
"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."},
|
"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"},
|
"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."}
|
"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."}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class Field:
|
|||||||
applies: str = RESTART
|
applies: str = RESTART
|
||||||
simple: bool = False # also on Settings, Simple
|
simple: bool = False # also on Settings, Simple
|
||||||
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
|
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
|
@property
|
||||||
def text(self) -> str:
|
def text(self) -> str:
|
||||||
@@ -73,17 +75,20 @@ _MAINT = (("maintenance_enabled", "==", True),)
|
|||||||
_ONE = (("inverter_count", "==", 1),)
|
_ONE = (("inverter_count", "==", 1),)
|
||||||
_SEVERAL = (("inverter_count", ">", 1),)
|
_SEVERAL = (("inverter_count", ">", 1),)
|
||||||
_PHASES = (("meter_phases", ">", 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.
|
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
|
||||||
ROSTER_FIELDS = (
|
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.setpoint_entity", ENTITY, kind="number", wizard=2),
|
||||||
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
|
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
|
||||||
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
|
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
|
||||||
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
|
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
|
||||||
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
|
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
|
||||||
Field("inverters.batt_invert", TOGGLE, 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 = (
|
GROUPS = (
|
||||||
@@ -123,7 +128,9 @@ GROUPS = (
|
|||||||
)),
|
)),
|
||||||
Group("tariff", (
|
Group("tariff", (
|
||||||
Part("", (
|
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_provider", SELECT, show=_DYNAMIC, wizard=4),
|
||||||
Field("price_formula", TEXT,
|
Field("price_formula", TEXT,
|
||||||
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
|
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
|
||||||
@@ -131,7 +138,8 @@ GROUPS = (
|
|||||||
Field("price_csv_path", TEXT,
|
Field("price_csv_path", TEXT,
|
||||||
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
|
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
|
||||||
wizard=4),
|
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,
|
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
|
||||||
wizard=4),
|
wizard=4),
|
||||||
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
||||||
|
|||||||
@@ -21,11 +21,23 @@ from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
|||||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||||
|
|
||||||
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
|
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
|
||||||
"arbitrage_from_batteries", "DeriveError"]
|
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
|
||||||
|
|
||||||
|
|
||||||
class DeriveError(ValueError):
|
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 = {}
|
out = {}
|
||||||
shape, cards = m.get("inverter_shape"), m.get("inverters")
|
shape, cards = m.get("inverter_shape"), m.get("inverters")
|
||||||
if shape == "raw":
|
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:
|
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:
|
if shape == "flat" and len(cards) == 1:
|
||||||
card = cards[0]
|
card = cards[0]
|
||||||
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
|
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:
|
for k in _ROSTER_FLAT:
|
||||||
if card.get(k) is not None or k in keep:
|
if card.get(k) is not None or k in keep:
|
||||||
out[k] = card.get(k)
|
out[k] = card.get(k)
|
||||||
@@ -96,8 +111,9 @@ def _roster_encode(m, keep):
|
|||||||
for c in cards]
|
for c in cards]
|
||||||
# The flat keys beside a list are not read: they stay as stored.
|
# 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})
|
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
|
||||||
if shape == "flat":
|
if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat":
|
||||||
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
|
# 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"] = ""
|
out["setpoint_entity"] = ""
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -210,7 +226,8 @@ def _tariff_encode(m, keep):
|
|||||||
contract = m.get("ui.contract")
|
contract = m.get("ui.contract")
|
||||||
if contract == "dynamic":
|
if contract == "dynamic":
|
||||||
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
|
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"]
|
out["price_provider"] = m["price_provider"]
|
||||||
kind = m.get("ui.fallback")
|
kind = m.get("ui.fallback")
|
||||||
else:
|
else:
|
||||||
@@ -226,17 +243,19 @@ def _tariff_encode(m, keep):
|
|||||||
elif kind == "custom":
|
elif kind == "custom":
|
||||||
if "static_profile" not in keep:
|
if "static_profile" not in keep:
|
||||||
raise DeriveError("odd tariff windows are edited in the "
|
raise DeriveError("odd tariff windows are edited in the "
|
||||||
"Configuration tab")
|
"Configuration tab", "custom_profile",
|
||||||
|
"ui.contract")
|
||||||
out["static_profile"] = keep["static_profile"]
|
out["static_profile"] = keep["static_profile"]
|
||||||
else:
|
else:
|
||||||
raise DeriveError(f"unknown contract {kind!r}")
|
raise DeriveError(f"unknown contract {kind!r}", "unknown",
|
||||||
|
"ui.contract")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _price_of(m, name):
|
def _price_of(m, name):
|
||||||
value = _num(m.get(name))
|
value = _num(m.get(name))
|
||||||
if value is None:
|
if value is None:
|
||||||
raise DeriveError(f"{name} needs a price")
|
raise DeriveError(f"{name} needs a price", "no_price", name)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -259,7 +278,7 @@ def _region_encode(m, keep):
|
|||||||
out["price_taxes_profile"] = keep["price_taxes_profile"]
|
out["price_taxes_profile"] = keep["price_taxes_profile"]
|
||||||
return out
|
return out
|
||||||
if region not in REGION_PRESETS:
|
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]
|
p = REGION_PRESETS[region]
|
||||||
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
|
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
|
||||||
if p["taxes_profile"] or "price_taxes_profile" in keep:
|
if p["taxes_profile"] or "price_taxes_profile" in keep:
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -37,12 +37,13 @@ import os
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from . import messages, selfconfig
|
from . import messages, selfconfig, uipage
|
||||||
from .haentities import EntityCache
|
from .haentities import EntityCache
|
||||||
from .homeview import region_of
|
from .homeview import region_of
|
||||||
from .validate import config_notes
|
from .validate import config_notes
|
||||||
from .config_page import CONFIG_PAGE
|
from .config_page import CONFIG_PAGE
|
||||||
from .configui import changed_keys, field_model, problems_by_field
|
from .configui import changed_keys, field_model, problems_by_field
|
||||||
|
from .uimodel import DeriveError, options_to_ui
|
||||||
|
|
||||||
_LOG = logging.getLogger("goodwe.web")
|
_LOG = logging.getLogger("goodwe.web")
|
||||||
|
|
||||||
@@ -65,13 +66,16 @@ def page() -> str:
|
|||||||
"""The assembled index page. `</` is escaped inside the two inline
|
"""The assembled index page. `</` is escaped inside the two inline
|
||||||
scripts, so no text in them can close the tag early."""
|
scripts, so no text in them can close the tag early."""
|
||||||
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
|
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
|
||||||
script = _read(os.path.join(STATIC, "app.js"))
|
scripts = {}
|
||||||
if "</script" in script.lower():
|
for name in ("app.js", "settings.js"):
|
||||||
raise ValueError("app.js may not contain a closing script tag")
|
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"))
|
return (_read(os.path.join(STATIC, "index.html"))
|
||||||
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
|
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
|
||||||
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
|
.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
|
# ⚠️ 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):
|
async def config_index(_request):
|
||||||
return web.Response(text=CONFIG_PAGE, content_type="text/html")
|
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:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise web.HTTPBadRequest(text="expected JSON")
|
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")
|
options = body.get("options")
|
||||||
if not isinstance(options, dict):
|
if not isinstance(options, dict):
|
||||||
raise web.HTTPBadRequest(text="expected an options object")
|
raise web.HTTPBadRequest(text="expected an options object")
|
||||||
return body, options
|
return body, options, None
|
||||||
|
|
||||||
async def config_model(request):
|
async def config_model(request):
|
||||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
"""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
|
# UX-13 P1: the levies preset and its year, for the P2 Settings
|
||||||
# screens, which show the year beside the region choice.
|
# screens, which show the year beside the region choice.
|
||||||
"region": region_of(options),
|
"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)
|
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
|
product this card is delivering; anything this layer did to them would
|
||||||
be a second opinion about a rule it does not own.
|
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 = []
|
notes = []
|
||||||
problems, unread = await selfconfig.problems_and_ranges(
|
problems, unread = await selfconfig.problems_and_ranges(
|
||||||
options, controller.hass, notes=notes)
|
options, controller.hass, notes=notes)
|
||||||
@@ -190,6 +210,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
"notes": [n._asdict() for n in notes]}
|
"notes": [n._asdict() for n in notes]}
|
||||||
if unread:
|
if unread:
|
||||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(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)
|
return web.json_response(payload)
|
||||||
|
|
||||||
def _keys_of(options):
|
def _keys_of(options):
|
||||||
@@ -202,7 +225,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
|
|
||||||
async def config_save(request):
|
async def config_save(request):
|
||||||
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
|
"""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 "")
|
expect = str(body.get("fingerprint") or "")
|
||||||
try:
|
try:
|
||||||
result = await selfconfig.save(selfcfg, options, expect,
|
result = await selfconfig.save(selfcfg, options, expect,
|
||||||
@@ -212,6 +255,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
stored = refused.stored or {}
|
stored = refused.stored or {}
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"kind": "stale", "changed": refused.changed,
|
{"kind": "stale", "changed": refused.changed,
|
||||||
|
"changed_fields": (uipage.fields_of(refused.changed,
|
||||||
|
model)
|
||||||
|
if model is not None else []),
|
||||||
"stored": stored,
|
"stored": stored,
|
||||||
# The page needs the CURRENT token to be able to save
|
# The page needs the CURRENT token to be able to save
|
||||||
# again after the operator has read the difference. That
|
# again after the operator has read the difference. That
|
||||||
@@ -221,9 +267,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
status=409)
|
status=409)
|
||||||
by_key, unanchored = problems_by_field(refused.problems,
|
by_key, unanchored = problems_by_field(refused.problems,
|
||||||
_keys_of(options))
|
_keys_of(options))
|
||||||
return web.json_response(
|
payload = {"kind": "invalid", "problems": refused.problems,
|
||||||
{"kind": "invalid", "problems": refused.problems,
|
"by_key": by_key, "unanchored": unanchored}
|
||||||
"by_key": by_key, "unanchored": unanchored}, status=422)
|
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:
|
except selfconfig.SupervisorUnavailable as err:
|
||||||
_LOG.error("saving options failed: %s", err)
|
_LOG.error("saving options failed: %s", err)
|
||||||
return web.json_response({"error": str(err)}, status=502)
|
return web.json_response({"error": str(err)}, status=502)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -96,9 +97,29 @@ for name, handler in (("502 while HA restarts", bad_gateway),
|
|||||||
("a body that is not JSON", garbage),
|
("a body that is not JSON", garbage),
|
||||||
("JSON that is not a list", not_a_list)):
|
("JSON that is not a list", not_a_list)):
|
||||||
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
|
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",
|
check("an answer slower than the timeout -> None, at the timeout",
|
||||||
asyncio.run(read_with(slow, timeout_s=0.3)) is None
|
asyncio.run(read_with(slow, timeout_s=0.3)) is None
|
||||||
and read_with.took < 1.0)
|
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("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",
|
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())
|
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
|
||||||
|
|||||||
@@ -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,
|
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 = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||||
c.auto = auto
|
c.auto = auto
|
||||||
for i, inv in enumerate(c.inverters):
|
for i, inv in enumerate(c.inverters):
|
||||||
inv.soc, inv.batt = soc[i], batt[i]
|
inv.soc, inv.batt = soc[i], batt[i]
|
||||||
|
inv.last_write = wrote
|
||||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||||
c.grid = grid
|
c.grid = grid
|
||||||
return c
|
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"][19]["kind"] == "dear"
|
||||||
and s_arb["plan"]["prices"][0]["kind"] == "mid"
|
and s_arb["plan"]["prices"][0]["kind"] == "mid"
|
||||||
and s_arb["plan"]["prices"][13]["label"] == "13:00")
|
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",
|
"that tomorrow's prices are in",
|
||||||
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
|
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
|
||||||
"Today: cheapest €0.14 at 13:00, "
|
"Today: lowest €0.14 at 13:00, "
|
||||||
"dearest €0.36 at 19:00.")
|
"highest €0.36 at 19:00.")
|
||||||
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
|
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
|
||||||
|
|
||||||
c_late = make(ARB)
|
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 not r["ok"] and r["warn"]]
|
||||||
+ [r["text"] for r in s_al["checks"] if 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")
|
print("\n4. a fault in the Home view never costs the page its rows")
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -883,6 +883,8 @@ DATA = {
|
|||||||
"hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err",
|
"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:obj", "hass.py:raw", "hass.py:domain", "hass.py:service",
|
||||||
"hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what",
|
"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
|
||||||
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
|
"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",
|
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
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)
|
and all(op in L.SHOW_OPS for _n, op, _v in f.show)
|
||||||
for _g, _p2, f in ROWS))
|
for _g, _p2, f in ROWS))
|
||||||
_names = set(ORDER) | set(KEYS) | {"inverter_count"}
|
_names = set(ORDER) | set(KEYS) | {uimodel.COUNT} | set(uimodel.GROWN)
|
||||||
check("every show condition reads an option, a page field or inverter_count",
|
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))
|
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",
|
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)
|
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)
|
_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: "
|
check(f"uimodel.py names only the keys its derivations write (others: "
|
||||||
f"{_extra})", not _extra)
|
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),
|
for k in _KEY.findall(io.open(os.path.join(HERE, "app", name),
|
||||||
encoding="utf-8").read())
|
encoding="utf-8").read())
|
||||||
if k in ORDER})
|
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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
"""UX-13 P1: serve the real page and routes over a fixture controller, for a
|
"""UX-13: serve the real page and routes over a fixture controller, for a
|
||||||
headless browser check. `python3 tools/ux13_demo.py STATE PORT`
|
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
|
||||||
|
|
||||||
STATE is running, shadow or alarm. The controller is the shipped `Controller`
|
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
|
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
|
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
|
inverter: `/action` changes only this fixture. Used for the P1 render check
|
||||||
(no JS errors, tooltips, phone width, dark mode) described in the PR.
|
(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 asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta, timezone
|
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
|
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.peakguard import PeakDecision # noqa: E402
|
||||||
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
|
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
|
||||||
from app.static_tariff import TariffPlan # 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,
|
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]
|
.14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
||||||
@@ -73,12 +84,119 @@ class Hass:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def states(self):
|
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:
|
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):
|
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",
|
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)):
|
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.soc, inv.batt = soc, (0.0 if state == "shadow" else batt)
|
||||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
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.grid = -10.0 if state != "shadow" else -2810.0
|
||||||
c.pv_w = 3900.0
|
c.pv_w = 3900.0
|
||||||
if state == "alarm":
|
if state == "alarm":
|
||||||
@@ -119,13 +239,21 @@ def controller(state):
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
async def main(state, port):
|
async def main(state, port, host="127.0.0.1"):
|
||||||
runner = aioweb.AppRunner(web.build_app(controller(state), Supervisor()))
|
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 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)
|
print(f"serving {state} on http://127.0.0.1:{port}/", flush=True)
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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]))
|
||||||
|
|||||||
Reference in New Issue
Block a user