Rows and banners are built once with their Dutch beside them (messages.Bi) and status_payload() picks one by the page's language. Settings notes and the wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by the posted language. The peak forecast label drops "(W)" in EN and NL. Co-Authored-By: Claude <noreply@anthropic.com>
409 lines
18 KiB
Python
409 lines
18 KiB
Python
"""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 os
|
|
import re
|
|
|
|
from . import messages
|
|
from . import uilayout as L
|
|
from . import uitext
|
|
from . import uimodel as U
|
|
from .configui import help_of, label_of, problems_by_field, split_group
|
|
from .validate import note_text, power_request_entities
|
|
|
|
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
|
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
|
# the Configuration tab and the page only keeps it (PRICE-01).
|
|
ONLY_IF_HELD = ("price_service",)
|
|
_TIERS = ("bad", "warn", "info")
|
|
|
|
|
|
def ui_text(lang="en") -> dict:
|
|
"""The page words in `lang`, English where Dutch has no key (P5)."""
|
|
return copy.deepcopy(uitext.words(lang))
|
|
|
|
|
|
def _roster():
|
|
"""The inverter list field's key, read off the table."""
|
|
return next(f.key for _g, _p, f in L.fields()
|
|
if f.control == L.INVERTER_LIST)
|
|
|
|
|
|
def _node(schema_ui, key):
|
|
"""The Supervisor's schema entry for `key` (`list.sub` too), or {}."""
|
|
nodes, path = schema_ui or [], key.split(".")
|
|
for step in path[:-1]:
|
|
parent = next((n for n in nodes if n.get("name") == step), None)
|
|
nodes = (parent or {}).get("schema") or []
|
|
return next((n for n in nodes if n.get("name") == path[-1]), None) or {}
|
|
|
|
|
|
def _words(translations, lang, key, T):
|
|
"""(label, help) for a field: ui_text for `ui.` fields, else the
|
|
translation without its `Group · ` prefix, and the raw key last."""
|
|
if key.startswith("ui."):
|
|
entry = T["fields"].get(key[3:], {})
|
|
return entry.get("name", key), entry.get("description", "")
|
|
path = tuple(key.split(".")[:-1])
|
|
name = key.split(".")[-1]
|
|
label, _lang = label_of(translations, lang, name, path)
|
|
text, _lang = help_of(translations, lang, name, path)
|
|
return split_group(label)[1], text
|
|
|
|
|
|
def _choices(fld, node, T, value_type):
|
|
"""[[value, label, only_if_held], ...] for a choice control, or None."""
|
|
if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE):
|
|
return None
|
|
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
|
|
words = T["choices"].get(name) or {}
|
|
if fld.key.startswith("ui."):
|
|
values = list(words)
|
|
elif node.get("options"):
|
|
values = [v for v in node["options"] if not words or v in words]
|
|
elif value_type == "integer" and node.get("lengthMax") is not None:
|
|
values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1))
|
|
if words:
|
|
values = [v for v in values if str(v) in words]
|
|
else:
|
|
return []
|
|
out = []
|
|
for v in values:
|
|
label = words.get(str(v))
|
|
if label is None:
|
|
label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}")
|
|
if isinstance(v, int) else str(v))
|
|
preset = U.REGION_PRESETS.get(v) if fld.fills else None
|
|
if preset:
|
|
label = f"{label}, " + T["settings"]["region_year"].replace(
|
|
"{year}", str(preset["year"]))
|
|
out.append([v, label, v in ONLY_IF_HELD])
|
|
return out
|
|
|
|
|
|
def _field(fld, schema_ui, translations, lang, T):
|
|
node = _node(schema_ui, fld.key)
|
|
label, text = _words(translations, lang, fld.key, T)
|
|
if fld.unit:
|
|
# The page shows the unit beside the control, in its own scale (kW),
|
|
# so the translation's "(W)" goes; "(optional)" stays.
|
|
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
|
|
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
|
|
else node.get("type") or "")
|
|
out = {"key": fld.key, "control": fld.control, "label": label,
|
|
"help": text, "unit": fld.unit, "scale": fld.scale,
|
|
"step": fld.step, "kind": fld.kind,
|
|
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
|
"wizard": fld.wizard, "simple": fld.simple, "note": fld.note,
|
|
"type": value_type, "optional": not node.get("required", False),
|
|
# A choice mirrored into a field also shows (and finds) its keys.
|
|
"writes": sorted(k for k, how in L.DERIVED.items()
|
|
if how in (fld.key, fld.mirror))}
|
|
if node.get("lengthMin") is not None:
|
|
out["min"] = node["lengthMin"]
|
|
if node.get("lengthMax") is not None:
|
|
out["max"] = node["lengthMax"]
|
|
choices = _choices(fld, node, T, value_type)
|
|
if choices is not None:
|
|
out["choices"] = choices
|
|
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
|
|
later = T.get("choices_later", {}).get(name)
|
|
if later:
|
|
out["later"] = [[v, words] for v, words in later.items()]
|
|
hints = T.get("choice_help", {}).get(name)
|
|
if hints:
|
|
out["hints"] = {v: e.get("description", "")
|
|
for v, e in hints.items()}
|
|
if fld.control == L.PERIOD_LIST:
|
|
# P5: the day choices of one time window, in the editor's order.
|
|
names = T["choices"]["period_days"]
|
|
out["days"] = [[d, names.get(d, d)] for d in U.PERIOD_DAYS]
|
|
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(lang)
|
|
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)."""
|
|
return _settle(U.ui_to_options(model), stored)
|
|
|
|
|
|
def to_options_partial(model: dict, stored: dict | None = None):
|
|
"""(blob, [DeriveError]) for the checks: `uimodel.ui_to_options_partial`,
|
|
settled like `to_options`. Never saved (UX-15 F1)."""
|
|
out, errors = U.ui_to_options_partial(model)
|
|
return _settle(out, stored), errors
|
|
|
|
|
|
def only_edits(mine: dict, base: dict, stored: dict) -> dict:
|
|
"""The blob to save: `stored`, with the keys whose value in `mine` (this
|
|
page's model) differs from `base` (the model it opened) set to `mine`'s.
|
|
A key this page never edited keeps what is stored now, even when another
|
|
surface changed it meanwhile (UX-13 P4 test, F2)."""
|
|
edits = {k: v for k, v in mine.items()
|
|
if k not in base or not _same(v, base[k])}
|
|
gone = {k for k in base if k not in mine}
|
|
out = {k: copy.deepcopy(edits.get(k, v)) for k, v in stored.items()
|
|
if k not in gone}
|
|
out.update({k: copy.deepcopy(v) for k, v in edits.items()
|
|
if k not in out})
|
|
return out
|
|
|
|
|
|
def _settle(out: dict, stored: dict | None) -> dict:
|
|
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, lang="en") -> dict:
|
|
T = uitext.words(lang)
|
|
words = T["settings"]["derive"]
|
|
text = words.get(err.code, words["unknown"])["description"]
|
|
args = dict(getattr(err, "values", None) or {})
|
|
# A time window's day is shown by name (P5 period editor).
|
|
if "day" in args:
|
|
args["day"] = T["choices"]["period_days"].get(args["day"], args["day"])
|
|
return {"field": err.field or None, "card": None, "tier": "bad",
|
|
"text": uitext.say(text, **args)}
|
|
|
|
|
|
def derive_messages(errors, problems, notes, model, keys, lang="en"):
|
|
"""The page's messages when part of the model could not become options:
|
|
each conversion's own sentence beside its field, then the checks of the
|
|
rest. A refusal or note about a key the failed conversion writes is left
|
|
out, because it judges the stored value, not what the page shows."""
|
|
failed = {k for err in errors for k in err.keys}
|
|
by_key, unanchored = problems_by_field(problems, keys)
|
|
kept = [t for k, texts in by_key.items() if k not in failed
|
|
for t in texts] + list(unanchored)
|
|
kept = [p for p in problems if p in kept]
|
|
notes = [n for n in notes if not set(n.keys or ()) & failed]
|
|
return ([derive_message(err, lang) for err in errors]
|
|
+ anchors(kept, notes, model, keys, lang))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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, lang="en") -> list:
|
|
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
|
None for a sentence that names no setting on the page. A note with a Dutch
|
|
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
|
|
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"
|
|
text = note_text(note, lang)
|
|
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, 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))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
|
|
# --------------------------------------------------------------------------- #
|
|
# Store keys (app/store.py), not options. SETUP_DONE is written when the
|
|
# wizard saves. STARTED is the one control's Start and Stop write.
|
|
SETUP_DONE = "setup_done"
|
|
STARTED = "auto"
|
|
|
|
|
|
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
|
|
"""Why the page opens the wizard by itself, or None.
|
|
|
|
"no_power_request": the stored options name no inverter to control, so
|
|
the add-on cannot start on them. "first_start": a new install. This boot
|
|
found no Store file (`fresh`), the wizard never saved and control was never
|
|
started or stopped. `fresh` is what tells a new install from an old site
|
|
that was never started, e.g. one left in shadow for weeks (TEST-23).
|
|
`store_data` is the Store's dict (None: unknown, so no), `started` is
|
|
whether control is started now."""
|
|
if not power_request_entities(stored or {}):
|
|
return "no_power_request"
|
|
if store_data is None or not fresh:
|
|
return None
|
|
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
|
return None
|
|
return "first_start"
|
|
|
|
|
|
def setup_spec() -> dict:
|
|
"""What the wizard needs beyond each field's step: read off the table."""
|
|
kettle = {}
|
|
for source, (read, outcomes) in L.KETTLE.items():
|
|
kettle[source] = {"read": [[f, sign] for f, sign in read]}
|
|
for way, (say, sets) in outcomes.items():
|
|
kettle[source][way] = {"say": say, "sets": sets}
|
|
field, on, off = L.SHADOW
|
|
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
|
|
"optional": list(L.OPTIONAL_STEPS),
|
|
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
|
|
"sources": kettle},
|
|
"shadow": {"field": field, "on": on, "off": off}}
|
|
|
|
|
|
def kettle(source, before_w, after_w):
|
|
"""The kettle test's verdict, as the page reaches it (settings.js
|
|
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
|
|
for a source the page cannot read."""
|
|
spec = L.KETTLE.get(source)
|
|
if spec is None:
|
|
return None
|
|
change = float(after_w) - float(before_w)
|
|
if abs(change) < L.KETTLE_MIN_W:
|
|
return "none", None, change
|
|
say, sets = spec[1]["up" if change > 0 else "down"]
|
|
return say, (dict(sets) if sets is not None else None), change
|
|
|
|
|
|
def step_of(field_key, model) -> int:
|
|
"""The wizard step a sentence on this page field blocks: the field's own
|
|
step while it is shown, else the last step, which lists everything."""
|
|
fld = L.field(field_key) if field_key else None
|
|
if fld is None or not fld.wizard or not L.visible(fld, model):
|
|
return L.FINAL_STEP
|
|
return fld.wizard
|
|
|
|
|
|
def step_problems(messages, model) -> dict:
|
|
"""{"1": n, ...}: how many refusals block each wizard step."""
|
|
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
|
|
for m in messages:
|
|
if m.get("tier") == "bad":
|
|
out[str(step_of(m.get("field"), model))] += 1
|
|
return out
|