"""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 from .validate import power_request_entities UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", "en.json") # A choice the page cannot make, only keep: odd tariff windows (P5 editor). ONLY_IF_HELD = ("custom",) _TIERS = ("bad", "warn", "info") def ui_text() -> dict: with io.open(UI_TEXT, encoding="utf-8") as f: return json.load(f) def _roster(): """The inverter list field's key, read off the table.""" return next(f.key for _g, _p, f in L.fields() if f.control == L.INVERTER_LIST) def _node(schema_ui, key): """The Supervisor's schema entry for `key` (`list.sub` too), or {}.""" nodes, path = schema_ui or [], key.split(".") for step in path[:-1]: parent = next((n for n in nodes if n.get("name") == step), None) nodes = (parent or {}).get("schema") or [] return next((n for n in nodes if n.get("name") == path[-1]), None) or {} def _words(translations, lang, key, T): """(label, help) for a field: ui_text for `ui.` fields, else the translation without its `Group · ` prefix, and the raw key last.""" if key.startswith("ui."): entry = T["fields"].get(key[3:], {}) return entry.get("name", key), entry.get("description", "") path = tuple(key.split(".")[:-1]) name = key.split(".")[-1] label, _lang = label_of(translations, lang, name, path) text, _lang = help_of(translations, lang, name, path) return split_group(label)[1], text def _choices(fld, node, T, value_type): """[[value, label, only_if_held], ...] for a choice control, or None.""" if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE): return None name = fld.key[3:] if fld.key.startswith("ui.") else fld.key words = T["choices"].get(name) or {} if fld.key.startswith("ui."): values = list(words) elif node.get("options"): values = [v for v in node["options"] if not words or v in words] elif value_type == "integer" and node.get("lengthMax") is not None: values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1)) if words: values = [v for v in values if str(v) in words] else: return [] out = [] for v in values: label = words.get(str(v)) if label is None: label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}") if isinstance(v, int) else str(v)) preset = U.REGION_PRESETS.get(v) if fld.fills else None if preset: label = f"{label}, " + T["settings"]["region_year"].replace( "{year}", str(preset["year"])) out.append([v, label, v in ONLY_IF_HELD]) return out def _field(fld, schema_ui, translations, lang, T): node = _node(schema_ui, fld.key) label, text = _words(translations, lang, fld.key, T) if fld.unit: # The page shows the unit beside the control, in its own scale (kW), # so the translation's "(W)" goes; "(optional)" stays. label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label) value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.") else node.get("type") or "") out = {"key": fld.key, "control": fld.control, "label": label, "help": text, "unit": fld.unit, "scale": fld.scale, "step": fld.step, "kind": fld.kind, "show": [list(c) for c in fld.show], "applies": fld.applies, "wizard": fld.wizard, "type": value_type, "optional": not node.get("required", False), # A choice mirrored into a field also shows (and finds) its keys. "writes": sorted(k for k, how in L.DERIVED.items() if how in (fld.key, fld.mirror))} if node.get("lengthMin") is not None: out["min"] = node["lengthMin"] if node.get("lengthMax") is not None: out["max"] = node["lengthMax"] choices = _choices(fld, node, T, value_type) if choices is not None: out["choices"] = choices name = fld.key[3:] if fld.key.startswith("ui.") else fld.key later = T.get("choices_later", {}).get(name) if later: out["later"] = [[v, words] for v, words in later.items()] hints = T.get("choice_help", {}).get(name) if hints: out["hints"] = {v: e.get("description", "") for v, e in hints.items()} if fld.mirror: out["mirror"] = fld.mirror if fld.fills: out["fills"] = fld.fills out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]} for rid, p in U.REGION_PRESETS.items()} roster = _roster() if fld.key.startswith(roster + "."): sub = fld.key[len(roster) + 1:] if L.DERIVED.get(sub) == roster: out["flat"] = sub # how the one flat inverter stores it if fld.control == L.INVERTER_LIST: out["fields"] = [_field(sub, schema_ui, translations, lang, T) for sub in L.ROSTER_FIELDS] out["count"] = U.COUNT out["grow"] = dict(U.GROWN) out["shape"] = next(iter(U.GROWN)) return out def layout(schema_ui, translations, lang="en") -> dict: """Every group, part and field of the Installer view, as the page draws it. Built from the table only: a field the table places is on the page.""" T = ui_text() groups = [] for g in L.GROUPS: parts = [] for p in g.parts: words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {} parts.append({"id": p.id, "name": words.get("name", ""), "description": words.get("description", ""), "collapsed": p.collapsed, "fields": [_field(f, schema_ui, translations, lang, T) for f in p.fields]}) words = T["groups"].get(g.id, {}) applies = sorted({f.applies for p in g.parts for f in p.fields}) groups.append({"id": g.id, "name": words.get("name", g.id), "description": words.get("description", ""), "support_only": g.support_only, "applies": [T["applies"][a]["name"] for a in applies], "parts": parts}) return {"groups": groups} # --------------------------------------------------------------------------- # # The page model -> options, for the validator and the save # --------------------------------------------------------------------------- # def _same(a, b) -> bool: """Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not.""" if isinstance(a, bool) or isinstance(b, bool): return type(a) is type(b) and a == b if isinstance(a, (int, float)) and isinstance(b, (int, float)): return a == b if isinstance(a, list) and isinstance(b, list): return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b)) if isinstance(a, dict) and isinstance(b, dict): return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) return type(a) is type(b) and a == b def to_options(model: dict, stored: dict | None = None) -> dict: """The options blob for a posted page model. A value equal to the stored one is written as stored, in the stored key order, so a save that changed nothing writes an identical blob (the browser reads 3.0 as 3).""" 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) -> 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} def derive_messages(errors, problems, notes, model, keys): """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) for err in errors] + anchors(kept, notes, model, keys)) # --------------------------------------------------------------------------- # # Where a sentence goes on the page # --------------------------------------------------------------------------- # # `inverters.max_w`, or `inverters[1].setpoint_entity` for one card. _SUB = re.compile(r"(? 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)) # --------------------------------------------------------------------------- # # 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