Merge pull request 'UX-15: setup-only mode, and wizard fixes F1, F2, meter entity, step 1' (#147) from UX-15-setup-only into release/1.0
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
Reviewed and live-tested on TEST-23: 0 service calls in setup-only mode; review comment 1896. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #147.
This commit is contained in:
@@ -148,6 +148,11 @@ exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
|
|||||||
the reason it stays, and the reason the panel refuses to write a blob the
|
the reason it stays, and the reason the panel refuses to write a blob the
|
||||||
validator rejected: this surface must never be the thing that removes itself.
|
validator rejected: this surface must never be the thing that removes itself.
|
||||||
|
|
||||||
|
*Amended by [[UX-15-setup-only-mode]] (2026-09-28):* one refused configuration
|
||||||
|
keeps the panel: options that name no inverter at all and have no other
|
||||||
|
refusal, which is what a new install ships. The add-on then serves the page in
|
||||||
|
setup-only mode and commands nothing. Every other refusal still exits 78.
|
||||||
|
|
||||||
**The panel shows fewer refusals than the startup gate can, never more.** Two
|
**The panel shows fewer refusals than the startup gate can, never more.** Two
|
||||||
of `validate.py`'s rules compare a configured ceiling against the setpoint
|
of `validate.py`'s rules compare a configured ceiling against the setpoint
|
||||||
device's *own* reported range, which is only knowable by asking Home Assistant.
|
device's *own* reported range, which is only knowable by asking Home Assistant.
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ The page opens `#setup` only when it is opened with no route, and only when
|
|||||||
- **`no_power_request`**: the stored options name no inverter to control.
|
- **`no_power_request`**: the stored options name no inverter to control.
|
||||||
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
||||||
Store has no `setup_done` and no `auto` key, and control is not started.
|
Store has no `setup_done` and no `auto` key, and control is not started.
|
||||||
`auto` is the key Start/Stop writes. The startup gate exits 78 before the
|
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
|
||||||
controller writes the Store, so a new install's first good boot is fresh.
|
setup-only mode (UX-15) writes the Store, so a new install's first good boot
|
||||||
|
is fresh.
|
||||||
|
|
||||||
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
||||||
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
|
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
|
||||||
@@ -37,14 +38,12 @@ started or stopped it, is never sent. Without a Store the answer is no. The wiza
|
|||||||
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
||||||
not.
|
not.
|
||||||
|
|
||||||
**Not decided here, for the owner:** the add-on exits 78 before it serves the
|
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
|
||||||
page when the stored options name no power request entity, which is what a
|
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
|
||||||
brand-new install ships (config.yaml's `setpoint_entity: ""`). So on a truly
|
naming no inverter is the gate's only refusal, the add-on serves the page in
|
||||||
new install the installer fills in "Inverter setpoint entity" on the
|
setup-only mode, which commands nothing, and the wizard opens with
|
||||||
Configuration tab first, and the wizard opens on the next page visit.
|
`no_power_request`. Setup-only mode writes no Store file, so the first-run
|
||||||
Serving a setup-only page from a refused configuration would change the CFG-01
|
rule above still holds for an install set up on the Configuration tab.
|
||||||
startup gate and UX-02 §4 ("the panel is not available when it is most
|
|
||||||
needed"), so it is left as an open question.
|
|
||||||
|
|
||||||
## 3. The kettle test and the sign checks
|
## 3. The kettle test and the sign checks
|
||||||
|
|
||||||
@@ -96,6 +95,7 @@ fingerprint was not handed out by this process, it falls back to `changed`.
|
|||||||
it), step blocking, the first-run rule on new and TEST-23 sites, the live
|
it), step blocking, the first-run rule on new and TEST-23 sites, the live
|
||||||
checks, the save through the P2 path, the stale fix, and the words.
|
checks, the save through the P2 path, the stale fix, and the words.
|
||||||
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
|
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
|
||||||
install and three TEST-23 sites (88 accepted by the shipped validator). At P4
|
install and three TEST-23 sites (88 accepted by the shipped validator).
|
||||||
|
UX-15 added the shipped defaults as a start (setup-only mode). At P4
|
||||||
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
|
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
|
||||||
keys each.
|
keys each.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 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).
|
||||||
@@ -3233,19 +3233,34 @@ the last step.
|
|||||||
sign for you; with a net or import/export meter it tells you what to change.
|
sign for you; with a net or import/export meter it tells you what to change.
|
||||||
- **The last step** runs the add-on's own start checks and reads Home
|
- **The last step** runs the add-on's own start checks and reads Home
|
||||||
Assistant: the meter reading, that each power request entity exists, the
|
Assistant: the meter reading, that each power request entity exists, the
|
||||||
battery levels, and whether prices are known. A missing power request entity
|
battery levels, and whether prices are known. A missing power request or
|
||||||
is red, so it cannot be saved.
|
meter entity is red, so it cannot be saved.
|
||||||
- **Save and restart** saves, then restarts the add-on. Every inverter is set
|
- **Save and restart** saves, then restarts the add-on. Every inverter is set
|
||||||
to 0 W while it restarts, and with "Start in shadow mode" on (the default) it
|
to 0 W while it restarts, and with "Start in shadow mode" on (the default) it
|
||||||
stays at 0 W until you turn shadow mode off under Settings.
|
stays at 0 W until you turn shadow mode off under Settings.
|
||||||
|
|
||||||
Setup opens by itself on a new install's first start, until Setup finishes or
|
Setup opens by itself on a new install's first start, until Setup finishes or
|
||||||
control is started, and whenever the stored settings name no inverter to
|
control is started, and whenever the stored settings name no inverter to
|
||||||
control. A site that already ran before is never sent there. The add-on does
|
control. A site that already ran before is never sent there.
|
||||||
not start with no power request entity at all, so on a brand-new install fill
|
|
||||||
in "Inverter setpoint entity" on the Configuration tab first.
|
|
||||||
|
|
||||||
*For installers: card UX-13, and `DECISIONS/UX-13-setup-wizard.md`.*
|
A new install names no inverter yet, so the add-on starts **in setup**: it
|
||||||
|
serves the page and sends nothing to any inverter. Home says "Setup: nothing
|
||||||
|
is controlled yet", and there is no Start button. The installer's steps:
|
||||||
|
|
||||||
|
1. Install the add-on, turn on **Watchdog** on its Info tab, and start it.
|
||||||
|
Leave the Configuration tab as it is.
|
||||||
|
2. Open the add-on's page. Setup opens by itself.
|
||||||
|
3. Go through the six steps and press **Save and restart**. With "Start in
|
||||||
|
shadow mode" on, every inverter stays at 0 W after the restart.
|
||||||
|
4. On Home, check the meter direction and what the add-on would do. Turn
|
||||||
|
shadow mode off under Settings when it looks right.
|
||||||
|
|
||||||
|
Any other problem in the stored settings still stops the add-on (exit code 78),
|
||||||
|
and the log names each one. Fix those on the Configuration tab.
|
||||||
|
|
||||||
|
*For installers: cards UX-13 and UX-15, `DECISIONS/UX-13-setup-wizard.md` and
|
||||||
|
`DECISIONS/UX-15-setup-only-mode.md`. In setup the `status` JSON has
|
||||||
|
`setup_only: true`, and no MQTT entity is published.*
|
||||||
|
|
||||||
## Status page
|
## Status page
|
||||||
|
|
||||||
@@ -3260,6 +3275,10 @@ inverter stays at 0 W. If the banner is red and says a safety stop stays on,
|
|||||||
one of the red rows below is a stop that will still be there when shadow mode
|
one of the red rows below is a stop that will still be there when shadow mode
|
||||||
ends. That row says how to clear it.
|
ends. That row says how to clear it.
|
||||||
|
|
||||||
|
In **setup** (a new install that names no inverter yet) the banner says
|
||||||
|
nothing is controlled yet, and the one row says to finish Setup. See *Setup,
|
||||||
|
for a new install*.
|
||||||
|
|
||||||
| Row starts with | What it tells you |
|
| Row starts with | What it tells you |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||||||
|
|||||||
@@ -51,10 +51,18 @@ class Outage:
|
|||||||
OUTAGE = Outage()
|
OUTAGE = Outage()
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnly(RuntimeError):
|
||||||
|
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistant:
|
class HomeAssistant:
|
||||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
|
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
|
||||||
|
*, read_only: bool = False):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
||||||
|
# UX-15: setup-only mode reads Home Assistant and calls no service.
|
||||||
|
# Fixed at construction; `call()` refuses before it sends anything.
|
||||||
|
self._read_only = bool(read_only)
|
||||||
self._last_moan: dict[str, float] = {}
|
self._last_moan: dict[str, float] = {}
|
||||||
if not self.token:
|
if not self.token:
|
||||||
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
|
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
|
||||||
@@ -181,7 +189,17 @@ class HomeAssistant:
|
|||||||
return None
|
return None
|
||||||
return (_f("min"), _f("max"), _f("step"))
|
return (_f("min"), _f("max"), _f("step"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return self._read_only
|
||||||
|
|
||||||
async def call(self, domain: str, service: str, data: dict) -> bool:
|
async def call(self, domain: str, service: str, data: dict) -> bool:
|
||||||
|
if self._read_only:
|
||||||
|
# Every service call, `number.set_value` included, goes through
|
||||||
|
# here, so setup-only mode has no way to command (DECISIONS UX-15).
|
||||||
|
_LOG.error("Setup-only mode called no service, because it may not "
|
||||||
|
"command anything.")
|
||||||
|
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
|
||||||
try:
|
try:
|
||||||
async with self.session.post(
|
async with self.session.post(
|
||||||
f"{CORE_API}/services/{domain}/{service}",
|
f"{CORE_API}/services/{domain}/{service}",
|
||||||
|
|||||||
@@ -87,9 +87,11 @@ from .watchdog import (
|
|||||||
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
|
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
|
||||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||||
)
|
)
|
||||||
|
from . import setuponly
|
||||||
from . import web
|
from . import web
|
||||||
|
|
||||||
OPTIONS_PATH = "/data/options.json"
|
OPTIONS_PATH = "/data/options.json"
|
||||||
|
INGRESS_PORT = 8099
|
||||||
_LOG = logging.getLogger("goodwe")
|
_LOG = logging.getLogger("goodwe")
|
||||||
|
|
||||||
# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the
|
# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the
|
||||||
@@ -375,6 +377,11 @@ class Controller:
|
|||||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||||
peak_db_path: str = "/data/tariff.db",
|
peak_db_path: str = "/data/tariff.db",
|
||||||
price_history_path: str = "/data/price_history.json"):
|
price_history_path: str = "/data/price_history.json"):
|
||||||
|
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
|
||||||
|
# builds no controller. Refused here so no controller can hold one.
|
||||||
|
if getattr(hass, "read_only", False) is True:
|
||||||
|
raise ValueError("a controller cannot run on a read-only "
|
||||||
|
"HomeAssistant (setup-only mode)")
|
||||||
self.o = opts
|
self.o = opts
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -6011,6 +6018,15 @@ async def amain() -> None:
|
|||||||
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
||||||
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
||||||
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
||||||
|
# UX-15: a new install names no inverter. When that is the ONLY
|
||||||
|
# refusal it serves the setup wizard in setup-only mode, which builds
|
||||||
|
# nothing that commands and returns when stopped. Any other refusal
|
||||||
|
# still exits 78 (DECISIONS/UX-15-setup-only-mode.md).
|
||||||
|
if _problems and setuponly.applies(opts, _problems):
|
||||||
|
await setuponly.serve(
|
||||||
|
opts, HomeAssistant(session, read_only=True), store,
|
||||||
|
port=INGRESS_PORT)
|
||||||
|
return
|
||||||
if _problems:
|
if _problems:
|
||||||
refuse(_problems)
|
refuse(_problems)
|
||||||
raise ConfigRefused(_problems)
|
raise ConfigRefused(_problems)
|
||||||
@@ -6054,7 +6070,7 @@ async def amain() -> None:
|
|||||||
|
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
controller = Controller(opts, hass, store, pub, stop=stop)
|
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||||
runner = await web.start(controller, port=8099)
|
runner = await web.start(controller, port=INGRESS_PORT)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
|||||||
@@ -382,6 +382,16 @@ def banner_stopped() -> str:
|
|||||||
return "Stopped — the inverters are set to 0 W"
|
return "Stopped — the inverters are set to 0 W"
|
||||||
|
|
||||||
|
|
||||||
|
def banner_setup_only() -> str:
|
||||||
|
"""UX-15: setup-only mode, where the add-on serves the page and commands
|
||||||
|
nothing (app/setuponly.py)."""
|
||||||
|
return "Setup: nothing is controlled yet"
|
||||||
|
|
||||||
|
|
||||||
|
def row_setup_only() -> str:
|
||||||
|
return "No inverter is named yet, so finish Setup and restart the add-on."
|
||||||
|
|
||||||
|
|
||||||
def banner_shadow(latched: bool) -> str:
|
def banner_shadow(latched: bool) -> str:
|
||||||
"""SAFETY-10: first in the cascade, never displaced by another banner.
|
"""SAFETY-10: first in the cascade, never displaced by another banner.
|
||||||
|
|
||||||
@@ -861,6 +871,7 @@ HINTS = {
|
|||||||
"control_stopped": "Control stopped",
|
"control_stopped": "Control stopped",
|
||||||
"settings_saved": "Settings saved, they apply after a restart",
|
"settings_saved": "Settings saved, they apply after a restart",
|
||||||
"restart_requested": "Restart requested from the settings page",
|
"restart_requested": "Restart requested from the settings page",
|
||||||
|
"setup_only": "In setup, nothing is sent to any inverter",
|
||||||
"roster": "Inverter list read: {n} inverters",
|
"roster": "Inverter list read: {n} inverters",
|
||||||
"maint_history": "Maintenance history did not move over cleanly",
|
"maint_history": "Maintenance history did not move over cleanly",
|
||||||
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
||||||
@@ -952,6 +963,8 @@ def status_samples() -> list:
|
|||||||
("banner_peak_reserve", None, banner_peak_reserve()),
|
("banner_peak_reserve", None, banner_peak_reserve()),
|
||||||
("banner_power_too_high", None, banner_power_too_high()),
|
("banner_power_too_high", None, banner_power_too_high()),
|
||||||
("banner_shadow", None, banner_shadow(False)),
|
("banner_shadow", None, banner_shadow(False)),
|
||||||
|
("banner_setup_only", None, banner_setup_only()),
|
||||||
|
("row_setup_only", AMBER, row_setup_only()),
|
||||||
("banner_shadow", None, banner_shadow(True)),
|
("banner_shadow", None, banner_shadow(True)),
|
||||||
("row_controller_starting", GREEN, row_controller_starting()),
|
("row_controller_starting", GREEN, row_controller_starting()),
|
||||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
|
||||||
|
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
|
||||||
|
|
||||||
|
`applies()` says when: the startup gate's only refusal is that no inverter is
|
||||||
|
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
|
||||||
|
controller. Nothing that can command is built: no Controller, no inverter, no
|
||||||
|
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
|
||||||
|
before anything is sent (hass.py), and `/action` refuses too (web.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import messages, web
|
||||||
|
from .inverter import roster_entries
|
||||||
|
from .validate import actuator_writers, no_power_request_problem
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("goodwe.setup")
|
||||||
|
|
||||||
|
|
||||||
|
def applies(opts: dict, problems) -> bool:
|
||||||
|
"""True when the options name no inverter at all and that is the gate's
|
||||||
|
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
|
||||||
|
return (not actuator_writers(opts) and not roster_entries(opts)
|
||||||
|
and list(problems) == [no_power_request_problem()])
|
||||||
|
|
||||||
|
|
||||||
|
class SetupOnly:
|
||||||
|
"""What web.build_app() needs from a controller, and nothing that writes.
|
||||||
|
|
||||||
|
It holds no inverter and no publisher. `hass` must be read-only."""
|
||||||
|
|
||||||
|
setup_only = True
|
||||||
|
auto = False # control is never started here
|
||||||
|
grid = None # no meter is read here
|
||||||
|
inverters = ()
|
||||||
|
|
||||||
|
def __init__(self, opts: dict, hass, store):
|
||||||
|
if getattr(hass, "read_only", False) is not True:
|
||||||
|
raise ValueError("setup-only mode needs a read-only HomeAssistant")
|
||||||
|
self.o, self.hass, self.store = opts, hass, store
|
||||||
|
self.events: list[str] = []
|
||||||
|
self.hint = ""
|
||||||
|
|
||||||
|
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||||
|
self.events.insert(0, f"{stamp} {msg}")
|
||||||
|
del self.events[40:]
|
||||||
|
if hint is not None:
|
||||||
|
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
||||||
|
|
||||||
|
def status_payload(self) -> dict:
|
||||||
|
return {"setup_only": True, "mode": "setup_only",
|
||||||
|
"banner": messages.banner_setup_only(), "level": "warn",
|
||||||
|
"rows": [["Control", "not started, in setup"]],
|
||||||
|
"checks": [{"ok": False, "warn": True,
|
||||||
|
"text": messages.row_setup_only()}],
|
||||||
|
"hint": self.hint, "events": list(self.events)}
|
||||||
|
|
||||||
|
async def handle_action(self, what):
|
||||||
|
# web.py answers 409 before it gets here. Nothing is done either way.
|
||||||
|
_LOG.warning("The page asked for an action in setup, so nothing "
|
||||||
|
"was done.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Nothing to wind down: nothing was ever commanded."""
|
||||||
|
|
||||||
|
|
||||||
|
async def serve(opts: dict, hass, store, *, port: int) -> None:
|
||||||
|
"""Serve the page in setup-only mode until the Supervisor stops us."""
|
||||||
|
controller = SetupOnly(opts, hass, store)
|
||||||
|
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
|
||||||
|
"controls nothing. Open its page to finish setup.")
|
||||||
|
controller.log_event("setup-only mode: no inverter named, nothing is "
|
||||||
|
"commanded", hint=messages.hint("setup_only"))
|
||||||
|
runner = await web.start(controller, port=port)
|
||||||
|
stop = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
with contextlib.suppress(NotImplementedError):
|
||||||
|
loop.add_signal_handler(sig, stop.set)
|
||||||
|
try:
|
||||||
|
await stop.wait()
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
_LOG.info("stopped")
|
||||||
@@ -94,11 +94,13 @@
|
|||||||
function render() {
|
function render() {
|
||||||
renderPill();
|
renderPill();
|
||||||
const structured = S.now && S.plan && !S.home_error;
|
const structured = S.now && S.plan && !S.home_error;
|
||||||
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null);
|
// UX-15: setup-only mode commands nothing and has no control to offer.
|
||||||
|
const setup = S.setup_only === true;
|
||||||
|
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
|
||||||
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
||||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||||
renderControl(structured);
|
renderControl(structured, setup);
|
||||||
renderDetails(!structured);
|
renderDetails(!structured && !setup);
|
||||||
renderBatteries();
|
renderBatteries();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,12 +272,15 @@
|
|||||||
badge.textContent = n; badge.hidden = !n;
|
badge.textContent = n; badge.hidden = !n;
|
||||||
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
||||||
}
|
}
|
||||||
function renderControl(structured) {
|
function renderControl(structured, setup) {
|
||||||
const running = structured ? S.control.running : null;
|
const running = structured ? S.control.running : null;
|
||||||
const btn = $("#btn-ctl");
|
const btn = $("#btn-ctl");
|
||||||
btn.textContent = running === false ? t("control.start") : t("control.stop");
|
btn.textContent = running === false ? t("control.start") : t("control.stop");
|
||||||
btn.className = "btn" + (running === false ? " primary" : "");
|
btn.className = "btn" + (running === false ? " primary" : "");
|
||||||
$("#ctl-text").textContent = structured ? S.control.text : "";
|
btn.hidden = !!setup;
|
||||||
|
$("#btn-setup").hidden = !setup;
|
||||||
|
$("#c-ctl .more").hidden = !!setup;
|
||||||
|
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
|
||||||
$("#hint").textContent = S.hint || "";
|
$("#hint").textContent = S.hint || "";
|
||||||
const docs = $("#docs-link");
|
const docs = $("#docs-link");
|
||||||
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
<div class="small" id="ctl-text"></div>
|
<div class="small" id="ctl-text"></div>
|
||||||
<div class="row" style="margin-top:10px">
|
<div class="row" style="margin-top:10px">
|
||||||
<button class="btn" id="btn-ctl" type="button"></button>
|
<button class="btn" id="btn-ctl" type="button"></button>
|
||||||
|
<a class="btn primary" id="btn-setup" href="#setup" hidden data-t="setup_only.open"></a>
|
||||||
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
|
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
|
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
|
||||||
|
|||||||
@@ -533,10 +533,12 @@
|
|||||||
if (input) input.focus({ preventScroll: true });
|
if (input) input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
// The one save path, for the Installer view and the wizard. After a stale
|
// The one save path, for the Installer view and the wizard. After a stale
|
||||||
// refusal the page holds the new token, so a second press saves.
|
// refusal the page holds the new token, so a second press saves. `base` is
|
||||||
|
// the model the page opened: only what changed since then is written, onto
|
||||||
|
// what is stored now (UX-15 F2).
|
||||||
async function postSave(extra) {
|
async function postSave(extra) {
|
||||||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(Object.assign({ model: V, fingerprint: FP }, extra || {})) });
|
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP }, extra || {})) });
|
||||||
const b = await r.json().catch(() => ({}));
|
const b = await r.json().catch(() => ({}));
|
||||||
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
||||||
return { r, b };
|
return { r, b };
|
||||||
@@ -624,8 +626,10 @@
|
|||||||
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
|
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
|
||||||
const k = WZ.kettle || { sig };
|
const k = WZ.kettle || { sig };
|
||||||
box.append(h("div", { class: "help" }, WS.kettle.description));
|
box.append(h("div", { class: "help" }, WS.kettle.description));
|
||||||
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn" }[k.say] || "bad";
|
// A skip after the reading went down still says it went down (UX-15).
|
||||||
const words = k.say ? WS.kettle[k.say] : k.before != null ? WS.kettle.before : null;
|
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
|
||||||
|
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
|
||||||
|
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
|
||||||
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
|
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
|
||||||
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
|
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
|
||||||
const busy = (b) => { b.disabled = true; };
|
const busy = (b) => { b.disabled = true; };
|
||||||
@@ -715,7 +719,9 @@
|
|||||||
if (!b) { toast(WS.check.failed.description); return; }
|
if (!b) { toast(WS.check.failed.description); return; }
|
||||||
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
|
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
|
||||||
const shadow = shadowValue() === M.setup.shadow.on;
|
const shadow = shadowValue() === M.setup.shadow.on;
|
||||||
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
|
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
|
||||||
|
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
|
||||||
|
ask(dlg, async () => {
|
||||||
wzBanner();
|
wzBanner();
|
||||||
let res;
|
let res;
|
||||||
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
|
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
|
||||||
|
|||||||
@@ -581,9 +581,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
|||||||
return rep
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
STATE_PATH = "/data/state.json"
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, path: str = "/data/state.json"):
|
def __init__(self, path: str | None = None):
|
||||||
self.path = path
|
self.path = path or STATE_PATH
|
||||||
self.data = dict(DEFAULTS)
|
self.data = dict(DEFAULTS)
|
||||||
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
|
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
|
||||||
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
|
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
},
|
},
|
||||||
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
|
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
|
||||||
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
|
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
|
||||||
|
"setup_only": {"description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
|
||||||
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
|
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
|
||||||
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
|
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
|
||||||
},
|
},
|
||||||
@@ -192,6 +193,7 @@
|
|||||||
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
|
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
|
||||||
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
|
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
|
||||||
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
|
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
|
||||||
|
"skipped_down": {"description": "The reading went down when the kettle came on, so check the meter's direction before you start control."},
|
||||||
"tested": {"description": "The meter direction was tested with a kettle."},
|
"tested": {"description": "The meter direction was tested with a kettle."},
|
||||||
"needed": {"description": "Do the kettle test first, or skip it."}
|
"needed": {"description": "Do the kettle test first, or skip it."}
|
||||||
},
|
},
|
||||||
@@ -206,6 +208,8 @@
|
|||||||
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
|
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
|
||||||
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
|
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
|
||||||
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
|
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
|
||||||
|
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
|
||||||
|
"dialog_setup_live": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and then they apply.", "yes": "Save and restart"},
|
||||||
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
|
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
|
||||||
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
|
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
|
||||||
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
|
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import json
|
|||||||
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
||||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||||
|
|
||||||
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
|
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
|
||||||
|
"REGION_PRESETS",
|
||||||
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
|
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class DeriveError(ValueError):
|
|||||||
def __init__(self, text, code="", field=""):
|
def __init__(self, text, code="", field=""):
|
||||||
super().__init__(text)
|
super().__init__(text)
|
||||||
self.code, self.field = code, field
|
self.code, self.field = code, field
|
||||||
|
self.keys = () # the option keys the failed derivation writes
|
||||||
|
|
||||||
|
|
||||||
# The page model's inverter count, which show conditions read, and what adding
|
# The page model's inverter count, which show conditions read, and what adding
|
||||||
@@ -337,14 +339,31 @@ def options_to_ui(options: dict) -> dict:
|
|||||||
def ui_to_options(model: dict) -> dict:
|
def ui_to_options(model: dict) -> dict:
|
||||||
"""The options blob for a page model. Raises DeriveError on a model no blob
|
"""The options blob for a page model. Raises DeriveError on a model no blob
|
||||||
can express, so the page says so instead of saving something else."""
|
can express, so the page says so instead of saving something else."""
|
||||||
|
out, errors = ui_to_options_partial(model)
|
||||||
|
if errors:
|
||||||
|
raise errors[0]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ui_to_options_partial(model: dict):
|
||||||
|
"""(blob, [DeriveError]): every derivation that can be made, and the
|
||||||
|
stored values (`keep`) for each one that cannot. For the checks only, so a
|
||||||
|
step can be blocked by its own field and the rest still checked (UX-15
|
||||||
|
F1). Never saved: `ui_to_options()` raises instead."""
|
||||||
keep = model.get("keep") or {}
|
keep = model.get("keep") or {}
|
||||||
out = {k: copy.deepcopy(v) for k, v in model.items()
|
out = {k: copy.deepcopy(v) for k, v in model.items()
|
||||||
if k not in _MODEL_ONLY and k not in _OWNED
|
if k not in _MODEL_ONLY and k not in _OWNED
|
||||||
and not k.startswith("ui.")}
|
and not k.startswith("ui.")}
|
||||||
|
errors = []
|
||||||
for keys, decode, encode in DERIVATIONS:
|
for keys, decode, encode in DERIVATIONS:
|
||||||
was = decode(keep)
|
was = decode(keep)
|
||||||
if all(model.get(name) == value for name, value in was.items()):
|
if all(model.get(name) == value for name, value in was.items()):
|
||||||
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
|
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
|
||||||
else:
|
continue
|
||||||
|
try:
|
||||||
out.update(encode(model, keep))
|
out.update(encode(model, keep))
|
||||||
return out
|
except DeriveError as err:
|
||||||
|
err.keys = keys
|
||||||
|
errors.append(err)
|
||||||
|
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
|
||||||
|
return out, errors
|
||||||
|
|||||||
@@ -186,7 +186,32 @@ def to_options(model: dict, stored: dict | None = None) -> dict:
|
|||||||
"""The options blob for a posted page model. A value equal to the stored
|
"""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
|
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)."""
|
nothing writes an identical blob (the browser reads 3.0 as 3)."""
|
||||||
out = U.ui_to_options(model)
|
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 {}
|
stored = stored or {}
|
||||||
for k, v in out.items():
|
for k, v in out.items():
|
||||||
if k in stored and _same(v, stored[k]):
|
if k in stored and _same(v, stored[k]):
|
||||||
@@ -202,6 +227,21 @@ def derive_message(err: U.DeriveError) -> dict:
|
|||||||
"text": text}
|
"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
|
# Where a sentence goes on the page
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -808,6 +808,14 @@ class ConfigRefused(Exception):
|
|||||||
super().__init__(problems[0] if problems else "configuration refused")
|
super().__init__(problems[0] if problems else "configuration refused")
|
||||||
|
|
||||||
|
|
||||||
|
def no_power_request_problem() -> str:
|
||||||
|
"""Rule 1's refusal: no inverter to control. UX-15's setup-only mode
|
||||||
|
starts when this is the only refusal (app/setuponly.py)."""
|
||||||
|
return (f"{S('setpoint_entity')} is empty, so the add-on cannot control "
|
||||||
|
"the inverter. Set it to the ESPHome number entity for the "
|
||||||
|
"setpoint.")
|
||||||
|
|
||||||
|
|
||||||
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||||
notes: list | None = None) -> list[str]:
|
notes: list | None = None) -> list[str]:
|
||||||
"""Return the list of reasons this configuration must not be started.
|
"""Return the list of reasons this configuration must not be started.
|
||||||
@@ -828,9 +836,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
|||||||
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
|
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
|
||||||
writers = actuator_writers(opts)
|
writers = actuator_writers(opts)
|
||||||
if not writers:
|
if not writers:
|
||||||
problems.append(
|
problems.append(no_power_request_problem())
|
||||||
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
|
|
||||||
"inverter. Set it to the ESPHome number entity for the setpoint.")
|
|
||||||
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
||||||
# count of option keys. Read `actuator_writers()` for why the old check went
|
# count of option keys. Read `actuator_writers()` for why the old check went
|
||||||
# green on the very config this one is here to refuse.
|
# green on the very config this one is here to refuse.
|
||||||
@@ -1704,6 +1710,14 @@ WATT_ENTITY_KEYS = (
|
|||||||
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
||||||
|
|
||||||
|
|
||||||
|
# The meter entities each `meter_source` reads through Home Assistant.
|
||||||
|
METER_ENTITY_KEYS = {
|
||||||
|
"off": ("meter_entity",),
|
||||||
|
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
|
||||||
|
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
|
||||||
|
"p1_phase_import_entities", "p1_phase_export_entities")}
|
||||||
|
|
||||||
|
|
||||||
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
||||||
"""(key, inverter name or "", entity id) for each entity the add-on reads
|
"""(key, inverter name or "", entity id) for each entity the add-on reads
|
||||||
with these options. Keys the chosen meter source or roster shape ignores
|
with these options. Keys the chosen meter source or roster shape ignores
|
||||||
@@ -1716,12 +1730,7 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
|||||||
out.append((key, where, e.strip()))
|
out.append((key, where, e.strip()))
|
||||||
|
|
||||||
source = str(opts.get("meter_source") or "off").strip()
|
source = str(opts.get("meter_source") or "off").strip()
|
||||||
by_source = {"off": ("meter_entity",),
|
for key in METER_ENTITY_KEYS.get(source, ()):
|
||||||
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
|
|
||||||
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
|
|
||||||
"p1_phase_import_entities",
|
|
||||||
"p1_phase_export_entities")}
|
|
||||||
for key in by_source.get(source, ()):
|
|
||||||
add(key, opts.get(key))
|
add(key, opts.get(key))
|
||||||
entries = roster_entries(opts)
|
entries = roster_entries(opts)
|
||||||
if not entries:
|
if not entries:
|
||||||
@@ -1844,7 +1853,7 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
|||||||
entity exists in Home Assistant (a write to a missing one is answered 200,
|
entity exists in Home Assistant (a write to a missing one is answered 200,
|
||||||
SAFETY-77, so only the entity list can tell), the battery levels read, and
|
SAFETY-77, so only the entity list can tell), the battery levels read, and
|
||||||
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
|
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
|
||||||
request entity, which the wizard will not save. Reads nothing itself:
|
request or meter entity, which the wizard will not save. Reads nothing itself:
|
||||||
`entities` is Home Assistant's list by id (None when it did not answer),
|
`entities` is Home Assistant's list by id (None when it did not answer),
|
||||||
`running` the options this process started with, `meter_now_w` its meter
|
`running` the options this process started with, `meter_now_w` its meter
|
||||||
reading and `prices_known` how many of today's hours have a price.
|
reading and `prices_known` how many of today's hours have a price.
|
||||||
@@ -1855,7 +1864,11 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
|||||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||||
"answering, so press Check again once it is back.")
|
"answering, so press Check again once it is back.")
|
||||||
else:
|
else:
|
||||||
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
|
# A missing meter entity is red too (UX-15): with the kettle test
|
||||||
|
# skipped, nothing else on the way to the save would catch it.
|
||||||
|
meter_keys = {k for keys in METER_ENTITY_KEYS.values() for k in keys}
|
||||||
|
_entity_notes(notes, opts, entities,
|
||||||
|
bad=("setpoint_entity",) + tuple(sorted(meter_keys)))
|
||||||
meter = _meter_check(notes, opts, entities, running, meter_now_w)
|
meter = _meter_check(notes, opts, entities, running, meter_now_w)
|
||||||
batts = [_card_checks(notes, who, entry, entities)
|
batts = [_card_checks(notes, who, entry, entities)
|
||||||
for who, entry in _cards(opts)]
|
for who, entry in _cards(opts)]
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ of Home Assistant's entity list (app/haentities.py) and writes nothing.
|
|||||||
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
|
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
|
||||||
not options is a Store key, `setup_done`, written when the wizard's save
|
not options is a Store key, `setup_done`, written when the wizard's save
|
||||||
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
||||||
|
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
|
||||||
|
commands nothing; there `/action` answers 409.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -136,6 +138,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise web.HTTPBadRequest(text="expected JSON")
|
raise web.HTTPBadRequest(text="expected JSON")
|
||||||
what = body.get("action")
|
what = body.get("action")
|
||||||
|
if getattr(controller, "setup_only", False):
|
||||||
|
# UX-15: nothing to start or stop until setup is saved.
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "error": messages.row_setup_only()}, status=409)
|
||||||
result = await controller.handle_action(what)
|
result = await controller.handle_action(what)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
||||||
@@ -149,14 +155,23 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
"""(body, options, model). UX-13's settings view posts its page
|
"""(body, options, model). UX-13's settings view posts its page
|
||||||
`model`, which app/uimodel.py turns into the options blob here, so the
|
`model`, which app/uimodel.py turns into the options blob here, so the
|
||||||
validator and the Supervisor only ever see a real blob. The classic
|
validator and the Supervisor only ever see a real blob. The classic
|
||||||
page posts `options`. Raises DeriveError for a model no blob can say."""
|
page posts `options`. Raises DeriveError for a model no blob can say.
|
||||||
|
|
||||||
|
With `stored` (a save) and the `base` model the page opened, only the
|
||||||
|
keys this page changed are written onto what is stored now, so a save
|
||||||
|
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise web.HTTPBadRequest(text="expected JSON")
|
raise web.HTTPBadRequest(text="expected JSON")
|
||||||
model = body.get("model")
|
model = body.get("model")
|
||||||
if isinstance(model, dict):
|
if isinstance(model, dict):
|
||||||
return body, uipage.to_options(model, stored), model
|
mine = uipage.to_options(model, stored)
|
||||||
|
base = body.get("base")
|
||||||
|
if stored is not None and isinstance(base, dict):
|
||||||
|
was, _errors = uipage.to_options_partial(base, stored)
|
||||||
|
mine = uipage.only_edits(mine, was, stored)
|
||||||
|
return body, mine, model
|
||||||
options = body.get("options")
|
options = body.get("options")
|
||||||
if not isinstance(options, dict):
|
if not isinstance(options, dict):
|
||||||
raise web.HTTPBadRequest(text="expected an options object")
|
raise web.HTTPBadRequest(text="expected an options object")
|
||||||
@@ -202,6 +217,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
options, store_data(), bool(getattr(controller, "auto", False)),
|
options, store_data(), bool(getattr(controller, "auto", False)),
|
||||||
fresh=getattr(getattr(controller, "store", None), "fresh",
|
fresh=getattr(getattr(controller, "store", None), "fresh",
|
||||||
False) is True),
|
False) is True),
|
||||||
|
# UX-15: this process serves setup only and commands nothing.
|
||||||
|
"setup_only": getattr(controller, "setup_only", False) is True,
|
||||||
})
|
})
|
||||||
return web.json_response(model)
|
return web.json_response(model)
|
||||||
|
|
||||||
@@ -219,11 +236,19 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
be a second opinion about a rule it does not own.
|
be a second opinion about a rule it does not own.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_body, options, model = await _proposed(request)
|
_body = await request.json()
|
||||||
except DeriveError as err:
|
except ValueError:
|
||||||
return web.json_response({"problems": [], "by_key": {},
|
raise web.HTTPBadRequest(text="expected JSON")
|
||||||
"unanchored": [], "notes": [],
|
model = _body.get("model")
|
||||||
"messages": [uipage.derive_message(err)]})
|
errors = []
|
||||||
|
if isinstance(model, dict):
|
||||||
|
# UX-15 F1: a part the page cannot turn into options blocks its
|
||||||
|
# own step, and everything else is still checked.
|
||||||
|
options, errors = uipage.to_options_partial(model)
|
||||||
|
else:
|
||||||
|
model, options = None, _body.get("options")
|
||||||
|
if not isinstance(options, dict):
|
||||||
|
raise web.HTTPBadRequest(text="expected an options object")
|
||||||
notes = []
|
notes = []
|
||||||
problems, unread = await selfconfig.problems_and_ranges(
|
problems, unread = await selfconfig.problems_and_ranges(
|
||||||
options, controller.hass, notes=notes)
|
options, controller.hass, notes=notes)
|
||||||
@@ -237,8 +262,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
if unread:
|
if unread:
|
||||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||||
if model is not None:
|
if model is not None:
|
||||||
payload["messages"] = uipage.anchors(problems, notes, model,
|
payload["messages"] = (
|
||||||
_keys_of(options))
|
uipage.derive_messages(errors, problems, notes, model,
|
||||||
|
_keys_of(options)) if errors
|
||||||
|
else uipage.anchors(problems, notes, model, _keys_of(options)))
|
||||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||||
if _body.get("setup"):
|
if _body.get("setup"):
|
||||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||||
@@ -286,10 +313,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
return web.json_response({"error": str(err)}, status=502)
|
return web.json_response({"error": str(err)}, status=502)
|
||||||
try:
|
try:
|
||||||
body, options, model = await _proposed(request, stored_now)
|
body, options, model = await _proposed(request, stored_now)
|
||||||
except DeriveError as err:
|
except DeriveError:
|
||||||
|
model = posted["model"]
|
||||||
|
_opts, errors = uipage.to_options_partial(model, stored_now)
|
||||||
|
messages_ = [uipage.derive_message(e) for e in errors]
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"kind": "invalid", "problems": [], "by_key": {},
|
{"kind": "invalid", "problems": [], "by_key": {},
|
||||||
"unanchored": [], "messages": [uipage.derive_message(err)]},
|
"unanchored": [], "messages": messages_,
|
||||||
|
"steps": uipage.step_problems(messages_, model)},
|
||||||
status=422)
|
status=422)
|
||||||
expect = str(body.get("fingerprint") or "")
|
expect = str(body.get("fingerprint") or "")
|
||||||
try:
|
try:
|
||||||
|
|||||||
+135
-11
@@ -9,7 +9,8 @@ Part 6 the save through the P2 path, and every blob the wizard can produce
|
|||||||
(tools/ux13_wizard_blobs.py; the Supervisor's own validator runs over the same
|
(tools/ux13_wizard_blobs.py; the Supervisor's own validator runs over the same
|
||||||
set in the PR, not in CI, which has no Supervisor image). Part 7 the stale
|
set in the PR, not in CI, which has no Supervisor image). Part 7 the stale
|
||||||
save names only what changed elsewhere (P2 test, finding a). Part 8 the
|
save names only what changed elsewhere (P2 test, finding a). Part 8 the
|
||||||
words. The headless-browser click-through is in the PR.
|
words. Part 9 UX-15's fixes: F1, F2 with two tabs, a missing meter entity and
|
||||||
|
step 1 after a skip. The headless-browser click-through is in the PR.
|
||||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
|||||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||||
logging.disable(logging.WARNING) # the validator's own log lines
|
logging.disable(logging.WARNING) # the validator's own log lines
|
||||||
|
|
||||||
from aiohttp import ClientSession, web as aioweb # noqa: E402
|
from aiohttp import ClientSession, ContentTypeError, web as aioweb # noqa: E402
|
||||||
|
|
||||||
import translations_lint as TL # noqa: E402
|
import translations_lint as TL # noqa: E402
|
||||||
import ux13_demo as D # noqa: E402
|
import ux13_demo as D # noqa: E402
|
||||||
@@ -51,6 +52,7 @@ DEFAULTS = D.defaults(D.CONFIG)
|
|||||||
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
|
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
|
||||||
UI_TEXT = uipage.ui_text()
|
UI_TEXT = uipage.ui_text()
|
||||||
WS = UI_TEXT["settings"]["setup"]
|
WS = UI_TEXT["settings"]["setup"]
|
||||||
|
WS_D = UI_TEXT["settings"]["derive"]
|
||||||
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
|
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
|
||||||
encoding="utf-8").read()
|
encoding="utf-8").read()
|
||||||
|
|
||||||
@@ -262,7 +264,10 @@ async def _get(s, url):
|
|||||||
|
|
||||||
async def _post(s, url, body):
|
async def _post(s, url, body):
|
||||||
async with s.post(url, json=body) as r:
|
async with s.post(url, json=body) as r:
|
||||||
return r.status, await r.json()
|
try:
|
||||||
|
return r.status, await r.json()
|
||||||
|
except (ValueError, ContentTypeError): # e.g. a plain-text 500
|
||||||
|
return r.status, {}
|
||||||
|
|
||||||
|
|
||||||
STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"),
|
STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"),
|
||||||
@@ -626,11 +631,15 @@ print(f" {len(_all)} blobs from {len(B.starts())} starts; "
|
|||||||
check(f"every plan from a new install is accepted by the shipped validator "
|
check(f"every plan from a new install is accepted by the shipped validator "
|
||||||
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
|
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
|
||||||
if r])
|
if r])
|
||||||
|
_empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")]
|
||||||
|
check(f"every plan from a new install as shipped (no power request entity, "
|
||||||
|
f"setup-only mode) is accepted ({len(_empty)} plans)",
|
||||||
|
len(_empty) == len(_new) and not [n for n, r in _empty if r])
|
||||||
check("every plan from the TEST-23 sites is accepted, except where the site "
|
check("every plan from the TEST-23 sites is accepted, except where the site "
|
||||||
"trades on price with a plan that has no meter or no prices, which the "
|
"trades on price with a plan that has no meter or no prices, which the "
|
||||||
"last step then lists",
|
"last step then lists",
|
||||||
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
|
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
|
||||||
for n, _b, r in _all) and len(_ok) >= 80)
|
for n, _b, r in _all) and len(_ok) >= 100)
|
||||||
check("every accepted blob keeps every option key (the Supervisor count, 78)",
|
check("every accepted blob keeps every option key (the Supervisor count, 78)",
|
||||||
all(set(b) == set(DEFAULTS) for _n, b, r in _all if not r))
|
all(set(b) == set(DEFAULTS) for _n, b, r in _all if not r))
|
||||||
|
|
||||||
@@ -645,9 +654,11 @@ async def _stale(s, root, sup):
|
|||||||
model = copy.deepcopy(m["ui"])
|
model = copy.deepcopy(m["ui"])
|
||||||
model["price_timeout_s"] = 12 # the operator's own edit
|
model["price_timeout_s"] = 12 # the operator's own edit
|
||||||
first = await _post(s, root + "config/save",
|
first = await _post(s, root + "config/save",
|
||||||
{"model": model, "fingerprint": m["fingerprint"]})
|
{"model": model, "base": m["ui"],
|
||||||
|
"fingerprint": m["fingerprint"]})
|
||||||
again = await _post(s, root + "config/save",
|
again = await _post(s, root + "config/save",
|
||||||
{"model": model, "fingerprint": first[1]["fingerprint"]})
|
{"model": model, "base": m["ui"],
|
||||||
|
"fingerprint": first[1]["fingerprint"]})
|
||||||
return first, again
|
return first, again
|
||||||
|
|
||||||
|
|
||||||
@@ -659,12 +670,13 @@ check("the banner's list is the other surface's key only, not the operator's "
|
|||||||
"own edit", _first_r[0] == 409
|
"own edit", _first_r[0] == 409
|
||||||
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
|
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
|
||||||
and _first_r[1]["moved"] == ["peak_cap_w"])
|
and _first_r[1]["moved"] == ["peak_cap_w"])
|
||||||
check("`changed` (stored against mine, which the classic page shows as "
|
check("`changed` (stored against what this page would write) is only the "
|
||||||
"theirs -> mine) is unchanged",
|
"page's own edit, since a save writes only that (UX-15 F2)",
|
||||||
_first_r[1]["changed"] == ["peak_cap_w", "price_timeout_s"])
|
_first_r[1]["changed"] == ["price_timeout_s"])
|
||||||
check("saving again with the new token is the deliberate second press: it "
|
check("saving again with the new token is the deliberate second press: it "
|
||||||
"stores the page as shown (Discard loads theirs instead)",
|
"stores this page's own edit and keeps the other surface's change "
|
||||||
_again[0] == 200 and _sup.options["peak_cap_w"] == 3500
|
"(UX-15 F2)",
|
||||||
|
_again[0] == 200 and _sup.options["peak_cap_w"] == 5000
|
||||||
and _sup.options["price_timeout_s"] == 12)
|
and _sup.options["price_timeout_s"] == 12)
|
||||||
|
|
||||||
|
|
||||||
@@ -757,6 +769,118 @@ _leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
|
|||||||
check(f"settings.js, wizard included, names no option key ({_leak})",
|
check(f"settings.js, wizard included, names no option key ({_leak})",
|
||||||
not _leak)
|
not _leak)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n9. UX-15: F1, F2, a missing meter entity, and step 1 after a skip")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def _f1(s, root, sup):
|
||||||
|
m = await _get(s, root + "config/model?lang=en")
|
||||||
|
price = copy.deepcopy(m["ui"])
|
||||||
|
price.update({"ui.contract": "daynight", "ui.fallback": "daynight",
|
||||||
|
"ui.day_price": None, "ui.night_price": 0.25})
|
||||||
|
none = copy.deepcopy(m["ui"])
|
||||||
|
none["inverters"] = []
|
||||||
|
none[U.COUNT] = 0
|
||||||
|
return {"price": (await _post(s, root + "config/validate",
|
||||||
|
{"model": price, "setup": True}))[1],
|
||||||
|
"none": (await _post(s, root + "config/validate",
|
||||||
|
{"model": none}))[1],
|
||||||
|
"save": await _post(s, root + "config/save", {
|
||||||
|
"model": price, "base": m["ui"], "setup": "done",
|
||||||
|
"fingerprint": m["fingerprint"]})}
|
||||||
|
|
||||||
|
|
||||||
|
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
|
||||||
|
SITE, _f1))
|
||||||
|
print(f" empty day price: steps {_r['price'].get('steps')}")
|
||||||
|
check("F1: a price the page cannot turn into options blocks step 4, beside "
|
||||||
|
"its own field",
|
||||||
|
_r["price"].get("steps", {}).get("4", 0) >= 1
|
||||||
|
and {"field": "ui.day_price", "card": None, "tier": "bad",
|
||||||
|
"text": WS_D["no_price"]["description"]} in _r["price"].get("messages", []))
|
||||||
|
check("F1: ...and the last step's live checks still run on the rest",
|
||||||
|
[x["tier"] for x in _r["price"].get("setup", [])
|
||||||
|
if "number.inv1_power" in x["text"]] == ["ok"]
|
||||||
|
and any("402 W" in x["text"] for x in _r["price"]["setup"]))
|
||||||
|
check("F1: no inverter card blocks step 2, not the last step alone",
|
||||||
|
_r["none"].get("steps", {}).get("2", 0) >= 1)
|
||||||
|
check("F1: the save refuses it (422) and says which step to go back to",
|
||||||
|
_r["save"][0] == 422 and _r["save"][1]["steps"]["4"] >= 1
|
||||||
|
and _sup.writes == [])
|
||||||
|
|
||||||
|
|
||||||
|
async def _tabs(s, root, sup):
|
||||||
|
"""Two tabs open one blob. A saves a peak limit; B edited only the
|
||||||
|
timeout, gets the 409, and presses Save again."""
|
||||||
|
a = await _get(s, root + "config/model?lang=en")
|
||||||
|
b = await _get(s, root + "config/model?lang=en")
|
||||||
|
va = copy.deepcopy(a["ui"])
|
||||||
|
va["peak_cap_w"] = 4000
|
||||||
|
ra = await _post(s, root + "config/save", {
|
||||||
|
"model": va, "base": a["ui"], "fingerprint": a["fingerprint"]})
|
||||||
|
after_a = copy.deepcopy(sup.options)
|
||||||
|
vb = copy.deepcopy(b["ui"])
|
||||||
|
vb["price_timeout_s"] = 12
|
||||||
|
first = await _post(s, root + "config/save", {
|
||||||
|
"model": vb, "base": b["ui"], "fingerprint": b["fingerprint"]})
|
||||||
|
again = await _post(s, root + "config/save", {
|
||||||
|
"model": vb, "base": b["ui"], "fingerprint": first[1]["fingerprint"]})
|
||||||
|
return ra, after_a, first, again
|
||||||
|
|
||||||
|
|
||||||
|
(_ra, _after_a, _first_b, _again_b), _sup = asyncio.run(
|
||||||
|
session(Controller(SITE, STATES), dict(SITE, peak_cap_w=3600), _tabs))
|
||||||
|
_moved = sorted(k for k in _sup.options
|
||||||
|
if _sup.options[k] != _after_a.get(k))
|
||||||
|
print(f" A: {_ra[0]}, B: {_first_b[0]} then {_again_b[0]}; B's re-save "
|
||||||
|
f"moved {_moved}")
|
||||||
|
check("F2: two tabs. A saves peak 4.0; B, which edited only the timeout, "
|
||||||
|
"gets the 409 naming the peak limit",
|
||||||
|
_ra[0] == 200 and _first_b[0] == 409
|
||||||
|
and _first_b[1]["changed_fields"] == ["peak_cap_w"])
|
||||||
|
check("F2: B's second press keeps A's peak 4.0 and writes only B's own key",
|
||||||
|
_again_b[0] == 200 and _sup.options["peak_cap_w"] == 4000
|
||||||
|
and _sup.options["price_timeout_s"] == 12
|
||||||
|
and _moved == ["price_timeout_s"])
|
||||||
|
_both = uipage.only_edits({"a": 2, "b": 1, "c": 5}, {"a": 1, "b": 1, "c": 3},
|
||||||
|
{"a": 1, "b": 9, "c": 4, "d": 0})
|
||||||
|
check("F2: only_edits() keeps every stored key the page did not edit, and a "
|
||||||
|
"key both edited takes this page's value (the deliberate press)",
|
||||||
|
_both == {"a": 2, "b": 9, "c": 5, "d": 0}
|
||||||
|
and list(_both) == ["a", "b", "c", "d"])
|
||||||
|
|
||||||
|
_gone = tiers(setup_checks(dict(SITE, meter_entity="sensor.gone"),
|
||||||
|
entities=ROWS))
|
||||||
|
check("a meter entity Home Assistant does not have is red on the last step, "
|
||||||
|
"so the wizard does not save it with the kettle test skipped",
|
||||||
|
("bad", "Home Assistant has no entity sensor.gone, which “Grid power "
|
||||||
|
"entity” (meter_entity) names.") in _gone)
|
||||||
|
_net_gone = tiers(setup_checks(dict(SITE, meter_source="ha_signed",
|
||||||
|
p1_net_entity="sensor.gone"),
|
||||||
|
entities=ROWS))
|
||||||
|
check("...for a net or import and export meter too",
|
||||||
|
[t for t, x in _net_gone if "sensor.gone" in x] == ["bad"])
|
||||||
|
from app.validate import config_notes # noqa: E402
|
||||||
|
check("...while the Installer view keeps it a warning, which never blocks "
|
||||||
|
"a save there",
|
||||||
|
[n.tier for n in config_notes(dict(SITE, meter_entity="sensor.gone"),
|
||||||
|
entities=ROWS)
|
||||||
|
if "sensor.gone" in n.text] == ["warn"])
|
||||||
|
|
||||||
|
check("step 1 after a skip that followed a fall says plainly that the "
|
||||||
|
"reading went down",
|
||||||
|
'k.say === "skipped" && k.wrong ? "skipped_down" : k.say' in JS
|
||||||
|
and "went down" in WS["kettle"]["skipped_down"]["description"]
|
||||||
|
and "not tested" not in WS["kettle"]["skipped_down"]["description"])
|
||||||
|
check("in setup-only mode the wizard's restart dialog does not claim a 0 W "
|
||||||
|
"wind-down that nothing runs, and the shadow one says 0 W",
|
||||||
|
"M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live)" in JS
|
||||||
|
and "0 W" in WS["dialog_setup"]["description"]
|
||||||
|
and "0 W" not in WS["dialog_setup_live"]["description"])
|
||||||
|
check("the page posts the model it opened with every save (F2)",
|
||||||
|
"model: V, base: ORIG, fingerprint: FP" in JS)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED")
|
print(f"{len(fails)} FAILED")
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""UX-15: setup-only mode. `python3 test_setup_only.py`
|
||||||
|
|
||||||
|
A new install names no inverter, and instead of exiting 78 it serves the setup
|
||||||
|
wizard and commands nothing (DECISIONS/UX-15-setup-only-mode.md).
|
||||||
|
Part 1 checks when it applies. Part 2 the write path refuses in that mode.
|
||||||
|
Part 3 boots the REAL `app.main.main()` in a child process (this file with
|
||||||
|
`--driver`) against a fake Supervisor and Home Assistant over HTTP, and logs
|
||||||
|
every request they get: the shipped defaults serve the wizard with no service
|
||||||
|
call, also with `auto_start` on, a Store that says control was started and a
|
||||||
|
Start pressed on the page; the wizard saves; the next boot passes the normal
|
||||||
|
gate and runs in shadow. Part 4: a refused but complete configuration still
|
||||||
|
exits 78 and serves nothing.
|
||||||
|
Takes ~25 s. Mutating a module this file grades? Read MUTATION.md first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||||
|
DEADLINE_S = 60
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def defaults():
|
||||||
|
import ux13_demo as D
|
||||||
|
return D.defaults(D.CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def state(eid, value, unit="W", cls=None, **attrs):
|
||||||
|
return {"entity_id": eid, "state": value,
|
||||||
|
"attributes": dict({"friendly_name": eid,
|
||||||
|
"unit_of_measurement": unit,
|
||||||
|
"device_class": cls}, **attrs)}
|
||||||
|
|
||||||
|
|
||||||
|
STATES = [state("sensor.grid", "402"),
|
||||||
|
state("number.inv1_power", "0", min=-2500, max=2500, step=1),
|
||||||
|
state("sensor.inv1_level", "62", "%", "battery"),
|
||||||
|
state("sensor.inv1_battery", "0")]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The child: the real main(), with only the outside world faked over HTTP.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def driver(scenario, work):
|
||||||
|
"""`work` holds options.json, state.json and the files the parent reads.
|
||||||
|
Every request the fakes get is streamed as `EV [...]` lines."""
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web as aioweb
|
||||||
|
from aiohttp.abc import AbstractResolver
|
||||||
|
import app.main as M
|
||||||
|
import app.store
|
||||||
|
import app.web
|
||||||
|
import ux13_demo as D
|
||||||
|
import ux13_wizard_blobs as B
|
||||||
|
import translations_lint as TL
|
||||||
|
|
||||||
|
def event(*e):
|
||||||
|
print("EV " + json.dumps(list(e)), flush=True)
|
||||||
|
|
||||||
|
opts = json.load(open(os.path.join(work, "options.json")))
|
||||||
|
stored = {"options": copy.deepcopy(opts)}
|
||||||
|
schema = D.schema_ui(D.CONFIG)
|
||||||
|
translations = {"en": TL.parse_translation_file(
|
||||||
|
os.path.join(HERE, "translations", "en.yaml"))}
|
||||||
|
|
||||||
|
# --- the fake Supervisor and Home Assistant ---------------------------
|
||||||
|
async def h_probe(_r):
|
||||||
|
event("GET", "/core/api/")
|
||||||
|
return aioweb.Response(text="API running.")
|
||||||
|
|
||||||
|
async def h_states(_r):
|
||||||
|
event("GET", "/core/api/states")
|
||||||
|
return aioweb.json_response(STATES)
|
||||||
|
|
||||||
|
async def h_state(r):
|
||||||
|
eid = r.match_info["eid"]
|
||||||
|
event("GET", "/core/api/states/" + eid)
|
||||||
|
found = next((s for s in STATES if s["entity_id"] == eid), None)
|
||||||
|
return (aioweb.json_response(found) if found
|
||||||
|
else aioweb.json_response({"message": "not found"}, status=404))
|
||||||
|
|
||||||
|
async def h_service(r):
|
||||||
|
body = await r.json()
|
||||||
|
event("SERVICE", r.match_info["domain"], r.match_info["service"], body)
|
||||||
|
return aioweb.json_response([])
|
||||||
|
|
||||||
|
async def h_mqtt(_r):
|
||||||
|
event("GET", "/services/mqtt")
|
||||||
|
return aioweb.json_response({"result": "error"}, status=400)
|
||||||
|
|
||||||
|
async def h_info(_r):
|
||||||
|
return aioweb.json_response({"data": {"slug": "local_goodwe",
|
||||||
|
"homeassistant": "2026.9.1"}})
|
||||||
|
|
||||||
|
async def h_self_info(_r):
|
||||||
|
return aioweb.json_response({"data": {
|
||||||
|
"slug": "local_goodwe", "options": stored["options"],
|
||||||
|
"schema": schema, "translations": translations}})
|
||||||
|
|
||||||
|
async def h_options(r):
|
||||||
|
body = await r.json()
|
||||||
|
stored["options"] = body["options"]
|
||||||
|
event("OPTIONS")
|
||||||
|
with open(os.path.join(work, "saved.json"), "w") as f:
|
||||||
|
json.dump(body["options"], f)
|
||||||
|
return aioweb.json_response({"result": "ok", "data": {}})
|
||||||
|
|
||||||
|
async def h_restart(_r):
|
||||||
|
event("RESTART")
|
||||||
|
return aioweb.json_response({"result": "ok", "data": {}})
|
||||||
|
|
||||||
|
async def h_other(r):
|
||||||
|
event("OTHER", r.method, r.path)
|
||||||
|
return aioweb.json_response({}, status=404)
|
||||||
|
|
||||||
|
fake = aioweb.Application()
|
||||||
|
fake.add_routes([
|
||||||
|
aioweb.get("/core/api/", h_probe),
|
||||||
|
aioweb.get("/core/api/states", h_states),
|
||||||
|
aioweb.get("/core/api/states/{eid}", h_state),
|
||||||
|
aioweb.post("/core/api/services/{domain}/{service}", h_service),
|
||||||
|
aioweb.get("/services/mqtt", h_mqtt),
|
||||||
|
aioweb.get("/info", h_info),
|
||||||
|
aioweb.get("/addons/self/info", h_self_info),
|
||||||
|
aioweb.post("/addons/self/options", h_options),
|
||||||
|
aioweb.post("/addons/self/restart", h_restart),
|
||||||
|
aioweb.route("*", "/{tail:.*}", h_other)])
|
||||||
|
|
||||||
|
fake_port = free_port()
|
||||||
|
|
||||||
|
class ToFake(AbstractResolver):
|
||||||
|
"""`http://supervisor` is the fake, on its port."""
|
||||||
|
async def resolve(self, host, port=0, family=socket.AF_INET):
|
||||||
|
return [{"hostname": host, "host": "127.0.0.1", "port": fake_port,
|
||||||
|
"family": socket.AF_INET, "proto": 0, "flags": 0}]
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
Real = aiohttp.ClientSession
|
||||||
|
|
||||||
|
def session(*a, **k):
|
||||||
|
k.setdefault("connector", aiohttp.TCPConnector(resolver=ToFake()))
|
||||||
|
return Real(*a, **k)
|
||||||
|
|
||||||
|
aiohttp.ClientSession = session
|
||||||
|
os.environ["SUPERVISOR_TOKEN"] = "test-token"
|
||||||
|
M.OPTIONS_PATH = os.path.join(work, "options.json")
|
||||||
|
app.store.STATE_PATH = os.path.join(work, "state.json")
|
||||||
|
M.INGRESS_PORT = free_port()
|
||||||
|
kw = M.Controller.__init__.__kwdefaults__
|
||||||
|
kw["peak_db_path"] = os.path.join(work, "tariff.db")
|
||||||
|
kw["price_history_path"] = os.path.join(work, "price_history.json")
|
||||||
|
|
||||||
|
served = []
|
||||||
|
real_start = app.web.start
|
||||||
|
|
||||||
|
async def start(controller, port=8099, selfcfg=None):
|
||||||
|
runner = await real_start(controller, port, selfcfg)
|
||||||
|
served.append(type(controller).__name__) # listening now
|
||||||
|
event("SERVED", type(controller).__name__,
|
||||||
|
getattr(controller.hass, "read_only", None))
|
||||||
|
return runner
|
||||||
|
|
||||||
|
app.web.start = start
|
||||||
|
real_amain = M.amain
|
||||||
|
root = f"http://127.0.0.1:{M.INGRESS_PORT}/"
|
||||||
|
|
||||||
|
async def ask(s, method, path, body=None):
|
||||||
|
async with s.request(method, root + path, json=body) as r:
|
||||||
|
try:
|
||||||
|
out = await r.json(content_type=None)
|
||||||
|
except ValueError:
|
||||||
|
out = None
|
||||||
|
event("PAGE", method, path, r.status, out)
|
||||||
|
return r.status, out
|
||||||
|
|
||||||
|
async def scenario_steps():
|
||||||
|
async with Real() as s:
|
||||||
|
await ask(s, "GET", "status")
|
||||||
|
_st, model = await ask(s, "GET", "config/model?lang=en")
|
||||||
|
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
|
||||||
|
await ask(s, "POST", "action", {"action": "auto_toggle"})
|
||||||
|
if scenario.startswith("setup"):
|
||||||
|
await ask(s, "POST", "action", {"action": "maint_start"})
|
||||||
|
wiz = B.apply(model["ui"], B.BASE)
|
||||||
|
wiz = B.browser(json.loads(json.dumps(wiz)))
|
||||||
|
await ask(s, "POST", "config/validate",
|
||||||
|
{"model": wiz, "setup": True})
|
||||||
|
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
|
||||||
|
await ask(s, "POST", "config/save",
|
||||||
|
{"model": wiz, "base": model["ui"],
|
||||||
|
"fingerprint": model["fingerprint"],
|
||||||
|
"setup": "done"})
|
||||||
|
await ask(s, "POST", "config/restart", {"confirm": "restart"})
|
||||||
|
await asyncio.sleep(3.0) # room for any stray task to write
|
||||||
|
await ask(s, "GET", "status")
|
||||||
|
|
||||||
|
async def wrapped():
|
||||||
|
runner = aioweb.AppRunner(fake)
|
||||||
|
await runner.setup()
|
||||||
|
await aioweb.TCPSite(runner, "127.0.0.1", fake_port).start()
|
||||||
|
task = asyncio.create_task(real_amain())
|
||||||
|
try:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
while not served and not task.done() \
|
||||||
|
and time.monotonic() - t0 < 20:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
if task.done():
|
||||||
|
event("ENDED_BEFORE_SERVING")
|
||||||
|
await task
|
||||||
|
return
|
||||||
|
await scenario_steps()
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
|
||||||
|
await asyncio.wait_for(task, 20)
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
M.amain = wrapped
|
||||||
|
M.main()
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run_child(scenario, options, state=None):
|
||||||
|
work = tempfile.mkdtemp(prefix="ux15-")
|
||||||
|
with open(os.path.join(work, "options.json"), "w") as f:
|
||||||
|
json.dump(options, f)
|
||||||
|
if state is not None:
|
||||||
|
with open(os.path.join(work, "state.json"), "w") as f:
|
||||||
|
json.dump(state, f)
|
||||||
|
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||||
|
try:
|
||||||
|
p = subprocess.run([sys.executable, "-B", __file__, "--driver",
|
||||||
|
scenario, work], cwd=HERE, env=env,
|
||||||
|
capture_output=True, text=True, timeout=DEADLINE_S)
|
||||||
|
rc, out, err = p.returncode, p.stdout, p.stderr
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
rc, out, err = None, e.stdout or "", e.stderr or ""
|
||||||
|
out = out.decode() if isinstance(out, bytes) else out
|
||||||
|
err = err.decode() if isinstance(err, bytes) else err
|
||||||
|
ev = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
|
||||||
|
files = {}
|
||||||
|
for name in ("saved.json", "state.json"):
|
||||||
|
path = os.path.join(work, name)
|
||||||
|
if os.path.exists(path):
|
||||||
|
files[name] = json.load(open(path))
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
return rc, ev, err, files
|
||||||
|
|
||||||
|
|
||||||
|
def page(ev, method, path):
|
||||||
|
"""(status, body) of each page request, in order."""
|
||||||
|
return [(e[3], e[4]) for e in ev
|
||||||
|
if e[0] == "PAGE" and e[1] == method and e[2].startswith(path)]
|
||||||
|
|
||||||
|
|
||||||
|
def tail(text, n=12):
|
||||||
|
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
|
||||||
|
|
||||||
|
|
||||||
|
if len(sys.argv) == 4 and sys.argv[1] == "--driver":
|
||||||
|
driver(sys.argv[2], sys.argv[3])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("1. when setup-only mode applies")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
from app import setuponly # noqa: E402
|
||||||
|
from app.validate import validate_startup_config # noqa: E402
|
||||||
|
|
||||||
|
SHIPPED = defaults()
|
||||||
|
V = validate_startup_config
|
||||||
|
check("the shipped defaults are refused only for the missing power request "
|
||||||
|
"entity, and that is setup-only mode",
|
||||||
|
setuponly.applies(SHIPPED, V(SHIPPED)) and len(V(SHIPPED)) == 1)
|
||||||
|
_blank_bad = dict(SHIPPED, maintenance_charge_w=2500)
|
||||||
|
check("no power request entity AND another refusal: not setup-only, exit 78",
|
||||||
|
len(V(_blank_bad)) >= 2 and not setuponly.applies(_blank_bad,
|
||||||
|
V(_blank_bad)))
|
||||||
|
_complete_bad = dict(SHIPPED, setpoint_entity="number.inv1_power",
|
||||||
|
maintenance_charge_w=2500)
|
||||||
|
check("a complete configuration that is refused: not setup-only, exit 78",
|
||||||
|
V(_complete_bad) and not setuponly.applies(_complete_bad,
|
||||||
|
V(_complete_bad)))
|
||||||
|
_list_blank = dict(SHIPPED, inverters=[{
|
||||||
|
"name": "inverter-a", "setpoint_entity": "", "soc_entity": "sensor.s",
|
||||||
|
"batt_entity": "sensor.b", "max_w": 2000, "bms_soc_floor": 10}])
|
||||||
|
check("an inverter list whose entry has no power request entity: not "
|
||||||
|
"setup-only (someone edited it), exit 78",
|
||||||
|
V(_list_blank) and not setuponly.applies(_list_blank, V(_list_blank)))
|
||||||
|
_ok = dict(SHIPPED, setpoint_entity="number.inv1_power")
|
||||||
|
check("a startable configuration: nothing refused, the normal start",
|
||||||
|
V(_ok) == [] and not setuponly.applies(_ok, V(_ok)))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n2. in setup-only mode the write path refuses")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
from app import hass as H # noqa: E402
|
||||||
|
import app.main as M # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""Records every request a HomeAssistant would send."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def post(self, url, **k):
|
||||||
|
self.sent.append(("POST", url))
|
||||||
|
raise AssertionError("a request was sent")
|
||||||
|
|
||||||
|
def get(self, url, **k):
|
||||||
|
self.sent.append(("GET", url))
|
||||||
|
raise AssertionError("a request was sent")
|
||||||
|
|
||||||
|
|
||||||
|
def _refused(coro):
|
||||||
|
"""True only for the read-only refusal; a request that reached the
|
||||||
|
session (AssertionError there) or no refusal at all is False."""
|
||||||
|
try:
|
||||||
|
asyncio.run(coro)
|
||||||
|
except H.ReadOnly:
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_s = Session()
|
||||||
|
_ro = H.HomeAssistant(_s, "t", read_only=True)
|
||||||
|
check("a read-only HomeAssistant refuses number.set_value before it sends "
|
||||||
|
"anything", _refused(_ro.set_number("number.inv1_power", 0.0))
|
||||||
|
and _s.sent == [])
|
||||||
|
check("...and any other service call, e.g. a notification",
|
||||||
|
_refused(_ro.call("persistent_notification", "create",
|
||||||
|
{"message": "x"})) and _s.sent == [])
|
||||||
|
try:
|
||||||
|
_ro.read_only = False
|
||||||
|
_flipped = True
|
||||||
|
except AttributeError:
|
||||||
|
_flipped = False
|
||||||
|
check("...and read-only cannot be switched off afterwards",
|
||||||
|
not _flipped and _ro.read_only is True)
|
||||||
|
_rw = H.HomeAssistant(Session(), "t")
|
||||||
|
try:
|
||||||
|
asyncio.run(_rw.set_number("number.inv1_power", 0.0))
|
||||||
|
_sent = False
|
||||||
|
except AssertionError:
|
||||||
|
_sent = True
|
||||||
|
check("control: a normal HomeAssistant does send the write", _sent
|
||||||
|
and _rw.read_only is False)
|
||||||
|
try:
|
||||||
|
M.Controller(dict(_ok), _ro, None, None, stop=asyncio.Event())
|
||||||
|
_built = True
|
||||||
|
except ValueError as err:
|
||||||
|
_built = "read-only" not in str(err)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_built = True # it got past the check and failed on something else
|
||||||
|
check("no controller can be built on a read-only HomeAssistant", not _built)
|
||||||
|
try:
|
||||||
|
setuponly.SetupOnly(SHIPPED, _rw, None)
|
||||||
|
_took = True
|
||||||
|
except ValueError:
|
||||||
|
_took = False
|
||||||
|
check("setup-only mode refuses a HomeAssistant that can write", not _took)
|
||||||
|
_so = setuponly.SetupOnly(SHIPPED, _ro, None)
|
||||||
|
check("setup-only mode holds no inverter and never says control is started",
|
||||||
|
list(_so.inverters) == [] and _so.auto is False
|
||||||
|
and _so.status_payload()["setup_only"] is True)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n3. the real main(): the shipped defaults serve the wizard, 0 writes")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_runs = {
|
||||||
|
"setup": (dict(SHIPPED), None),
|
||||||
|
# A started-control attempt three ways: auto_start on, a Store that says
|
||||||
|
# control was started, and the page's own Start (the driver presses it).
|
||||||
|
"setup_started": (dict(SHIPPED, auto_start=True), {"auto": True}),
|
||||||
|
}
|
||||||
|
SAVED = None
|
||||||
|
for name, (options, st) in _runs.items():
|
||||||
|
rc, ev, err, files = run_child(name, options, st)
|
||||||
|
services = [e for e in ev if e[0] == "SERVICE"]
|
||||||
|
print(f" {name}: exit {rc}, {len(ev)} requests logged, "
|
||||||
|
f"{len(services)} service calls")
|
||||||
|
check(f"{name}: the process served the page in setup-only mode on a "
|
||||||
|
"read-only HomeAssistant, and exited 0 when stopped",
|
||||||
|
rc == 0 and ["SERVED", "SetupOnly", True] in ev)
|
||||||
|
if rc != 0:
|
||||||
|
print(tail(err))
|
||||||
|
check(f"{name}: 0 service calls to Home Assistant (no set_value, no "
|
||||||
|
"notification), and MQTT was never asked for",
|
||||||
|
services == [] and ["GET", "/services/mqtt"] not in ev)
|
||||||
|
_status = page(ev, "GET", "status")
|
||||||
|
check(f"{name}: /status says setup_only, with the setup banner",
|
||||||
|
_status and all(b and b.get("setup_only") is True
|
||||||
|
and b.get("mode") == "setup_only"
|
||||||
|
and b.get("banner", "").startswith("Setup")
|
||||||
|
for _c, b in _status))
|
||||||
|
_model = page(ev, "GET", "config/model")
|
||||||
|
check(f"{name}: the page model opens the wizard (no power request) and "
|
||||||
|
"says setup-only",
|
||||||
|
_model and _model[0][1]["setup_needed"] == "no_power_request"
|
||||||
|
and _model[0][1]["setup_only"] is True)
|
||||||
|
_acts = page(ev, "POST", "action")
|
||||||
|
check(f"{name}: Start and the battery cycle are refused with 409",
|
||||||
|
len(_acts) == 2 and all(c == 409 for c, _b in _acts))
|
||||||
|
check(f"{name}: setup-only mode writes no Store file before the wizard "
|
||||||
|
"saves, so the first-run rule still sees a new install",
|
||||||
|
[e for e in ev if e[0] == "STATE_FILE"][:2]
|
||||||
|
== [["STATE_FILE", st is not None]] * 2)
|
||||||
|
_val = page(ev, "POST", "config/validate")
|
||||||
|
check(f"{name}: the wizard's live checks run and find nothing red",
|
||||||
|
_val and _val[0][0] == 200
|
||||||
|
and not [m for m in _val[0][1]["messages"] if m["tier"] == "bad"]
|
||||||
|
and not [m for m in _val[0][1]["setup"] if m["tier"] == "bad"])
|
||||||
|
_save = page(ev, "POST", "config/save")
|
||||||
|
check(f"{name}: the wizard saves (200) and asks for the restart",
|
||||||
|
_save and _save[0][0] == 200 and ["RESTART"] in ev
|
||||||
|
and "saved.json" in files)
|
||||||
|
check(f"{name}: the Store gets setup_done from the save",
|
||||||
|
bool(files.get("state.json", {}).get("setup_done")))
|
||||||
|
if name == "setup":
|
||||||
|
SAVED, SAVED_STATE = files.get("saved.json"), files.get("state.json")
|
||||||
|
|
||||||
|
check("the saved blob passes the normal gate: no refusal, not setup-only",
|
||||||
|
SAVED is not None and V(SAVED) == []
|
||||||
|
and not setuponly.applies(SAVED, V(SAVED))
|
||||||
|
and SAVED.get("shadow_mode") == "on")
|
||||||
|
|
||||||
|
print("\n after the restart, on the saved settings:")
|
||||||
|
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
|
||||||
|
services = [e for e in ev if e[0] == "SERVICE"]
|
||||||
|
writes = [e[3].get("value") for e in services
|
||||||
|
if e[1:3] == ["number", "set_value"]]
|
||||||
|
print(f" after: exit {rc}, {len(services)} service calls, set_value "
|
||||||
|
f"values {sorted(set(writes))}")
|
||||||
|
check("the next boot passes the normal gate: a Controller serves the page, "
|
||||||
|
"and it exits 0 when stopped", rc == 0 and ["SERVED", "Controller",
|
||||||
|
False] in ev)
|
||||||
|
if rc != 0:
|
||||||
|
print(tail(err))
|
||||||
|
check("...where Start is accepted (200), unlike in setup-only mode",
|
||||||
|
[c for c, _b in page(ev, "POST", "action")] == [200])
|
||||||
|
_status = page(ev, "GET", "status")
|
||||||
|
check("...its /status is not setup-only", _status and all(
|
||||||
|
b and "setup_only" not in b for _c, b in _status))
|
||||||
|
_model = page(ev, "GET", "config/model")
|
||||||
|
check("...and the wizard does not open again: setup_done is in the Store",
|
||||||
|
_model and _model[0][1]["setup_needed"] is None
|
||||||
|
and _model[0][1]["setup_only"] is False)
|
||||||
|
check("...and it runs in shadow, even after Start: every service call is "
|
||||||
|
"number.set_value 0 W", writes and len(writes) == len(services)
|
||||||
|
and all(w == 0.0 for w in writes))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n4. a refused configuration that is not only missing setup: exit 78")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
for name, options in (("refused", _complete_bad), ("refused_blank", _blank_bad)):
|
||||||
|
rc, ev, err, _files = run_child(name, options)
|
||||||
|
print(f" {name}: exit {rc}")
|
||||||
|
check(f"{name}: exit 78 and nothing served, no service call",
|
||||||
|
rc == 78 and not [e for e in ev if e[0] in ("SERVED", "SERVICE")]
|
||||||
|
and ["ENDED_BEFORE_SERVING"] in ev)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all passed")
|
||||||
@@ -11,7 +11,7 @@ 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
|
uipage.to_options(), like the Installer view. `blobs()` walks each step's
|
||||||
choices (meter source and sign, one to three inverters, solar rows, every
|
choices (meter source and sign, one to three inverters, solar rows, every
|
||||||
contract and price source, region, peak limit, shadow mode) from a new
|
contract and price source, region, peak limit, shadow mode) from a new
|
||||||
install and from the TEST-23 sites, and keeps what the shipped validator
|
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
|
accepts, because the wizard saves nothing it refuses. test_setup.py checks
|
||||||
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
|
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.
|
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
|
||||||
@@ -177,13 +177,14 @@ def apply(model, plan):
|
|||||||
|
|
||||||
|
|
||||||
def starts():
|
def starts():
|
||||||
"""(name, stored options) the wizard opens on: a new install (config.yaml's
|
"""(name, stored options) the wizard opens on: a new install as shipped
|
||||||
defaults and one power request entity, typed in the Configuration tab so
|
(config.yaml's defaults, served in setup-only mode since UX-15), one with a
|
||||||
the add-on starts) and the TEST-23 sites."""
|
power request entity typed in the Configuration tab, and the TEST-23
|
||||||
|
sites."""
|
||||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||||
import ux13_demo as D
|
import ux13_demo as D
|
||||||
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
|
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
|
||||||
out = [("new", new)]
|
out = [("empty", D.defaults(D.CONFIG)), ("new", new)]
|
||||||
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
|
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
|
||||||
"*.json")))[:3]:
|
"*.json")))[:3]:
|
||||||
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
|
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
|
||||||
|
|||||||
Reference in New Issue
Block a user