"""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": "", "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__)