Files
goodwe-addon/goodwe_controller/tools/ux13_wizard_blobs.py
T
adminandClaude Opus 5.5 159d7461d8 UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only
refusal, the add-on now serves the setup wizard in setup-only mode instead
of exiting 78. It builds no controller, MQTT client or meter task, its
HomeAssistant is read-only (every service call refuses before sending),
Controller refuses a read-only HomeAssistant, and /action answers 409.
Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md.

Wizard fixes from the UX-13 P4 test:
- F1: /config/validate converts what it can, so a conversion error blocks
  its own step and the live checks still run; save 422 carries steps.
- F2: saves send the model the page opened (base); only keys this page
  changed are written onto the blob stored now.
- A meter entity missing from Home Assistant is red on the last step.
- Step 1 says the reading went down after a skip that followed a fall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:21 +02:00

256 lines
11 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, 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},
}
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"}):
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__)