UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
app/uilayout.py through the new app/uipage.py: search, six collapsible
groups with "n to fix / n to check / n changed" badges and an "applies
after a restart" chip, radio cards, segmented choices, sliders with units
(the peak limit in kW), entity pickers with search and the live reading,
inverter cards, solar rows, locked measured values behind "Change", and
the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
config/save; uimodel turns it into options before the shipped validator,
so nothing it refuses is saved. No route added. A value equal to the
stored one is written as stored, so a save that changed nothing writes an
identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
is 0 W; (b) the trade blocks and the price line name the feed's own points
("Lowest published price"), the hour bars say "hour average"; (c) the entity
list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
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,13 @@ 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's last accepted power request reached 0 W? A stop or
|
||||||
|
an alarm asks for 0 W at once, but the inverter gets it with the next
|
||||||
|
write, so until then the page says "Going to 0 W" (UX-13 P1 test, a)."""
|
||||||
|
return abs(inv.standing_w()) < 1.0
|
||||||
|
|
||||||
|
|
||||||
def battery_rows(c, shadow, running, held=False):
|
def battery_rows(c, shadow, running, held=False):
|
||||||
shares = {}
|
shares = {}
|
||||||
alloc = getattr(c, "allocation", None)
|
alloc = getattr(c, "allocation", None)
|
||||||
@@ -521,7 +539,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 +652,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 +695,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 +751,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 +789,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 +813,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 +829,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 +911,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 +955,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 +969,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 +977,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,555 @@
|
|||||||
|
"use strict";
|
||||||
|
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
|
||||||
|
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
|
||||||
|
from app/uimodel.py), so this file names no option key: keys, labels,
|
||||||
|
ranges and choices all arrive as data. It posts the page model back; the
|
||||||
|
add-on turns it into options and runs its own start checks, and nothing
|
||||||
|
they refuse is saved. Every word is from app/ui_text or the add-on, and
|
||||||
|
data is set as text, never as HTML. */
|
||||||
|
(function () {
|
||||||
|
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
|
||||||
|
const W = ROOT.settings;
|
||||||
|
const { h, ask, toast, maint } = window.UX13;
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
|
||||||
|
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
|
||||||
|
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
|
||||||
|
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
|
||||||
|
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
|
||||||
|
$("#savebar").setAttribute("aria-label", W.savebar.label);
|
||||||
|
|
||||||
|
let M = null; // the last /config/model
|
||||||
|
let V = null; // the page model being edited
|
||||||
|
let ORIG = null; // the page model as stored
|
||||||
|
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
|
||||||
|
let MSG = []; // [{field, card, tier, text}] from the add-on
|
||||||
|
let DEVICE = "";
|
||||||
|
let LIST = null; // the inverter list field
|
||||||
|
let FILLED = {}; // field key -> the preset field that fills it
|
||||||
|
let active = false;
|
||||||
|
let uid = 0;
|
||||||
|
const ROWS = [];
|
||||||
|
const OPEN = new Set();
|
||||||
|
const UNLOCKED = new Set();
|
||||||
|
|
||||||
|
/* ---------------- the page model ---------------- */
|
||||||
|
const sub = (f) => f.key.slice(LIST.key.length + 1);
|
||||||
|
function valueIn(model, f, card) {
|
||||||
|
if (card == null) return model[f.key];
|
||||||
|
const c = (model[LIST.key] || [])[card];
|
||||||
|
return c ? c[sub(f)] : undefined;
|
||||||
|
}
|
||||||
|
const get = (f, card) => valueIn(V, f, card);
|
||||||
|
function visible(f) {
|
||||||
|
return (f.show || []).every(([name, op, want]) => {
|
||||||
|
const have = V[name];
|
||||||
|
if (op === "==") return same(have, want);
|
||||||
|
if (op === "!=") return !same(have, want);
|
||||||
|
if (op === "in") return want.some((x) => same(x, have));
|
||||||
|
if (have == null || have === "" || isNaN(+have)) return false;
|
||||||
|
return op === ">" ? +have > want : +have < want;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function put(f, card, v, redraw) {
|
||||||
|
if (card == null) V[f.key] = v;
|
||||||
|
else V[LIST.key][card][sub(f)] = v;
|
||||||
|
if (f.mirror) {
|
||||||
|
const m = findField(f.mirror);
|
||||||
|
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
||||||
|
}
|
||||||
|
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
|
||||||
|
if (redraw || f.fills) render(); else paint();
|
||||||
|
queueValidate();
|
||||||
|
}
|
||||||
|
function findField(key) {
|
||||||
|
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||||||
|
if (f.key === key) return f;
|
||||||
|
for (const s of f.fields || []) if (s.key === key) return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- numbers: stored units <-> shown units ---------------- */
|
||||||
|
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
|
||||||
|
function shown(f, v) {
|
||||||
|
if (v == null || v === "") return "";
|
||||||
|
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
|
||||||
|
}
|
||||||
|
function stored(f, x) {
|
||||||
|
const st = f.step || (f.type === "integer" ? 1 : 0);
|
||||||
|
let v = x * (f.scale || 1);
|
||||||
|
if (st) v = Math.round(v / st) * st;
|
||||||
|
if (f.min != null) v = Math.max(f.min, v);
|
||||||
|
if (f.max != null) v = Math.min(f.max, v);
|
||||||
|
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- controls ---------------- */
|
||||||
|
function choicesOf(f, cur) {
|
||||||
|
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur));
|
||||||
|
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function toggle(f, card, lid) {
|
||||||
|
const on = !!get(f, card);
|
||||||
|
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
|
||||||
|
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
const n = b.getAttribute("aria-checked") !== "true";
|
||||||
|
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
|
||||||
|
put(f, card, n);
|
||||||
|
});
|
||||||
|
return h("span", { class: "swrap" }, b, txt);
|
||||||
|
}
|
||||||
|
function radios(f, card, lid, cards) {
|
||||||
|
const cur = get(f, card);
|
||||||
|
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
|
||||||
|
const items = choicesOf(f, cur);
|
||||||
|
const buttons = items.map(([v, label], i) => {
|
||||||
|
const on = same(v, cur);
|
||||||
|
const hint = cards && f.hints ? f.hints[v] : "";
|
||||||
|
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
|
||||||
|
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
|
||||||
|
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
|
||||||
|
b.addEventListener("click", () => put(f, card, v, true));
|
||||||
|
b.addEventListener("keydown", (e) => {
|
||||||
|
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
||||||
|
if (!d) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const j = (i + d + items.length) % items.length;
|
||||||
|
put(f, card, items[j][0], true);
|
||||||
|
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
|
||||||
|
if (again) again.focus();
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
g.dataset.rk = f.key + "|" + card;
|
||||||
|
g.append(...buttons);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function select(f, card, lid) {
|
||||||
|
const cur = get(f, card);
|
||||||
|
const s = h("select", { class: "sel", "aria-labelledby": lid });
|
||||||
|
for (const [v, label] of choicesOf(f, cur)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
|
||||||
|
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
|
||||||
|
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
|
||||||
|
const preset = f.presets && f.presets[cur];
|
||||||
|
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
|
||||||
|
}
|
||||||
|
function slider(f, card, lid) {
|
||||||
|
if (f.min == null || f.max == null) return number(f, card, lid);
|
||||||
|
const sc = f.scale || 1;
|
||||||
|
const st = f.step || (f.type === "integer" ? 1 : 0.01);
|
||||||
|
const v = get(f, card);
|
||||||
|
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
|
||||||
|
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
|
||||||
|
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
|
||||||
|
n.addEventListener("change", () => {
|
||||||
|
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
|
||||||
|
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
|
||||||
|
});
|
||||||
|
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
|
||||||
|
}
|
||||||
|
function number(f, card, lid) {
|
||||||
|
const sc = f.scale || 1;
|
||||||
|
const by = FILLED[f.key];
|
||||||
|
const preset = by && by.presets[V[by.key]];
|
||||||
|
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
|
||||||
|
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
|
||||||
|
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
|
||||||
|
n.addEventListener("input", () => {
|
||||||
|
if (n.value === "") { if (f.optional) put(f, card, null); return; }
|
||||||
|
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
|
||||||
|
});
|
||||||
|
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
|
||||||
|
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
|
||||||
|
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
|
||||||
|
}
|
||||||
|
function text(f, card, lid) {
|
||||||
|
const v = get(f, card);
|
||||||
|
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
|
||||||
|
t.addEventListener("input", () => put(f, card, t.value));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function locked(f, card, lid) {
|
||||||
|
const box = h("div", {});
|
||||||
|
const id = f.key + "#" + card;
|
||||||
|
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
|
||||||
|
if (UNLOCKED.has(id)) { open(); return box; }
|
||||||
|
const v = get(f, card);
|
||||||
|
box.append(h("div", { class: "row" },
|
||||||
|
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
|
||||||
|
h("span", { class: "chip" }, W.locked),
|
||||||
|
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* entity picker: search, the matching kind first, the live reading */
|
||||||
|
const ENT = {};
|
||||||
|
function entities(kind) {
|
||||||
|
const now = Date.now();
|
||||||
|
const e = ENT[kind];
|
||||||
|
if (e && now - e.at < 30000) return e.p;
|
||||||
|
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
|
||||||
|
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
|
||||||
|
.catch(() => ({ available: false, entities: [] }));
|
||||||
|
ENT[kind] = { at: now, p };
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
|
||||||
|
function picker(f, lid, value, pick) {
|
||||||
|
let data = null, all = false;
|
||||||
|
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
|
||||||
|
const pop = h("div", { class: "ent-pop", hidden: true });
|
||||||
|
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
|
||||||
|
const note = h("div", { class: "tiny muted ent-note" });
|
||||||
|
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
|
||||||
|
const allBox = h("label", { class: "tiny muted ent-all" },
|
||||||
|
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
|
||||||
|
function drawBtn() {
|
||||||
|
const id = value();
|
||||||
|
const e = data && data.entities.find((x) => x.entity_id === id);
|
||||||
|
btn.replaceChildren();
|
||||||
|
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
|
||||||
|
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
|
||||||
|
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
|
||||||
|
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
|
||||||
|
}
|
||||||
|
function opt(id, name, val) {
|
||||||
|
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
|
||||||
|
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
|
||||||
|
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
|
||||||
|
}
|
||||||
|
function fill() {
|
||||||
|
const typed = q.value.trim();
|
||||||
|
const t = typed.toLowerCase();
|
||||||
|
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
|
||||||
|
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
|
||||||
|
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
|
||||||
|
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
|
||||||
|
note.textContent = data && data.text ? data.text : "";
|
||||||
|
note.hidden = !note.textContent;
|
||||||
|
}
|
||||||
|
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (!pop.hidden) { close(); return; }
|
||||||
|
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
|
||||||
|
setTimeout(() => q.focus(), 0);
|
||||||
|
});
|
||||||
|
q.addEventListener("input", fill);
|
||||||
|
pop.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const opts = [...list.querySelectorAll(".ent-opt")];
|
||||||
|
const i = opts.indexOf(document.activeElement);
|
||||||
|
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
|
||||||
|
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
|
||||||
|
});
|
||||||
|
pop.append(q, note, list, allBox);
|
||||||
|
drawBtn();
|
||||||
|
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
|
||||||
|
return h("div", { class: "ent" }, btn, pop,
|
||||||
|
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
|
||||||
|
}
|
||||||
|
function entity(f, card, lid) {
|
||||||
|
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
|
||||||
|
}
|
||||||
|
function entityList(f, card, lid) {
|
||||||
|
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||||
|
const box = h("div", { class: "elist" });
|
||||||
|
arr().forEach((_id, i) => {
|
||||||
|
box.append(h("div", { class: "erow" },
|
||||||
|
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
|
||||||
|
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
|
||||||
|
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
|
||||||
|
});
|
||||||
|
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
function solarList(f, card, lid) {
|
||||||
|
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||||
|
const box = h("div", { class: "elist" });
|
||||||
|
arr().forEach((row, i) => {
|
||||||
|
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
|
||||||
|
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
|
||||||
|
box.append(h("div", { class: "erow" },
|
||||||
|
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
|
||||||
|
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
|
||||||
|
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
|
||||||
|
});
|
||||||
|
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
|
||||||
|
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList };
|
||||||
|
|
||||||
|
/* ---------------- rows, cards and groups ---------------- */
|
||||||
|
function keyText(f, card) {
|
||||||
|
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
|
||||||
|
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||||||
|
}
|
||||||
|
function fieldRow(f, card, gid) {
|
||||||
|
if (f.control === "inverter_list") return cardList(f, gid);
|
||||||
|
const lid = "sf" + (++uid);
|
||||||
|
const keys = keyText(f, card);
|
||||||
|
const msg = h("div", { class: "msgs" });
|
||||||
|
const make = CONTROLS[f.control] || text;
|
||||||
|
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
|
||||||
|
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""),
|
||||||
|
keys ? h("div", { class: "key" }, keys) : null),
|
||||||
|
h("div", { class: "ctl" }, make(f, card, lid)),
|
||||||
|
f.help ? h("div", { class: "help" }, f.help) : null, msg);
|
||||||
|
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function blankCard(f, first) {
|
||||||
|
const out = {};
|
||||||
|
for (const s of f.fields) {
|
||||||
|
const k = sub(s);
|
||||||
|
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function cardList(f, gid) {
|
||||||
|
const wrap = h("div", { class: "invs", "data-key": f.key });
|
||||||
|
const msg = h("div", { class: "msgs" });
|
||||||
|
const cards = V[f.key];
|
||||||
|
if (!Array.isArray(cards)) {
|
||||||
|
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
|
||||||
|
} else {
|
||||||
|
cards.forEach((c, i) => {
|
||||||
|
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
|
||||||
|
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||||
|
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
|
||||||
|
V[f.key].splice(i, 1); V[f.count] = V[f.key].length; put(f, null, V[f.key], true);
|
||||||
|
} }, W.remove) : null));
|
||||||
|
for (const s of f.fields) box.append(fieldRow(s, i, gid));
|
||||||
|
wrap.append(box);
|
||||||
|
});
|
||||||
|
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
|
||||||
|
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
|
||||||
|
put(f, null, V[f.key], true);
|
||||||
|
} }, "+ " + W.add_inverter));
|
||||||
|
}
|
||||||
|
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
|
||||||
|
function render() {
|
||||||
|
const host = $("#groups");
|
||||||
|
const y = window.scrollY;
|
||||||
|
host.replaceChildren();
|
||||||
|
ROWS.length = 0;
|
||||||
|
for (const g of M.layout.groups) {
|
||||||
|
const d = h("details", { class: "grp", "data-g": g.id },
|
||||||
|
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
|
||||||
|
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
|
||||||
|
d.open = OPEN.has(g.id);
|
||||||
|
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
|
||||||
|
const body = h("div", { class: "body" });
|
||||||
|
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
|
||||||
|
for (const p of g.parts) {
|
||||||
|
const pid = g.id + "." + p.id;
|
||||||
|
let box;
|
||||||
|
if (p.collapsed) {
|
||||||
|
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
|
||||||
|
box.open = OPEN.has(pid);
|
||||||
|
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
|
||||||
|
} else {
|
||||||
|
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
|
||||||
|
}
|
||||||
|
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
|
||||||
|
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
|
||||||
|
body.append(box);
|
||||||
|
}
|
||||||
|
if (g.id === "maintenance") {
|
||||||
|
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
|
||||||
|
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
|
||||||
|
h("div", { class: "row" },
|
||||||
|
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
|
||||||
|
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
|
||||||
|
}
|
||||||
|
d.append(body);
|
||||||
|
host.append(d);
|
||||||
|
}
|
||||||
|
paint();
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- messages, visibility, search, badges ---------------- */
|
||||||
|
const ICON = { bad: "×", warn: "!", info: "i" };
|
||||||
|
const RANK = { bad: 0, warn: 1, info: 2 };
|
||||||
|
function mine(r, m) {
|
||||||
|
if (m.field !== r.f.key) return false;
|
||||||
|
if (r.list || r.card == null) return true;
|
||||||
|
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||||
|
}
|
||||||
|
function paint() {
|
||||||
|
const q = $("#set-search").value.trim().toLowerCase();
|
||||||
|
const placed = new Set();
|
||||||
|
for (const r of ROWS) {
|
||||||
|
r.visible = visible(r.f);
|
||||||
|
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible) placed.add(i); return hit; })
|
||||||
|
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
|
||||||
|
r.tiers = new Set(msgs.map((m) => m.tier));
|
||||||
|
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
|
||||||
|
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
|
||||||
|
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
|
||||||
|
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
|
||||||
|
// A card list counts as one change when cards were added or removed;
|
||||||
|
// an edit inside a card counts on its own row.
|
||||||
|
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
|
||||||
|
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
|
||||||
|
r.el.classList.toggle("changed", r.changed && !r.list);
|
||||||
|
}
|
||||||
|
// Search: a row shows when it is visible and matches. A card list shows
|
||||||
|
// while any of its rows does.
|
||||||
|
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
|
||||||
|
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
|
||||||
|
let any = false;
|
||||||
|
document.querySelectorAll("#groups details.grp").forEach((d) => {
|
||||||
|
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
|
||||||
|
d.querySelectorAll(".part").forEach((p) => {
|
||||||
|
const inside = rows.filter((r) => p.contains(r.el));
|
||||||
|
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
|
||||||
|
});
|
||||||
|
const hit = rows.some((r) => !r.el.hidden);
|
||||||
|
any = any || hit;
|
||||||
|
d.hidden = !!q && !hit;
|
||||||
|
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
|
||||||
|
const shownRows = rows.filter((r) => r.visible && !r.list);
|
||||||
|
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
|
||||||
|
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
|
||||||
|
const changed = rows.filter((r) => r.changed).length;
|
||||||
|
d.querySelector(".gbadge").replaceChildren(...[
|
||||||
|
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
|
||||||
|
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
|
||||||
|
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
|
||||||
|
});
|
||||||
|
$("#set-nomatch").hidden = !q || any;
|
||||||
|
// A sentence shown beside any field is not repeated here.
|
||||||
|
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
|
||||||
|
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
|
||||||
|
if (DEVICE) other.push({ tier: "info", text: DEVICE });
|
||||||
|
$("#set-other").hidden = !other.length;
|
||||||
|
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
|
||||||
|
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
|
||||||
|
savebar();
|
||||||
|
}
|
||||||
|
function dirtyCount() {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
|
||||||
|
return seen.size;
|
||||||
|
}
|
||||||
|
function savebar() {
|
||||||
|
const n = V ? dirtyCount() : 0;
|
||||||
|
$("#savebar").hidden = !(active && n);
|
||||||
|
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||||||
|
}
|
||||||
|
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||||||
|
|
||||||
|
/* ---------------- talking to the add-on ---------------- */
|
||||||
|
let vtimer = null, vseq = 0;
|
||||||
|
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
||||||
|
async function validate() {
|
||||||
|
const seq = ++vseq;
|
||||||
|
try {
|
||||||
|
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: V }) });
|
||||||
|
const b = await r.json();
|
||||||
|
if (seq !== vseq) return;
|
||||||
|
MSG = b.messages || [];
|
||||||
|
DEVICE = b.device_note || "";
|
||||||
|
paint();
|
||||||
|
} catch (e) { /* the next change tries again */ }
|
||||||
|
}
|
||||||
|
function banner(level, title, sub, button, onClick) {
|
||||||
|
const host = $("#set-banner");
|
||||||
|
if (!title) { host.replaceChildren(); return; }
|
||||||
|
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||||||
|
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||||
|
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||||||
|
}
|
||||||
|
function restart() {
|
||||||
|
ask(W.dialog, async () => {
|
||||||
|
try {
|
||||||
|
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ confirm: "restart" }) });
|
||||||
|
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||||||
|
toast(W.toast.restarting.description);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function load(after) {
|
||||||
|
let m;
|
||||||
|
try {
|
||||||
|
const r = await fetch("config/model?lang=en", { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error("model " + r.status);
|
||||||
|
m = await r.json();
|
||||||
|
if (!m.layout || !m.ui) throw new Error("no layout");
|
||||||
|
} catch (e) {
|
||||||
|
$("#groups").replaceChildren();
|
||||||
|
$("#set-search").hidden = true;
|
||||||
|
banner("bad", W.load_failed.name, W.load_failed.description);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
|
||||||
|
$("#set-search").hidden = false;
|
||||||
|
FILLED = {};
|
||||||
|
LIST = null;
|
||||||
|
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||||||
|
if (f.fills) FILLED[f.fills] = f;
|
||||||
|
if (f.control === "inverter_list") LIST = f;
|
||||||
|
}
|
||||||
|
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
|
||||||
|
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
|
||||||
|
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
|
||||||
|
else banner();
|
||||||
|
render();
|
||||||
|
validate();
|
||||||
|
}
|
||||||
|
function firstBad() {
|
||||||
|
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
|
||||||
|
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
|
||||||
|
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
|
||||||
|
r.el.scrollIntoView({ block: "center" });
|
||||||
|
const input = r.el.querySelector("input,select,button");
|
||||||
|
if (input) input.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const btn = $("#sb-save");
|
||||||
|
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||||||
|
try {
|
||||||
|
clearTimeout(vtimer);
|
||||||
|
await validate();
|
||||||
|
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||||
|
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: V, fingerprint: FP }) });
|
||||||
|
const b = await r.json().catch(() => ({}));
|
||||||
|
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||||||
|
if (r.status === 409 && b.kind === "stale") {
|
||||||
|
FP = b.fingerprint || FP;
|
||||||
|
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||||
|
banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
|
||||||
|
toast(W.toast.failed);
|
||||||
|
} catch (e) { toast(W.toast.failed); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
|
||||||
|
}
|
||||||
|
$("#sb-save").addEventListener("click", save);
|
||||||
|
$("#sb-discard").addEventListener("click", () => {
|
||||||
|
V = clone(ORIG); UNLOCKED.clear(); render(); validate(); toast(W.toast.discarded);
|
||||||
|
if ($("#set-banner .b-bad")) banner();
|
||||||
|
});
|
||||||
|
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
|
||||||
|
document.addEventListener("ux13:tab", (e) => {
|
||||||
|
active = e.detail === "settings";
|
||||||
|
if (active && !M) load(); else savebar();
|
||||||
|
});
|
||||||
|
// app.js chose the first tab before this file ran.
|
||||||
|
if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); }
|
||||||
|
})();
|
||||||
@@ -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,275 @@
|
|||||||
|
"""UX-13 P2: the settings view's server half. It names no option key.
|
||||||
|
|
||||||
|
It turns app/uilayout.py's table into the JSON the page draws from (labels
|
||||||
|
from the Supervisor's translations, ranges and choices from its schema, page
|
||||||
|
words from app/ui_text), turns a posted page model into options through
|
||||||
|
app/uimodel.py, and says beside which page field each validator sentence
|
||||||
|
goes. The rules stay in validate.py and the sentences are passed on as they
|
||||||
|
are (DECISIONS/UX-13-one-layout-table.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from . import uilayout as L
|
||||||
|
from . import uimodel as U
|
||||||
|
from .configui import help_of, label_of, problems_by_field, split_group
|
||||||
|
|
||||||
|
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
|
||||||
|
"en.json")
|
||||||
|
# A choice the page cannot make, only keep: odd tariff windows (P5 editor).
|
||||||
|
ONLY_IF_HELD = ("custom",)
|
||||||
|
_TIERS = ("bad", "warn", "info")
|
||||||
|
|
||||||
|
|
||||||
|
def ui_text() -> dict:
|
||||||
|
with io.open(UI_TEXT, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _roster():
|
||||||
|
"""The inverter list field's key, read off the table."""
|
||||||
|
return next(f.key for _g, _p, f in L.fields()
|
||||||
|
if f.control == L.INVERTER_LIST)
|
||||||
|
|
||||||
|
|
||||||
|
def _node(schema_ui, key):
|
||||||
|
"""The Supervisor's schema entry for `key` (`list.sub` too), or {}."""
|
||||||
|
nodes, path = schema_ui or [], key.split(".")
|
||||||
|
for step in path[:-1]:
|
||||||
|
parent = next((n for n in nodes if n.get("name") == step), None)
|
||||||
|
nodes = (parent or {}).get("schema") or []
|
||||||
|
return next((n for n in nodes if n.get("name") == path[-1]), None) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _words(translations, lang, key, T):
|
||||||
|
"""(label, help) for a field: ui_text for `ui.` fields, else the
|
||||||
|
translation without its `Group · ` prefix, and the raw key last."""
|
||||||
|
if key.startswith("ui."):
|
||||||
|
entry = T["fields"].get(key[3:], {})
|
||||||
|
return entry.get("name", key), entry.get("description", "")
|
||||||
|
path = tuple(key.split(".")[:-1])
|
||||||
|
name = key.split(".")[-1]
|
||||||
|
label, _lang = label_of(translations, lang, name, path)
|
||||||
|
text, _lang = help_of(translations, lang, name, path)
|
||||||
|
return split_group(label)[1], text
|
||||||
|
|
||||||
|
|
||||||
|
def _choices(fld, node, T, value_type):
|
||||||
|
"""[[value, label, only_if_held], ...] for a choice control, or None."""
|
||||||
|
if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE):
|
||||||
|
return None
|
||||||
|
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
|
||||||
|
words = T["choices"].get(name) or {}
|
||||||
|
if fld.key.startswith("ui."):
|
||||||
|
values = list(words)
|
||||||
|
elif node.get("options"):
|
||||||
|
values = [v for v in node["options"] if not words or v in words]
|
||||||
|
elif value_type == "integer" and node.get("lengthMax") is not None:
|
||||||
|
values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1))
|
||||||
|
if words:
|
||||||
|
values = [v for v in values if str(v) in words]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for v in values:
|
||||||
|
label = words.get(str(v))
|
||||||
|
if label is None:
|
||||||
|
label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}")
|
||||||
|
if isinstance(v, int) else str(v))
|
||||||
|
preset = U.REGION_PRESETS.get(v) if fld.fills else None
|
||||||
|
if preset:
|
||||||
|
label = f"{label}, " + T["settings"]["region_year"].replace(
|
||||||
|
"{year}", str(preset["year"]))
|
||||||
|
out.append([v, label, v in ONLY_IF_HELD])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _field(fld, schema_ui, translations, lang, T):
|
||||||
|
node = _node(schema_ui, fld.key)
|
||||||
|
label, text = _words(translations, lang, fld.key, T)
|
||||||
|
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
|
||||||
|
else node.get("type") or "")
|
||||||
|
out = {"key": fld.key, "control": fld.control, "label": label,
|
||||||
|
"help": text, "unit": fld.unit, "scale": fld.scale,
|
||||||
|
"step": fld.step, "kind": fld.kind,
|
||||||
|
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
||||||
|
"type": value_type, "optional": not node.get("required", False),
|
||||||
|
"writes": sorted(k for k, how in L.DERIVED.items()
|
||||||
|
if how == fld.key)}
|
||||||
|
if node.get("lengthMin") is not None:
|
||||||
|
out["min"] = node["lengthMin"]
|
||||||
|
if node.get("lengthMax") is not None:
|
||||||
|
out["max"] = node["lengthMax"]
|
||||||
|
choices = _choices(fld, node, T, value_type)
|
||||||
|
if choices is not None:
|
||||||
|
out["choices"] = choices
|
||||||
|
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
|
||||||
|
later = T.get("choices_later", {}).get(name)
|
||||||
|
if later:
|
||||||
|
out["later"] = [[v, words] for v, words in later.items()]
|
||||||
|
hints = T.get("choice_help", {}).get(name)
|
||||||
|
if hints:
|
||||||
|
out["hints"] = {v: e.get("description", "")
|
||||||
|
for v, e in hints.items()}
|
||||||
|
if fld.mirror:
|
||||||
|
out["mirror"] = fld.mirror
|
||||||
|
if fld.fills:
|
||||||
|
out["fills"] = fld.fills
|
||||||
|
out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]}
|
||||||
|
for rid, p in U.REGION_PRESETS.items()}
|
||||||
|
roster = _roster()
|
||||||
|
if fld.key.startswith(roster + "."):
|
||||||
|
sub = fld.key[len(roster) + 1:]
|
||||||
|
if L.DERIVED.get(sub) == roster:
|
||||||
|
out["flat"] = sub # how the one flat inverter stores it
|
||||||
|
if fld.control == L.INVERTER_LIST:
|
||||||
|
out["fields"] = [_field(sub, schema_ui, translations, lang, T)
|
||||||
|
for sub in L.ROSTER_FIELDS]
|
||||||
|
out["count"] = U.COUNT
|
||||||
|
out["grow"] = dict(U.GROWN)
|
||||||
|
out["shape"] = next(iter(U.GROWN))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def layout(schema_ui, translations, lang="en") -> dict:
|
||||||
|
"""Every group, part and field of the Installer view, as the page draws
|
||||||
|
it. Built from the table only: a field the table places is on the page."""
|
||||||
|
T = ui_text()
|
||||||
|
groups = []
|
||||||
|
for g in L.GROUPS:
|
||||||
|
parts = []
|
||||||
|
for p in g.parts:
|
||||||
|
words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {}
|
||||||
|
parts.append({"id": p.id, "name": words.get("name", ""),
|
||||||
|
"description": words.get("description", ""),
|
||||||
|
"collapsed": p.collapsed,
|
||||||
|
"fields": [_field(f, schema_ui, translations, lang, T)
|
||||||
|
for f in p.fields]})
|
||||||
|
words = T["groups"].get(g.id, {})
|
||||||
|
applies = sorted({f.applies for p in g.parts for f in p.fields})
|
||||||
|
groups.append({"id": g.id, "name": words.get("name", g.id),
|
||||||
|
"description": words.get("description", ""),
|
||||||
|
"support_only": g.support_only,
|
||||||
|
"applies": [T["applies"][a]["name"] for a in applies],
|
||||||
|
"parts": parts})
|
||||||
|
return {"groups": groups}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The page model -> options, for the validator and the save
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _same(a, b) -> bool:
|
||||||
|
"""Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not."""
|
||||||
|
if isinstance(a, bool) or isinstance(b, bool):
|
||||||
|
return type(a) is type(b) and a == b
|
||||||
|
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||||
|
return a == b
|
||||||
|
if isinstance(a, list) and isinstance(b, list):
|
||||||
|
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
|
||||||
|
if isinstance(a, dict) and isinstance(b, dict):
|
||||||
|
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
|
||||||
|
return type(a) is type(b) and a == b
|
||||||
|
|
||||||
|
|
||||||
|
def to_options(model: dict, stored: dict | None = None) -> dict:
|
||||||
|
"""The options blob for a posted page model. A value equal to the stored
|
||||||
|
one is written as stored, in the stored key order, so a save that changed
|
||||||
|
nothing writes an identical blob (the browser reads 3.0 as 3)."""
|
||||||
|
out = U.ui_to_options(model)
|
||||||
|
stored = stored or {}
|
||||||
|
for k, v in out.items():
|
||||||
|
if k in stored and _same(v, stored[k]):
|
||||||
|
out[k] = copy.deepcopy(stored[k])
|
||||||
|
return ({k: out[k] for k in stored if k in out}
|
||||||
|
| {k: v for k, v in out.items() if k not in stored})
|
||||||
|
|
||||||
|
|
||||||
|
def derive_message(err: U.DeriveError) -> dict:
|
||||||
|
words = ui_text()["settings"]["derive"]
|
||||||
|
text = words.get(err.code, words["unknown"])["description"]
|
||||||
|
return {"field": err.field or None, "card": None, "tier": "bad",
|
||||||
|
"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Where a sentence goes on the page
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card.
|
||||||
|
_SUB = re.compile(r"(?<![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())
|
||||||
|
|||||||
@@ -321,11 +321,11 @@ check("the price bars are today's 24 hours, from the real series, with the "
|
|||||||
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
and s_arb["plan"]["prices"][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 +454,59 @@ check("...and every red, amber and settling row reaches Needs attention with "
|
|||||||
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
|
+ [r["text"] for r in s_al["checks"] if 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)
|
||||||
|
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,437 @@
|
|||||||
|
"""UX-13 P2: the Installer settings view. `python3 test_settings.py`
|
||||||
|
|
||||||
|
The view is drawn from app/uilayout.py through app/uipage.py and saves the
|
||||||
|
page model through app/uimodel.py and the shipped validator. Part 1 checks
|
||||||
|
that every group and every mapped field reaches the page with its words,
|
||||||
|
range and choices. Part 2 checks the page script names no option key. Parts
|
||||||
|
3-5 drive the real routes over HTTP against a fake Supervisor: the save round
|
||||||
|
trip, where a refusal lands, and a stale save. Part 6 holds the view's words
|
||||||
|
to STYLE. The headless-browser click-through is in the PR, not in CI (the CI
|
||||||
|
image has no browser).
|
||||||
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import glob
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||||
|
logging.disable(logging.WARNING) # the validator's own log lines
|
||||||
|
|
||||||
|
from aiohttp import ClientSession, web as aioweb # noqa: E402
|
||||||
|
|
||||||
|
import translations_lint as TL # noqa: E402
|
||||||
|
import ux13_demo as D # noqa: E402
|
||||||
|
from app import selfconfig, uilayout as L, uimodel as U, uipage, web # noqa: E402
|
||||||
|
from app.validate import validate_startup_config # noqa: E402
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = D.schema_ui(D.CONFIG)
|
||||||
|
TRANSLATIONS = {"en": TL.parse_translation_file(
|
||||||
|
os.path.join(HERE, "translations", "en.yaml"))}
|
||||||
|
DEFAULTS = D.defaults(D.CONFIG)
|
||||||
|
SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint",
|
||||||
|
soc_entity="sensor.soc", batt_entity="sensor.batt")
|
||||||
|
TWO = dict(SITE, setpoint_entity="", inverters=[
|
||||||
|
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": 2500,
|
||||||
|
"bms_soc_floor": 10},
|
||||||
|
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 2500,
|
||||||
|
"bms_soc_floor": 10}])
|
||||||
|
UI_TEXT = uipage.ui_text()
|
||||||
|
W = UI_TEXT["settings"]
|
||||||
|
|
||||||
|
|
||||||
|
def browser(value):
|
||||||
|
"""What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3."""
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return int(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [browser(v) for v in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: browser(v) for k, v in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("1. every group and every mapped field reaches the page")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS)
|
||||||
|
_flat = []
|
||||||
|
for _g in LAYOUT["groups"]:
|
||||||
|
for _p in _g["parts"]:
|
||||||
|
for _f in _p["fields"]:
|
||||||
|
_flat.append((_g["id"], _f))
|
||||||
|
_flat += [(_g["id"], _s) for _s in _f.get("fields", [])]
|
||||||
|
_keys = [f["key"] for _g, f in _flat]
|
||||||
|
check("the six groups, in the design's order",
|
||||||
|
[g["id"] for g in LAYOUT["groups"]]
|
||||||
|
== ["meter", "inverters", "tariff", "safety", "maintenance", "tuning"]
|
||||||
|
and [g["name"] for g in LAYOUT["groups"]]
|
||||||
|
== ["Meter", "Inverters and batteries", "Tariff and prices",
|
||||||
|
"Safety limits", "Maintenance", "Control tuning"])
|
||||||
|
check(f"every field of the table is on the page once ({len(_keys)} fields)",
|
||||||
|
sorted(_keys) == sorted(f.key for _g, _p, f in L.fields())
|
||||||
|
and len(_keys) == len(set(_keys)))
|
||||||
|
_counts = {g["id"]: sum(1 for gid, _f in _flat if gid == g["id"])
|
||||||
|
for g in LAYOUT["groups"]}
|
||||||
|
print(f" fields per group: {_counts}")
|
||||||
|
check("every field has a label, and every option field's label is its "
|
||||||
|
"translation without the group prefix",
|
||||||
|
all(f["label"] and " · " not in f["label"] for _g, f in _flat)
|
||||||
|
and next(f for _g, f in _flat if f["key"] == "meter_source")["label"]
|
||||||
|
== "Where the meter reading comes from")
|
||||||
|
_ranged = [(f["key"], f.get("min"), f.get("max")) for _g, f in _flat
|
||||||
|
if f["control"] in (L.SLIDER, L.LOCKED)]
|
||||||
|
check(f"every slider and locked value has its range from the schema "
|
||||||
|
f"({len(_ranged)})", all(lo is not None and hi is not None
|
||||||
|
for _k, lo, hi in _ranged)
|
||||||
|
and ("peak_cap_w", 500.0, 15000.0) in _ranged)
|
||||||
|
check("the peak limit is a kW slider over a value stored in W",
|
||||||
|
next(f for _g, f in _flat if f["key"] == "peak_cap_w")["scale"] == 1000)
|
||||||
|
_choice = [f for _g, f in _flat
|
||||||
|
if f["control"] in (L.SEGMENTED, L.SELECT, L.CHOICE)]
|
||||||
|
check("every choice control has choices with words",
|
||||||
|
all(f["choices"] and all(c[1] for c in f["choices"]) for f in _choice))
|
||||||
|
_ms = next(f for _g, f in _flat if f["key"] == "meter_source")
|
||||||
|
check("the meter source is radio cards with a line of help on each",
|
||||||
|
_ms["control"] == L.CHOICE and set(_ms["hints"]) == {c[0] for c in
|
||||||
|
_ms["choices"]})
|
||||||
|
_reg = next(f for _g, f in _flat if f["key"] == "ui.region")
|
||||||
|
check("the region shows its preset year beside the choice (P0 review)",
|
||||||
|
["flanders", "Flanders, Fluvius Antwerpen, 2026 figures", False]
|
||||||
|
in _reg["choices"] and _reg["presets"]["flanders"]["year"] == 2026)
|
||||||
|
check("...and says plainly that Wallonia and Brussels have no preset yet "
|
||||||
|
"(TARIFF-22)", _reg["later"] == [["wallonia", "Wallonia, no preset yet"],
|
||||||
|
["brussels", "Brussels, no preset yet"]]
|
||||||
|
and "Wallonia or Brussels" in _reg["help"])
|
||||||
|
_locked = sorted(f["key"] for _g, f in _flat if f["control"] == L.LOCKED)
|
||||||
|
check(f"measured and derived values are locked behind 'Change' "
|
||||||
|
f"({len(_locked)}): every control tuning value, every safety stop and "
|
||||||
|
"the peak reserve",
|
||||||
|
{f.key for p in L.GROUPS[-1].parts for f in p.fields} <= set(_locked)
|
||||||
|
and "sign_disagree_w" in _locked and "peak_soc_floor" in _locked
|
||||||
|
and LAYOUT["groups"][-1]["support_only"])
|
||||||
|
check("each group says when its settings apply",
|
||||||
|
all(g["applies"] == [UI_TEXT["applies"]["restart"]["name"]]
|
||||||
|
for g in LAYOUT["groups"]))
|
||||||
|
_inv = next(f for _g, f in _flat if f["key"] == "inverters")
|
||||||
|
check("the inverter cards carry their count, and adding one makes a list",
|
||||||
|
_inv["count"] == U.COUNT and _inv["grow"] == U.GROWN
|
||||||
|
and [s["key"] for s in _inv["fields"]]
|
||||||
|
== [f.key for f in L.ROSTER_FIELDS])
|
||||||
|
check("a page-only field names the option keys it writes, for the grey key "
|
||||||
|
"line", _reg["writes"] == ["price_taxes_profile"]
|
||||||
|
and next(f for _g, f in _flat if f["key"] == "ui.solar")["writes"]
|
||||||
|
== ["pv_entities", "pv_entity", "pv_invert_entities"])
|
||||||
|
check("without a Supervisor schema the page still draws, with no ranges",
|
||||||
|
len(uipage.layout([], {})["groups"]) == 6)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n2. the page script names no option key")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
|
||||||
|
encoding="utf-8").read()
|
||||||
|
ORDER, _nested = TL.config_surface(D.CONFIG)
|
||||||
|
_leak = sorted(k for k in ORDER if re.search(
|
||||||
|
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
|
||||||
|
check(f"settings.js names no option key ({_leak})", not _leak)
|
||||||
|
check("data is set as text, never as HTML",
|
||||||
|
"innerHTML" not in JS and "insertAdjacentHTML" not in JS)
|
||||||
|
_fetch = sorted(set(re.findall(r'fetch\("([^"?]+)', JS)))
|
||||||
|
check(f"every request is relative, to the add-on's own routes ({_fetch})",
|
||||||
|
_fetch == ["config/model", "config/restart", "config/save",
|
||||||
|
"config/validate", "ha/entities"])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(path, node=W):
|
||||||
|
for k in path.split("."):
|
||||||
|
if not isinstance(node, dict) or k not in node:
|
||||||
|
return None
|
||||||
|
node = node[k]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
_used = {re.sub(r"\.(replace|join|length)$", "", k) for k in
|
||||||
|
re.findall(r'\bW\.([a-z_]+(?:\.[a-z_]+)*)', JS)}
|
||||||
|
_used |= set(re.findall(r'data-s(?:-ph|-label)?="([\w.]+)"', io.open(
|
||||||
|
os.path.join(HERE, "app", "static", "index.html"), encoding="utf-8").read()))
|
||||||
|
_miss = sorted(k for k in _used if _resolve(k) is None)
|
||||||
|
check(f"every word the view asks for is in app/ui_text ({len(_used)} keys, "
|
||||||
|
f"missing: {_miss})", len(_used) > 25 and not _miss)
|
||||||
|
check("search matches a field's label, its key and its help",
|
||||||
|
"(f.label + \" \" + keys + \" \" + f.key + \" \" + (f.help || \"\"))"
|
||||||
|
in JS)
|
||||||
|
check("a locked value shows no input until 'Change' is pressed",
|
||||||
|
re.search(r"function locked\(.*?\n }\n", JS, re.S)
|
||||||
|
and "UNLOCKED.has(id)) { open(); return box; }" in JS)
|
||||||
|
check("nothing is saved while the add-on's own checks refuse the model",
|
||||||
|
"if (MSG.some((m) => m.tier === \"bad\")) { toast(" in JS)
|
||||||
|
check("the old settings page stays reachable (until P5)",
|
||||||
|
'href="config" id="set-classic"' in web.page())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The real routes, over HTTP, against a fake Supervisor
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class Supervisor:
|
||||||
|
def __init__(self, options):
|
||||||
|
self.options = copy.deepcopy(options)
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
async def info(self):
|
||||||
|
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
|
||||||
|
"translations": TRANSLATIONS}
|
||||||
|
|
||||||
|
async def write_options(self, options):
|
||||||
|
self.writes.append(copy.deepcopy(options))
|
||||||
|
self.options = copy.deepcopy(options)
|
||||||
|
|
||||||
|
async def restart(self, confirm):
|
||||||
|
raise AssertionError("no restart here")
|
||||||
|
|
||||||
|
|
||||||
|
class Hass:
|
||||||
|
session = None
|
||||||
|
token = ""
|
||||||
|
|
||||||
|
async def limits(self, _e):
|
||||||
|
return (-5000.0, 5000.0, 1.0)
|
||||||
|
|
||||||
|
async def states(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class Controller:
|
||||||
|
def __init__(self, opts):
|
||||||
|
self.o, self.hass, self.inverters = dict(opts), Hass(), []
|
||||||
|
|
||||||
|
def log_event(self, text, hint=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def status_payload(self):
|
||||||
|
return {"banner": "", "level": "ok", "rows": [], "checks": []}
|
||||||
|
|
||||||
|
async def handle_action(self, what):
|
||||||
|
raise AssertionError(f"the settings view reached {what!r}")
|
||||||
|
|
||||||
|
|
||||||
|
async def session(stored, steps):
|
||||||
|
"""Run `steps(s, root)` against the app over a fake Supervisor."""
|
||||||
|
sup = Supervisor(stored)
|
||||||
|
runner = aioweb.AppRunner(web.build_app(Controller(stored), sup))
|
||||||
|
await runner.setup()
|
||||||
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||||
|
await site.start()
|
||||||
|
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
|
||||||
|
try:
|
||||||
|
async with ClientSession() as s:
|
||||||
|
out = await steps(s, root, sup)
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
return out, sup
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(s, url):
|
||||||
|
async with s.get(url) as r:
|
||||||
|
return await r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(s, url, body):
|
||||||
|
async with s.post(url, json=body) as r:
|
||||||
|
return r.status, await r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n3. the save round trip")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
check("the fixture site starts (so a refusal below is the edit's)",
|
||||||
|
not validate_startup_config(TWO) and not validate_startup_config(SITE))
|
||||||
|
|
||||||
|
|
||||||
|
async def _round(s, root, sup):
|
||||||
|
out = {}
|
||||||
|
m = await _get(s, root + "config/model?lang=en")
|
||||||
|
out["model"] = m
|
||||||
|
model = browser(m["ui"])
|
||||||
|
out["same"] = await _post(s, root + "config/save",
|
||||||
|
{"model": model, "fingerprint": m["fingerprint"]})
|
||||||
|
out["after_same"] = copy.deepcopy(sup.options)
|
||||||
|
m = await _get(s, root + "config/model?lang=en")
|
||||||
|
model = browser(m["ui"])
|
||||||
|
model["peak_cap_w"] = 4200
|
||||||
|
model["inverters"][1]["max_w"] = 2200
|
||||||
|
out["edit"] = await _post(s, root + "config/save",
|
||||||
|
{"model": model, "fingerprint": m["fingerprint"]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_stored = dict(TWO, gain=0.5, target_grid_w=-10.0, peak_cap_w=3500)
|
||||||
|
_r, _sup = asyncio.run(session(_stored, _round))
|
||||||
|
check("config/model carries the layout and the page model",
|
||||||
|
_r["model"]["layout"] == json.loads(json.dumps(uipage.layout(
|
||||||
|
SCHEMA, TRANSLATIONS))) and _r["model"]["ui"]["inverter_count"] == 2)
|
||||||
|
check("a save that changed nothing writes an identical blob: same values, "
|
||||||
|
"types and key order, although the browser turned -10.0 into -10",
|
||||||
|
_r["same"][0] == 200 and json.dumps(_r["after_same"])
|
||||||
|
== json.dumps(_stored) and isinstance(_r["after_same"]["target_grid_w"],
|
||||||
|
float))
|
||||||
|
_written = _sup.writes[-1]
|
||||||
|
print(f" the edit: {_r['edit']}, changed "
|
||||||
|
f"{sorted(k for k in _stored if _written[k] != _stored[k])}")
|
||||||
|
check("an edit writes exactly the fields edited, the peak limit in W",
|
||||||
|
_r["edit"][0] == 200
|
||||||
|
and sorted(k for k in _stored if _written[k] != _stored[k])
|
||||||
|
== ["inverters", "peak_cap_w"] and _written["peak_cap_w"] == 4200
|
||||||
|
and _written["inverters"][1]["max_w"] == 2200
|
||||||
|
and _written["inverters"][0] == _stored["inverters"][0])
|
||||||
|
_blobs = [("config.yaml", DEFAULTS), ("site", SITE), ("two", TWO)] + [
|
||||||
|
(os.path.basename(f), json.load(open(f))) for f in sorted(glob.glob(
|
||||||
|
os.path.join(HERE, "scenarios", "ux13", "test23", "*.json")))]
|
||||||
|
_bad = [n for n, o in _blobs if json.dumps(uipage.to_options(browser(
|
||||||
|
json.loads(json.dumps(U.options_to_ui(o)))), o)) != json.dumps(o)]
|
||||||
|
check(f"every known blob comes back byte for byte through the browser "
|
||||||
|
f"({len(_blobs)} blobs, not: {_bad})", not _bad and len(_blobs) >= 8)
|
||||||
|
_grown = U.options_to_ui(SITE)
|
||||||
|
_grown["inverters"].append(dict(_grown["inverters"][0], name="b",
|
||||||
|
setpoint_entity="number.b"))
|
||||||
|
_grown["inverters"][0]["name"] = "a"
|
||||||
|
_grown.update(U.GROWN)
|
||||||
|
_g = U.ui_to_options(_grown)
|
||||||
|
check("'Add an inverter' on a flat site writes a list and clears the flat "
|
||||||
|
"power request entity, which the validator refuses beside a list",
|
||||||
|
[e["name"] for e in _g["inverters"]] == ["a", "b"]
|
||||||
|
and _g["setpoint_entity"] == "" and not validate_startup_config(_g))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n4. a refusal lands beside the field it is about")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(s, root, sup):
|
||||||
|
out = {}
|
||||||
|
m = await _get(s, root + "config/model?lang=en")
|
||||||
|
base = m["ui"]
|
||||||
|
|
||||||
|
async def validate(**edits):
|
||||||
|
model = copy.deepcopy(base)
|
||||||
|
for k, v in edits.items():
|
||||||
|
if k == "card":
|
||||||
|
model["inverters"][v[0]][v[1]] = v[2]
|
||||||
|
else:
|
||||||
|
model[k.replace("__", ".")] = v
|
||||||
|
return (await _post(s, root + "config/validate", {"model": model}))[1]
|
||||||
|
|
||||||
|
out["card"] = await validate(card=(1, "setpoint_entity", ""))
|
||||||
|
out["same"] = await validate(card=(1, "setpoint_entity", "number.a"))
|
||||||
|
out["floor"] = await validate(peak_soc_floor=5)
|
||||||
|
out["derive"] = await validate(ui__contract="dynamic", price_provider="")
|
||||||
|
model = copy.deepcopy(base)
|
||||||
|
model["inverters"][1]["setpoint_entity"] = ""
|
||||||
|
out["save"] = await _post(s, root + "config/save",
|
||||||
|
{"model": model, "fingerprint": m["fingerprint"]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_r, _sup = asyncio.run(session(TWO, _refuse))
|
||||||
|
|
||||||
|
|
||||||
|
def _at(payload, tier="bad"):
|
||||||
|
return {(m["field"], m["card"]) for m in payload["messages"]
|
||||||
|
if m["tier"] == tier}
|
||||||
|
|
||||||
|
|
||||||
|
print(f" blank power request entity on card 2: {_r['card']['messages']}")
|
||||||
|
check("a blank power request entity on the second card is refused beside "
|
||||||
|
"that card's field, and not beside the list as well",
|
||||||
|
("inverters.setpoint_entity", 1) in _at(_r["card"])
|
||||||
|
and ("inverters", None) not in _at(_r["card"]))
|
||||||
|
print(f" two cards on one entity: {_r['same']['messages']}")
|
||||||
|
check("two cards on one entity are refused beside that field on both cards",
|
||||||
|
{("inverters.setpoint_entity", 0), ("inverters.setpoint_entity", 1)}
|
||||||
|
== _at(_r["same"]))
|
||||||
|
check("a peak reserve under the cut-out level lands on the peak reserve and "
|
||||||
|
"on each card's cut-out level",
|
||||||
|
{("peak_soc_floor", None), ("inverters.bms_soc_floor", 0),
|
||||||
|
("inverters.bms_soc_floor", 1)} <= _at(_r["floor"]))
|
||||||
|
check("a model no blob can say is refused beside its field, in plain words",
|
||||||
|
_r["derive"]["messages"] == [{
|
||||||
|
"field": "price_provider", "card": None, "tier": "bad",
|
||||||
|
"text": W["derive"]["no_source"]["description"]}])
|
||||||
|
check("a refused save answers 422 with the same placement and writes nothing",
|
||||||
|
_r["save"][0] == 422 and ("inverters.setpoint_entity", 1)
|
||||||
|
in _at(_r["save"][1]) and _sup.writes == [])
|
||||||
|
_fixed = U.options_to_ui(dict(TWO, static_profile="not json"))
|
||||||
|
check("a sentence about a key the page derives goes to the field that writes "
|
||||||
|
"it, or to the visible choice that sets it",
|
||||||
|
uipage.targets("static_profile", "", _fixed) == [("ui.contract", None)]
|
||||||
|
and uipage.targets("max_w", "", U.options_to_ui(SITE))
|
||||||
|
== [("inverters.max_w", 0)]
|
||||||
|
and uipage.targets("pv_entity", "", _fixed) == [("ui.solar", None)])
|
||||||
|
check("a sentence naming no setting on the page is kept, with no field",
|
||||||
|
uipage.anchors(["Something odd."], [], _fixed, ["gain"])
|
||||||
|
== [{"field": None, "card": None, "tier": "bad",
|
||||||
|
"text": "Something odd."}])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n5. a stale save names the fields that changed elsewhere")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def _stale(s, root, sup):
|
||||||
|
m = await _get(s, root + "config/model?lang=en")
|
||||||
|
sup.options["peak_cap_w"] = 5000 # the Configuration tab saves
|
||||||
|
model = copy.deepcopy(m["ui"])
|
||||||
|
model["gain"] = 0.4
|
||||||
|
return await _post(s, root + "config/save",
|
||||||
|
{"model": model, "fingerprint": m["fingerprint"]})
|
||||||
|
|
||||||
|
|
||||||
|
_r, _sup = asyncio.run(session(TWO, _stale))
|
||||||
|
check("409 with the page fields to look at, and nothing written",
|
||||||
|
_r[0] == 409 and _r[1]["kind"] == "stale"
|
||||||
|
and "peak_cap_w" in _r[1]["changed_fields"] and _sup.writes == []
|
||||||
|
and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n6. the view's words keep to STYLE")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(node, path=""):
|
||||||
|
for k, v in node.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
yield from _texts(v, f"{path}{k}.")
|
||||||
|
elif isinstance(v, str):
|
||||||
|
yield f"{path}{k}", v
|
||||||
|
|
||||||
|
|
||||||
|
_long = [(p, t) for p, t in _texts(W)
|
||||||
|
if len(t.split()) > (25 if p.endswith("description") else 6)
|
||||||
|
or (p.endswith("description") and len(re.findall(r"[.!?](?:\s|$)", t)) > 1)
|
||||||
|
or ";" in t]
|
||||||
|
check(f"every settings string is one sentence of at most 25 words, or a "
|
||||||
|
f"name of at most 6 ({_long})", not _long)
|
||||||
|
_labels = [c[1] for _g, f in _flat for c in f.get("choices", [])]
|
||||||
|
check("every choice, including the region with its year, is at most 6 words",
|
||||||
|
all(len(x.split()) <= 6 for x in _labels))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all passed")
|
||||||
@@ -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",
|
||||||
@@ -119,13 +237,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