- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English fallback per key; the page picks by browser language. Home's sentences move into ui_text `home_words`, so /status?lang=nl builds them in Dutch. - test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon lists for both languages, no inline details, untranslated Dutch caught. Shared rules moved unchanged to tools/style_rules.py. - tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot. - "Other time windows": a list editor (ui.periods) that builds static_profile and names bad times, empty prices, overlaps and gaps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""UX-13 P4: every options blob the setup wizard can save, and the Supervisor's
|
|
own validator run over them.
|
|
|
|
python3 tools/ux13_wizard_blobs.py --dump DIR # write the blobs
|
|
docker run --rm --network none -v "$PWD:/w:ro" -v DIR:/blobs:ro \
|
|
--entrypoint python3 \
|
|
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
|
/w/tools/ux13_wizard_blobs.py --supervisor /blobs
|
|
|
|
The wizard edits the page model one step at a time and saves it through
|
|
uipage.to_options(), like the Installer view. `blobs()` walks each step's
|
|
choices (meter source and sign, one to three inverters, solar rows, every
|
|
contract and price source, time windows (P5), region, peak limit, shadow
|
|
mode) from a new
|
|
install as shipped, one with a power request entity, and the TEST-23 sites, and keeps what the shipped validator
|
|
accepts, because the wizard saves nothing it refuses. test_setup.py checks
|
|
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
|
|
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
|
|
"""
|
|
|
|
import copy
|
|
import glob
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- the wizard's steps, as edits to the page model ------------------------ #
|
|
METERS = {
|
|
"entity": {"meter_source": "off", "meter_entity": "sensor.grid"},
|
|
"entity_flipped": {"meter_source": "off", "meter_entity": "sensor.grid",
|
|
"meter_invert": True},
|
|
"signed": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net"},
|
|
"signed_3": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
|
"meter_phases": 3, "p1_phase_net_entities": [
|
|
"sensor.l1", "sensor.l2", "sensor.l3"]},
|
|
"dsmr": {"meter_source": "ha_dsmr", "p1_import_entity": "sensor.imp",
|
|
"p1_export_entity": "sensor.exp"},
|
|
"homewizard": {"meter_source": "homewizard_local",
|
|
"p1_host": "192.0.2.50"},
|
|
"mqtt": {"meter_source": "mqtt_p1", "meter_mqtt_topic": "p1/power"},
|
|
}
|
|
|
|
|
|
def _card(i, flat=False, **more):
|
|
card = {"setpoint_entity": f"number.inv{i}_power",
|
|
"soc_entity": f"sensor.inv{i}_level",
|
|
"batt_entity": f"sensor.inv{i}_battery", "batt_invert": False,
|
|
"max_w": 2500, "bms_soc_floor": 10}
|
|
if flat:
|
|
card.update(name="", capacity_kwh=None)
|
|
else:
|
|
card.update(name=f"inverter-{i}", capacity_kwh=5.0)
|
|
card.update(more)
|
|
return card
|
|
|
|
|
|
ROSTERS = {
|
|
"one": ("flat", [_card(1, flat=True)]),
|
|
"one_flipped": ("flat", [_card(1, flat=True, batt_invert=True,
|
|
max_w=3000)]),
|
|
"two": ("list", [_card(1), _card(2, max_w=4600, bms_soc_floor=15)]),
|
|
"three": ("list", [_card(1), _card(2), _card(3, capacity_kwh=9.6)]),
|
|
}
|
|
SOLAR = {
|
|
"none": [],
|
|
"one": [{"entity": "sensor.pv", "reversed": False}],
|
|
"one_reversed": [{"entity": "sensor.pv", "reversed": True}],
|
|
"two": [{"entity": "sensor.pv_a", "reversed": False},
|
|
{"entity": "sensor.pv_b", "reversed": True}],
|
|
}
|
|
PRICES = {
|
|
"none": {"ui.contract": "none", "ui.fallback": "none"},
|
|
"fixed": {"ui.contract": "fixed", "ui.fallback": "fixed",
|
|
"ui.fixed_price": 0.31},
|
|
"daynight": {"ui.contract": "daynight", "ui.fallback": "daynight",
|
|
"ui.day_price": 0.34, "ui.night_price": 0.25},
|
|
"dynamic": {"ui.contract": "dynamic", "price_provider": "generic_dayahead",
|
|
"ui.fallback": "none"},
|
|
"dynamic_fixed": {"ui.contract": "dynamic",
|
|
"price_provider": "generic_dayahead",
|
|
"ui.fallback": "fixed", "ui.fixed_price": 0.3},
|
|
"dynamic_daynight": {"ui.contract": "dynamic",
|
|
"price_provider": "generic_dayahead",
|
|
"ui.fallback": "daynight", "ui.day_price": 0.3,
|
|
"ui.night_price": 0.2},
|
|
"formula": {"ui.contract": "dynamic", "price_provider": "custom_formula",
|
|
"price_formula": "<the documented example>",
|
|
"ui.fallback": "fixed",
|
|
"ui.fixed_price": 0.3},
|
|
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
|
|
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
|
|
"ui.fixed_price": 0.3},
|
|
# P5: the time-window editor, odd windows the day/night preset cannot say.
|
|
"periods": {"ui.contract": "custom", "ui.fallback": "custom",
|
|
"ui.periods": [
|
|
{"from": "06:30", "to": "21:15", "days": "mon-fri",
|
|
"price": 0.31},
|
|
{"from": "21:15", "to": "06:30", "days": "mon-fri",
|
|
"price": 0.19},
|
|
{"from": "00:00", "to": "12:00", "days": "sat",
|
|
"price": 0.2},
|
|
{"from": "12:00", "to": "24:00", "days": "sat",
|
|
"price": 0.25},
|
|
{"from": "00:00", "to": "24:00", "days": "sun",
|
|
"price": 0.18}]},
|
|
"periods_one": {"ui.contract": "custom", "ui.fallback": "custom",
|
|
"ui.periods": [{"from": "00:00", "to": "00:00",
|
|
"days": "mon-sun", "price": 0.29}]},
|
|
"dynamic_periods": {"ui.contract": "dynamic",
|
|
"price_provider": "generic_dayahead",
|
|
"ui.fallback": "custom", "ui.periods": [
|
|
{"from": "07:00", "to": "23:00",
|
|
"days": "mon-sun", "price": 0.32},
|
|
{"from": "23:00", "to": "07:00",
|
|
"days": "mon-sun", "price": 0.21}]},
|
|
}
|
|
REGIONS = {"flanders": {"ui.region": "flanders"},
|
|
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
|
|
PEAKS = {"low": {"peak_cap_w": 2500, "peak_forecast_entity": ""},
|
|
"forecast": {"peak_cap_w": 4200,
|
|
"peak_forecast_entity": "sensor.peak_forecast"},
|
|
"high": {"peak_cap_w": 10000, "peak_forecast_entity": ""}}
|
|
SHADOWS = ("on", "auto") # the switch on, or off (uilayout.SHADOW)
|
|
BASE = {"meter": "entity", "roster": "one", "solar": "none",
|
|
"prices": "none", "region": "own", "peak": "low", "shadow": "on"}
|
|
|
|
|
|
def plans():
|
|
"""Each step's choices once, with the other steps at BASE, then a few
|
|
whole sites that combine them."""
|
|
axes = {"meter": METERS, "roster": ROSTERS, "solar": SOLAR,
|
|
"prices": PRICES, "region": REGIONS, "peak": PEAKS,
|
|
"shadow": dict.fromkeys(SHADOWS)}
|
|
seen = []
|
|
for axis, choices in axes.items():
|
|
for choice in choices:
|
|
plan = dict(BASE, **{axis: choice})
|
|
if plan not in seen:
|
|
seen.append(plan)
|
|
for plan in (
|
|
{"meter": "dsmr", "roster": "two", "solar": "two",
|
|
"prices": "dynamic_daynight", "region": "flanders",
|
|
"peak": "forecast", "shadow": "on"},
|
|
{"meter": "signed_3", "roster": "three", "solar": "one_reversed",
|
|
"prices": "daynight", "region": "flanders", "peak": "high",
|
|
"shadow": "auto"},
|
|
{"meter": "homewizard", "roster": "one_flipped", "solar": "one",
|
|
"prices": "fixed", "region": "own", "peak": "forecast",
|
|
"shadow": "on"},
|
|
{"meter": "mqtt", "roster": "two", "solar": "none",
|
|
"prices": "formula", "region": "flanders", "peak": "low",
|
|
"shadow": "on"},
|
|
{"meter": "dsmr", "roster": "two", "solar": "one",
|
|
"prices": "periods", "region": "flanders", "peak": "forecast",
|
|
"shadow": "on"}):
|
|
seen.append(plan)
|
|
return seen
|
|
|
|
|
|
def apply(model, plan):
|
|
"""The page model after the wizard's six steps, as the page leaves it."""
|
|
from app import uilayout as L, uimodel as U
|
|
m = copy.deepcopy(model)
|
|
m.update(copy.deepcopy(METERS[plan["meter"]]))
|
|
shape, cards = ROSTERS[plan["roster"]]
|
|
cards = copy.deepcopy(cards)
|
|
if m.get("inverter_shape") == "list":
|
|
# A site stored as a list stays one: one card keeps a name.
|
|
for i, card in enumerate(cards, 1):
|
|
card.update(name=card["name"] or f"inverter-{i}",
|
|
capacity_kwh=card["capacity_kwh"] or 5.0)
|
|
m["inverters"] = cards
|
|
m[U.COUNT] = len(cards)
|
|
if shape == "list":
|
|
m.update(U.GROWN)
|
|
m["ui.solar"] = copy.deepcopy(SOLAR[plan["solar"]])
|
|
m.update(copy.deepcopy(PRICES[plan["prices"]]))
|
|
if plan["prices"] == "formula":
|
|
from app.custom_formula import _LEGS
|
|
m["price_formula"] = _LEGS["price_formula"].example
|
|
m.update(copy.deepcopy(REGIONS[plan["region"]]))
|
|
if plan["region"] != "own":
|
|
m["price_taxes_eur_kwh"] = U.REGION_PRESETS[plan["region"]][
|
|
"taxes_eur_kwh"]
|
|
m.update(PEAKS[plan["peak"]])
|
|
field, on, off = L.SHADOW
|
|
m[field] = on if plan["shadow"] == "on" else (
|
|
m.get(field) if m.get(field) != on else off)
|
|
return m
|
|
|
|
|
|
def starts():
|
|
"""(name, stored options) the wizard opens on: a new install as shipped
|
|
(config.yaml's defaults, served in setup-only mode since UX-15), one with a
|
|
power request entity typed in the Configuration tab, and the TEST-23
|
|
sites."""
|
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
|
import ux13_demo as D
|
|
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
|
|
out = [("empty", D.defaults(D.CONFIG)), ("new", new)]
|
|
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
|
|
"*.json")))[:3]:
|
|
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
|
|
**json.load(open(f)))))
|
|
return out
|
|
|
|
|
|
def blobs():
|
|
"""[(name, blob, refusals)] for every plan from every start."""
|
|
sys.path.insert(0, HERE)
|
|
from app import uimodel as U, uipage
|
|
from app.validate import validate_startup_config
|
|
out = []
|
|
for start, stored in starts():
|
|
model = json.loads(json.dumps(U.options_to_ui(stored)))
|
|
for plan in plans():
|
|
name = start + "-" + "-".join(f"{k}.{v}" for k, v in plan.items())
|
|
posted = browser(json.loads(json.dumps(apply(model, plan))))
|
|
blob = uipage.to_options(posted, stored)
|
|
out.append((name, blob, validate_startup_config(blob)))
|
|
return out
|
|
|
|
|
|
# --- run inside the Supervisor image --------------------------------------- #
|
|
def supervisor(folder):
|
|
sys.path.insert(0, "/usr/src/supervisor")
|
|
import voluptuous as vol
|
|
from pathlib import Path
|
|
from supervisor.apps.options import AppOptions
|
|
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
|
from supervisor.utils.yaml import read_yaml_file
|
|
cfg = SCHEMA_APP_CONFIG(read_yaml_file(Path(HERE) / "config.yaml"))
|
|
check = vol.Schema(vol.All(dict, AppOptions(
|
|
None, cfg["schema"], cfg["name"], "local_goodwe_controller")))
|
|
bad = 0
|
|
files = sorted(glob.glob(os.path.join(folder, "*.json")))
|
|
for f in files:
|
|
blob = json.load(open(f))
|
|
try:
|
|
got = check(copy.deepcopy(blob))
|
|
dropped = sorted(set(blob) - set(got))
|
|
ok = not dropped
|
|
why = f"dropped {dropped}" if dropped else f"{len(got)} keys"
|
|
except vol.Invalid as err:
|
|
ok, why = False, str(err)
|
|
bad += not ok
|
|
print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(f)}: {why}")
|
|
print(f"{len(files) - bad} of {len(files)} accepted by AppOptions "
|
|
f"({cfg['version']})")
|
|
return 1 if bad or not files else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if sys.argv[1:2] == ["--supervisor"]:
|
|
sys.exit(supervisor(sys.argv[2]))
|
|
if sys.argv[1:2] == ["--dump"]:
|
|
os.makedirs(sys.argv[2], exist_ok=True)
|
|
n = 0
|
|
for name, blob, refused in blobs():
|
|
if refused:
|
|
print("refused, not written:", name, refused[0])
|
|
continue
|
|
with open(os.path.join(sys.argv[2], name + ".json"), "w") as f:
|
|
json.dump(blob, f, indent=1)
|
|
n += 1
|
|
print(f"{n} blobs written to {sys.argv[2]}")
|
|
sys.exit(0)
|
|
print(__doc__)
|