# UX-15 — setup-only mode: a new install serves the wizard **Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card 6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4. ## 1. The rule `config.yaml` ships `setpoint_entity: ""`, so a new install's options are refused by rule 1 of `validate_startup_config()`: no inverter to control. Until now that was exit 78 before any page was served, and the installer had to fill in the entity on the Configuration tab before the wizard could open. The add-on now starts in **setup-only mode** when, and only when (`setuponly.applies()`): - the options name no inverter at all: no flat `setpoint_entity` and no `inverters` entry, and - rule 1's refusal (`no_power_request_problem()`) is the gate's **only** refusal. In setup-only mode it serves the page, says it is in setup (banner, `/status` `setup_only: true`, a WARNING log line) and opens the wizard when the page is opened with no route (`setup_needed` is `no_power_request`). After the wizard's save and restart the normal gate applies to the saved options. ## 2. What still exits 78, and why Everything else the gate refuses, including: - a complete configuration that is refused, e.g. `maintenance_charge_w` above `max_w`; - no power request entity **and** any other refusal; - an `inverters` list whose entry has a blank power request entity. These are configurations somebody edited. The Configuration tab stored them, the log's refusal banner names each problem, and the tab is where they are fixed (UX-02 §4). Serving the wizard there would put a six-step setup in front of someone who needs to change one field. A narrow rule also keeps the setup-only decision to one comparison against one known sentence, so a new validator rule cannot widen it by accident: any other refusal fails the equality and exits 78. The cost: an installer who first sets, say, a DSMR meter with no entities on the Configuration tab and leaves the power request entity empty gets exit 78, not the wizard. The log then names both fields. ## 3. Why it cannot command Setup-only mode builds nothing that commands. `amain()` branches to `setuponly.serve()` before the MQTT broker, the controller, the meter task and the tariff task are built, and it returns from there when the Supervisor stops it. What serves the page is `SetupOnly`, which holds no inverter. Three checks make it structural rather than a convention: 1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`). `call()` refuses before it sends anything, and every service call goes through it: `number.set_value` (`set_number()`), notifications, all of them. The flag is fixed at construction, and there is no setter. 2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller` refuses one that cannot.** So neither can be built on the wrong one. 3. **`/action` answers 409** in setup-only mode, before `handle_action()`. Start, Stop and the battery cycle do nothing. No MQTT client is created, so nothing is published. The page's other routes already read only, or write the add-on's own options (UX-02 §6). Setup-only mode writes no Store file. The wizard's save writes `setup_done`, as in any mode. So a new install that is set up on the Configuration tab instead still has a fresh Store on its first normal boot, and the first-run rule (UX-13 §2) sends it to the wizard as before. ## 4. How it is held - `test_setup_only.py` part 1: when it applies, including the three exit-78 cases above. Part 2: the read-only write path refuses with nothing sent, and the two constructors refuse. Part 3 boots the real `main()` on the shipped defaults against a fake Supervisor and Home Assistant over HTTP and logs every request: the page is served, `/status` says setup-only, Start and the cycle get 409, and there are 0 service calls and no MQTT request, also with `auto_start` on and a Store that says control was started. The wizard's save is then booted: it passes the normal gate, does not open the wizard again, and every write is 0 W (shadow). Part 4: the refused configurations exit 78 and serve nothing. - Mutations, each red: `applies()` always false or always true, the read-only refusal removed, the Controller's check removed, `amain()` giving setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the `return` after `serve()` removed. - TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped defaults and an empty `/data` made 0 service calls and no MQTT request in 3.5 minutes of setup-only mode, counted in both the stand-in and the rig shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`, and after the restart it ran in shadow: 11 of 11 writes were `set_value 0.0`. ## 5. The wizard fixes in the same card - **F1.** `/config/validate` converts what it can of a page model (`uimodel.ui_to_options_partial()`), so a conversion error blocks its own step beside its field and the live checks still run on the rest. A refusal about a key the failed conversion writes is left out, because it would judge the stored value. The save still refuses such a model (422, with `steps`). - **F2.** A save from the page model sends `base`, the model it opened. The add-on writes only the keys whose value differs from `base`, onto the blob stored now (`uipage.only_edits()`). So "save again to keep yours" after a 409 keeps the other surface's changes. A key both surfaces changed takes this page's value, which is what the second press after the banner means. - **Meter entity.** On the last step a meter entity Home Assistant does not have is red, like a missing power request entity, so a skipped kettle test cannot let it through. In the Installer view it stays a warning. - **Step 1 after a skip.** When the kettle test saw the reading fall and was then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).