Compare commits

...
Author SHA1 Message Date
review-agent 3198f346fd UX-13 P4 review fixes (PR #146, comment 1887)
checks / unit (pull_request) Successful in 10m24s
infra-pin / fresh (pull_request) Successful in 1s
R1: first_start also needs a Store created at this boot (Store.fresh), so
    old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home.
R2: a kettle skip after a fall says on the last step that the reading went down.
R3: the shadow switch goes only on the model the wizard posts, never into V.
R4: Home Assistant not answering is red on the last step.
R5: meter + solar + battery below -300 W flags a reversed battery sensor.
R6: the live checks say what to change and where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:12:09 +02:00
adminandClaude Opus 5.5 be57b87c34 UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s
The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 05:31:18 +02:00
admin 515c11b380 Merge pull request 'UX-13 P2: the Installer settings view' (#145) from UX-13-p2-installer into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge UX-13 P2: the installer settings view (#145)
2026-09-28 03:54:23 +02:00
UX-13 P2 review 7be09197d4 UX-13 P2 review fixes (PR #145, comment 1880)
checks / unit (pull_request) Successful in 10m15s
infra-pin / fresh (pull_request) Successful in 2s
1. Discard reloads /config/model: after a stale save the page kept the
   new fingerprint with the old values, so Discard + Save overwrote the
   other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
   flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
   while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
   "Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
   peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
   static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 03:43:47 +02:00
Claude 1752adf6e6 UX-13 P2: test_settings reads fields through named_absence
checks / unit (pull_request) Successful in 10m15s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 03:02:43 +02:00
glenn schrooyen 0a28f71872 Merge release/1.0 (28a49cc, SAFETY-73) into UX-13-p2-installer 2026-09-28 02:49:58 +02:00
Claude c9ab48d6fc UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
  app/uilayout.py through the new app/uipage.py: search, six collapsible
  groups with "n to fix / n to check / n changed" badges and an "applies
  after a restart" chip, radio cards, segmented choices, sliders with units
  (the peak limit in kW), entity pickers with search and the live reading,
  inverter cards, solar rows, locked measured values behind "Change", and
  the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
  config/save; uimodel turns it into options before the shipped validator,
  so nothing it refuses is saved. No route added. A value equal to the
  stored one is written as stored, so a save that changed nothing writes an
  identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
  as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
  is 0 W; (b) the trade blocks and the price line name the feed's own points
  ("Lowest published price"), the hour bars say "hour average"; (c) the entity
  list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 02:49:58 +02:00
admin 28a49ccf75 Merge pull request 'SAFETY-73: a battery that refuses, per inverter, with two or more' (#143) from SAFETY-73-per-unit-refusal into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-28 02:27:15 +02:00
26 changed files with 3899 additions and 128 deletions
@@ -60,6 +60,16 @@ What UX-02 made authoritative stays authoritative:
new validator. At P0: 261 calls, 158 refused, no refusal changed.
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
into `app/static/` and the same check applies there.
- P2: the Installer view (`app/static/settings.js`) draws from
`app/uipage.py`, which serialises the table and names no key itself
(test_uilayout part 5, test_settings part 2). The view posts its page model
to the existing `/config/validate` and `/config/save`, which turn it into
options with `ui_to_options()` before the shipped validator runs. No route
was added.
- P4: the setup wizard draws the same model by each field's `wizard` step.
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
names no key. See [[UX-13-setup-wizard]].
## 4. What this costs
@@ -0,0 +1,101 @@
# UX-13 P4 — the setup wizard, and when it opens by itself
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
## 1. What the wizard is
The Setup tab draws the Installer view's page model one step at a time: a
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
controls, `/config/validate` and `/config/save`. No route was added.
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
yet (OPT-11); it says they come later.
A step moves on when no refusal is anchored to one of its shown fields
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
no field at all blocks the last step, which lists every refusal.
## 2. When it opens by itself (`uipage.setup_needed`)
The page opens `#setup` only when it is opened with no route, and only when
`/config/model` gives a reason:
- **`no_power_request`**: the stored options name no inverter to control.
- **`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.
`auto` is the key Start/Stop writes. The startup gate exits 78 before the
controller 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`
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
R1). So a Store read from a file is an old site, and it is never sent to the
wizard. A new install that restarts before the page is opened is not sent
either, and its Setup tab still works. A site that runs control, or ever
started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes
`setup_done` (an ISO time) to the Store. A save from the Installer view does
not.
**Not decided here, for the owner:** the add-on exits 78 before it serves the
page when the stored options name no power request entity, which is what a
brand-new install ships (config.yaml's `setpoint_entity: ""`). So on a truly
new install the installer fills in "Inverter setpoint entity" on the
Configuration tab first, and the wizard opens on the next page visit.
Serving a setup-only page from a refused configuration would change the CFG-01
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
Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one
`hass.state()` read, in W) before and after a kettle is switched on. The
change, not the sign, decides: a rise of at least 500 W keeps `meter_invert`
off, a fall turns it on. For a net entity or import and export, a fall cannot
be fixed by an option, so the step is not passed and the page says what to
check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by
the page, so the test is skipped and Home shows the direction after the
restart. The installer may skip the test, because a cloud can fake a fall;
the last step then warns, and after a fall it says the reading went down.
The battery and solar signs cannot be forced from the page, which never
commands an inverter. The last step reports each battery's reading with its
direction ("reads 800 W charging now, so check the inverter shows the same")
and warns when a solar entity reads below zero without its "reads negative"
switch. It also warns when meter + solar + battery, all known, add up to a
house using less than -300 W: a battery sensor reversed while giving power.
An idle or charging battery cannot be judged that way.
## 4. The last step
`validate.setup_checks()` sits beside the rules because it names option keys.
It reads nothing itself: `web.py` passes a Home Assistant entity list at most
5 s old, the running options, the running meter reading and today's price
count. Tiers are ok, info, warn and bad. A power request entity missing from
Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77)
and only the entity list can tell. So is Home Assistant not answering, since
the list is then unknown. The wizard does not save while anything is
bad. It then saves through P2's path (`setup: "done"`) and asks for the
restart as the second, confirmed request, with a dialog that names the 0 W.
The shadow switch is on by default and writes `shadow_mode: on`, which holds
every inverter at 0 W after a restart even if control is started. Off keeps the
stored value, or `auto` if that was `on`. The switch goes only on the model the
wizard posts, never into the page model the Installer view also saves.
## 5. The stale save lists only what moved (P2 test, finding a)
`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a
stale save the add-on compares that blob with what is stored now, so the banner
lists only the other surface's changes. `changed` (stored against the posted
blob) is unchanged for the classic page's "theirs → mine" list. When the
fingerprint was not handed out by this process, it falls back to `changed`.
## 6. How it is held
- test_setup.py: the steps and tables, the kettle logic (and the page's copy of
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.
- 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
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
keys each.
+26
View File
@@ -3221,6 +3221,32 @@ action log (`Controller.events`). The page no longer shows that log, but the
page and add `status` to its address. The action-log tables in this document
refer to it.*
### Setup, for a new install
The **Setup** tab walks an installer through six steps: the meter, the
inverters and batteries, solar (optional), prices, the peak limit, and a last
check. Each step shows the same fields as Settings, and nothing is saved until
the last step.
- **The kettle test** on the meter step reads the meter, then reads it again
after you switch on a kettle. If the reading goes down, the add-on flips its
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
Assistant: the meter reading, that each power request entity exists, the
battery levels, and whether prices are known. A missing power request entity
is red, so it cannot be saved.
- **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
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
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
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`.*
## Status page
The add-on page shows a banner at the top and a **Commissioning** list below
+27 -3
View File
@@ -13,6 +13,7 @@ CACHE_S = 30.0
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
@@ -28,6 +29,18 @@ def row(state: dict) -> dict | None:
"device_class": attrs.get("device_class")}
def watts(r: dict | None):
"""A power entity's reading in W, or None when it is not a number in a
power unit (the setup wizard's kettle test)."""
if not r or r.get("unit") not in _TO_W:
return None
try:
value = float(r.get("state"))
except (TypeError, ValueError):
return None
return value * _TO_W[r["unit"]] if value == value else None
def matches(r: dict, kind: str) -> bool:
"""Is `r` what a picker of this kind offers first? `kind` is one of
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
@@ -54,13 +67,16 @@ class EntityCache:
self.tried_at: float | None = None
self._lock = asyncio.Lock()
async def get(self) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read."""
async def get(self, max_age: float = CACHE_S
) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read.
`max_age` is how old a try may be before HA is asked again: the setup
wizard's last step asks for a list at most a few seconds old."""
async with self._lock: # one HA read however many pickers ask
now = self.clock()
# A failed read also waits CACHE_S, so polls while HA is down do
# not queue one 10 s read each behind this lock.
if self.tried_at is None or now - self.tried_at >= CACHE_S:
if self.tried_at is None or now - self.tried_at >= max_age:
self.tried_at = now
states = await self.hass.states()
if states is not None:
@@ -71,6 +87,14 @@ class EntityCache:
return None, None
return self.rows, now - self.read_at
async def reading(self, entity_id: str) -> dict:
"""One entity read now, not from the list: the kettle test compares
two readings seconds apart, which a 30 s list cannot. A read only."""
r = row(await self.hass.state(entity_id)) if entity_id else None
return {"entity_id": entity_id, "found": r is not None,
"state": r and r["state"], "unit": r and r["unit"],
"watts": watts(r)}
def peek(self) -> dict | None:
"""The cached rows if they are fresh, without asking Home Assistant."""
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
+9 -2
View File
@@ -132,8 +132,15 @@ class HomeAssistant:
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read all states: {err}")
return None
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s", err)
# A timeout's own text is empty, so the line names the wait instead
# (UX-13 P1 test, c).
except TimeoutError:
self._moan("read:*", "Reading the entity list failed: no answer "
"within %s seconds.", STATES_TIMEOUT_S)
return None
except (aiohttp.ClientError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s",
str(err) or type(err).__name__)
return None
if not isinstance(obj, list):
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
+70 -30
View File
@@ -82,7 +82,7 @@ def flow(w, *, shadow=False):
return "resting"
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow):
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True):
"""The four tiles. `house_w` needs all three readings, so without a solar
reading it is None rather than a number that leaves the sun out."""
house, disagree = None, False
@@ -105,7 +105,7 @@ def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow):
"solar": tile_solar(pv_w, has_solar),
"house": tile_house(house, has_solar, disagree),
"grid": tile_grid(grid_w),
"batt": tile_batt(batt_w, shadow),
"batt": tile_batt(batt_w, shadow, settled),
},
}
@@ -174,7 +174,7 @@ def hour_bars(points, day0, day1):
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
if inside else None)
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
"end_label": hhmm(b),
"end_label": hhmm(b), "points": len(inside),
"eur_kwh": None if price is None else round(price, 4),
"estimate": bool(inside) and any(p.provisional
for p in inside),
@@ -196,6 +196,19 @@ def hour_bars(points, day0, day1):
return bars
def day_range(points):
"""(lowest, highest) of today's published points, as {eur_kwh, label},
or (None, None). The feed's own points, like the trade legs, not the
chart's hour averages."""
firm = [p for p in points if not p.provisional]
if not firm:
return None, None
lo = min(firm, key=lambda p: p.eur_per_kwh)
hi = max(firm, key=lambda p: p.eur_per_kwh)
return ({"eur_kwh": round(lo.eur_per_kwh, 4), "label": hhmm(lo.start)},
{"eur_kwh": round(hi.eur_per_kwh, 4), "label": hhmm(hi.start)})
# --------------------------------------------------------------------------- #
# The plan, per battery
# --------------------------------------------------------------------------- #
@@ -407,7 +420,7 @@ def region_of(opts):
if rid in REGION_PRESETS else None}
def build_plan(c, now, shadow, running, held=False):
def build_plan(c, now, shadow, running, held=False, settled=True):
day0, day1 = local_day(now)
fresh = c.fresh_tariff_plan(now)
plan = fresh
@@ -477,17 +490,15 @@ def build_plan(c, now, shadow, running, held=False):
"day": brussels(now).date().isoformat(),
"now_h": round(_hours(now, day0), 3),
"hours": int(round(_hours(day1, day0))),
"verdict": VERDICT[verdict_key],
"verdict": (VERDICT[verdict_key] if settled or verdict_key != "held"
else ZERO_GOING),
"verdict_key": verdict_key,
"head": plan_head(verdict_key, shadow),
"head": plan_head(verdict_key, shadow, settled),
"prices": bars,
"runs": runs,
"lines": lines,
"why": c.why_words,
"price_line": line_prices(
price_now,
min(known, key=lambda b: b["eur_kwh"]) if known else None,
max(known, key=lambda b: b["eur_kwh"]) if known else None),
"price_line": line_prices(price_now, *day_range(seen)),
"tomorrow_line": line_tomorrow(tomorrow) if feed else "",
"region": region,
"region_line": (line_region(region["id"], region["year"])
@@ -499,6 +510,15 @@ def build_plan(c, now, shadow, running, held=False):
# --------------------------------------------------------------------------- #
# Batteries, attention, month peak, control
# --------------------------------------------------------------------------- #
def at_zero(inv) -> bool:
"""Has this inverter accepted a 0 W power request? A stop or an alarm asks
for 0 W at once, but the inverter gets it with the next write, so until
then the page says "Going to 0 W" (UX-13 P1 test, a). Before the first
accepted write nothing is known to be held; the heartbeat writes every
`heartbeat_s`, stopped or not, so this does not stick."""
return inv.last_write is not None and abs(inv.last_write) < 1.0
def battery_rows(c, shadow, running, held=False):
shares = {}
alloc = getattr(c, "allocation", None)
@@ -521,7 +541,8 @@ def battery_rows(c, shadow, running, held=False):
"maintenance": phase,
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
running=running, held=held,
would_w=shares.get(inv.name)),
would_w=shares.get(inv.name),
settled=at_zero(inv)),
# Per-battery reasons arrive with battery modes (OPT-11).
"why": None,
})
@@ -633,15 +654,18 @@ def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
# what the add-on would do.
held = level == "bad" and not shadow
running = (bool(c.auto) or shadow) and not held
settled = all(at_zero(inv) for inv in c.inverters)
ctl = next((r for r in checks if r.get("id") == "controller"), None)
return {
"state": {"kind": kind, "pill": pill(kind),
"ran": ctl.get("text", "") if ctl else "",
"sub": banner_sub(kind)},
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
has_solar=bool(c.pv_roster), shadow=shadow),
has_solar=bool(c.pv_roster), shadow=shadow,
settled=settled),
"batteries": battery_rows(c, shadow, running, held),
"plan": build_plan(c, now, shadow, running, held=held),
"plan": build_plan(c, now, shadow, running, held=held,
settled=settled),
"attention": ([{"severity": INFO, "id": "shadow",
"title": ATTN_SHADOW, "body": attn_shadow_body(),
"docs": None}] if shadow else []) + attention(checks),
@@ -673,8 +697,9 @@ ATTN_SHADOW = "Shadow mode is on"
def attn_shadow_body() -> str:
return "The add-on decides everything, but every inverter stays at 0 W."
ZERO_HELD, ZERO_GOING = "Held at 0 W", "Going to 0 W"
VERDICT = {
"held": "Held at 0 W",
"held": ZERO_HELD,
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
@@ -728,26 +753,31 @@ def tile_grid(grid_w) -> str:
return "Balanced"
def tile_batt(batt_w, shadow) -> str:
def _zero(settled) -> str:
return ZERO_HELD if settled else ZERO_GOING
def tile_batt(batt_w, shadow, settled=True) -> str:
if shadow:
return "Held at 0 W"
return _zero(settled)
return {"giving": "Giving power", "charging": "Charging",
"resting": "Resting", None: "No reading"}[flow(batt_w)]
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
would_w=None) -> str:
would_w=None, settled=True) -> str:
if soc is None or batt_w is None:
return "No reading from this battery."
zero = _zero(settled)
if shadow:
if would_w is None or abs(would_w) <= IDLE_W:
return "Held at 0 W. Would rest now."
return f"{zero}. Would rest now."
verb = "give" if would_w > 0 else "charge"
return f"Held at 0 W. Would {verb} {_kw(would_w)} now."
return f"{zero}. Would {verb} {_kw(would_w)} now."
if held:
return "Held at 0 W until the alarm clears."
return f"{zero} until the alarm clears."
if not running:
return "Held at 0 W while control is stopped."
return f"{zero} while control is stopped."
if phase != M_IDLE:
return {M_DRAIN: "Monthly cycle: emptying.",
M_CHARGE: "Monthly cycle: refilling.",
@@ -761,9 +791,10 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return "Resting now."
def plan_head(key, shadow) -> str:
def plan_head(key, shadow, settled=True) -> str:
if key == "held":
return "Every inverter is at 0 W until the alarm clears."
return ("Every inverter is at 0 W until the alarm clears." if settled
else "Every inverter goes to 0 W until the alarm clears.")
if key == "stopped":
return "Control is stopped, so nothing is planned."
if not shadow:
@@ -784,8 +815,10 @@ def run_grid_what(kwh, shadow) -> str:
f" up to {_kwh(kwh)} from the grid."
# The trade legs and the price line use the feed's own points (a 15-minute
# period on a quarter-hourly feed), so both name the same price and time.
def run_grid_why(price) -> str:
return f"Cheapest price in the list, {_eur(price)} per kWh."
return f"Lowest published price, {_eur(price)} per kWh."
def run_grid_amount(eur) -> str:
@@ -798,7 +831,7 @@ def run_give_what(kwh, shadow) -> str:
def run_give_why(price) -> str:
return f"Dearest price in the list, {_eur(price)} per kWh."
return f"Highest published price, {_eur(price)} per kWh."
def run_give_amount(eur) -> str:
@@ -880,8 +913,8 @@ def line_prices(now_eur, lo, hi) -> str:
if now_eur is not None:
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
if lo is not None and hi is not None:
parts.append(f"Today: cheapest {_eur(lo['eur_kwh'])} at "
f"{lo['label']}, dearest {_eur(hi['eur_kwh'])} at "
parts.append(f"Today: lowest {_eur(lo['eur_kwh'])} at "
f"{lo['label']}, highest {_eur(hi['eur_kwh'])} at "
f"{hi['label']}.")
return " ".join(parts) or "No prices for today."
@@ -924,7 +957,7 @@ def text_samples() -> list:
"""`(kind, text)` for every sentence above, rendered with real values, so
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
(2 sentences of 12 words), line (1 sentence of 25 words)."""
out = [("title", t) for t in VERDICT.values()]
out = [("title", t) for t in VERDICT.values()] + [("title", ZERO_GOING)]
out += [("title", t) for t in ATTN_TITLES.values()]
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
("line", attn_shadow_body())]
@@ -938,6 +971,7 @@ def text_samples() -> list:
out.append(("title", tile_house(None, True, True)))
out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)]
out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)]
out.append(("title", tile_batt(0, sh, settled=False)))
for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD):
for w in (2500, -2500, 0):
out.append(("plan", battery_now(62, w, phase=phase,
@@ -945,8 +979,14 @@ def text_samples() -> list:
would_w=-2400)))
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh,
running=False)))
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False,
running=False, held=True)))
for settled in (True, False):
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False,
running=False, held=True,
settled=settled)))
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh,
running=False, would_w=-2400,
settled=settled)))
out.append(("plan", plan_head("held", sh, settled=False)))
out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh,
running=True)))
for k in VERDICT:
+19 -4
View File
@@ -131,11 +131,16 @@ class SaveRefused(Exception):
own sentences, byte for byte; `changed` names the keys that moved.
"""
def __init__(self, kind, problems=(), changed=(), stored=None):
def __init__(self, kind, problems=(), changed=(), stored=None,
moved=None):
self.kind = kind
self.problems = list(problems)
self.changed = list(changed)
self.stored = stored
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
# against stored-now - when the caller knows what it opened. None
# when it does not. `changed` also holds the operator's own edits.
self.moved = None if moved is None else list(moved)
super().__init__(f"save refused ({kind})")
@@ -359,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
hass=None) -> dict:
hass=None, base=None) -> dict:
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
@@ -382,6 +387,11 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
genuinely wants what the other surface wrote replaced, the page reloads,
shows them what changed, and they save again from THAT - which is a
decision made in front of the difference rather than through it.
`base`, when given, is the blob the page opened (the one `expect` is the
fingerprint of). A stale refusal then also names the keys that moved
underneath it, so the page does not list the operator's own edits among
the settings "changed elsewhere" (UX-13 P2 test, finding a).
"""
problems = await problems_for(proposed, hass)
if problems:
@@ -395,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
moved = (changed_keys(base, stored)
if isinstance(base, dict) and fingerprint(base) == expect
else None)
keys = changed if moved is None else moved
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
", ".join(keys) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored,
moved=moved)
await selfcfg.write_options(proposed)
_LOG.info("options saved from the ingress panel; a restart is needed "
+89
View File
@@ -135,3 +135,92 @@ dialog::backdrop{background:rgba(0,0,0,.45)}
.chart [data-tip]{cursor:default}
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* UX-13 P2: Settings, the Installer view (mockup version 2) */
.btn.link{border:0;background:none;color:var(--accent);padding:6px 0;min-height:0}
.search{width:100%;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:10px 12px;min-height:44px;margin-bottom:10px}
.key{font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint);overflow-wrap:anywhere}
.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;border-radius:999px;padding:1px 8px;background:var(--info-bg);color:var(--muted);white-space:nowrap}
.chip.now{background:var(--ok-bg);color:var(--ok)} .chip.rs{background:var(--warn-bg);color:var(--warn)}
.chip.bad{background:var(--bad-bg);color:var(--bad)} .chip.changed{background:var(--accent-soft);color:var(--accent)}
details.grp{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:10px;box-shadow:var(--shadow)}
details.grp>summary{list-style:none;cursor:pointer;padding:14px;display:flex;gap:10px;align-items:center;min-height:56px;flex-wrap:wrap}
details.grp>summary::-webkit-details-marker{display:none}
details.grp>summary .grow{flex:1 1 200px}
details.grp>summary .gdesc{display:block}
details.grp>summary .chev{transition:transform .15s;flex:none;font-size:22px;line-height:1;width:18px;text-align:center;color:var(--muted)}
details.grp[open]>summary .chev{transform:rotate(90deg)}
.gchips,.gbadge{display:inline-flex;flex-wrap:wrap;gap:4px}
details.grp .body{padding:0 14px 8px}
details.sub{border-top:1px solid var(--line);margin-top:4px}
details.sub>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
.subhead{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint);margin:14px 0 0;font-weight:700}
.f{padding:12px 0;border-top:1px solid var(--line);min-width:0}
.part>.f:first-child,.subhead+.f{border-top:0}
.f .lbl{font-weight:600;font-size:14px}
.f .help{font-size:13px;color:var(--muted);margin-top:4px}
.f .ctl{margin-top:8px;min-width:0}
.f.inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:start}
.f.inline .ctl{margin-top:0;grid-row:1;grid-column:2}
.f.inline .help,.f.inline .msgs{grid-column:1/-1}
.f.changed>.head .lbl::after{content:" •";color:var(--accent)}
.msg{font-size:13px;margin-top:6px;display:flex;gap:6px;align-items:flex-start;border-radius:8px;padding:6px 8px;overflow-wrap:anywhere}
.msg .mi{font-weight:800;flex:none}
.msg.bad{background:var(--bad-bg);color:var(--bad)} .msg.warn{background:var(--warn-bg);color:var(--warn)} .msg.info{background:var(--info-bg);color:var(--info)}
.f.has-bad .ctl select,.f.has-bad .ctl input,.f.has-bad .ent-btn{border-color:var(--bad)}
.switch{position:relative;width:52px;height:44px;border:0;background:none;cursor:pointer;flex:none;padding:0}
.switch::before{content:"";position:absolute;left:2px;top:8px;width:48px;height:28px;border-radius:14px;background:var(--track);transition:background .15s}
.switch::after{content:"";position:absolute;top:11px;left:5px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:left .15s}
.switch[aria-checked="true"]::before{background:var(--accent)}
.switch[aria-checked="true"]::after{left:25px}
.swrap{display:inline-flex;align-items:center;gap:8px;min-height:44px}
.swtext{font-size:13px;color:var(--muted)}
select.sel,input.txt,input.num{border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 10px;min-height:44px;max-width:100%}
select.sel{width:100%}
.row>select.sel{width:auto;flex:1 1 200px}
input.num{width:110px;text-align:right;font-variant-numeric:tabular-nums}
input.txt{width:100%}
input:disabled{opacity:.6}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-width:100%;flex-wrap:wrap}
.seg button{border:0;background:var(--card);padding:8px 12px;min-height:44px;cursor:pointer;border-left:1px solid var(--line)}
.seg button:first-child{border-left:0}
.seg button[aria-checked="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
.choice{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;cursor:pointer;width:100%;text-align:left;background:var(--card);min-height:44px}
.choice[aria-checked="true"]{border-color:var(--accent);background:var(--accent-soft)}
.choice .rad{flex:none;width:18px;height:18px;border-radius:50%;border:2px solid var(--faint);margin-top:2px}
.choice[aria-checked="true"] .rad{border:5px solid var(--accent)}
.slider{display:flex;align-items:center;gap:10px;width:100%}
.slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:28px}
.unit{color:var(--muted);font-size:13px;white-space:nowrap}
.ent-btn{width:100%;text-align:left;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:8px 10px;min-height:44px;cursor:pointer;display:flex;gap:8px;align-items:center}
.ent-btn .grow{min-width:0}
.ent-btn .grow>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ent-pop{border:1px solid var(--line);border-radius:10px;margin-top:6px;background:var(--card);overflow:hidden}
.ent-pop input[type=search]{border:0;border-bottom:1px solid var(--line);width:100%;padding:10px;background:var(--card);min-height:44px}
.ent-note{padding:6px 10px}
.ent-list{max-height:240px;overflow:auto}
.ent-opt{display:flex;gap:8px;align-items:center;width:100%;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;padding:8px 10px;cursor:pointer;min-height:44px}
.ent-opt:hover,.ent-opt:focus{background:var(--accent-soft)}
.ent-opt .grow{min-width:0}
.ent-opt .grow>div{overflow-wrap:anywhere}
.ent-opt .v{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);white-space:nowrap}
.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px}
.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)}
.elist>.btn{margin-top:8px}
.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0}
.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap}
.inv>.f:nth-child(2){border-top:0}
.invs>.btn{margin:4px 0 8px}
#savebar{position:fixed;left:0;right:0;bottom:0;z-index:15}
#savebar .in{max-width:820px;margin:0 auto 10px;padding:10px 12px;background:var(--ink);color:var(--bg);border-radius:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,.25);width:calc(100% - 32px)}
#savebar .btn{min-height:44px;padding:6px 12px;background:transparent;color:var(--bg);border-color:rgba(127,127,127,.6)}
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
@media (max-width:640px){#savebar{bottom:62px}}
#s-set{padding-bottom:80px}
/* UX-13 P4: the setup wizard */
.steps{display:flex;gap:4px;margin:4px 0 12px}
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
.msg.ok{background:var(--ok-bg);color:var(--ok)}
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
.kettle .row{margin-top:8px}
+13 -10
View File
@@ -40,10 +40,12 @@
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
}
let onYes = null;
// `key` names Home's words in ui_text, or is {name, description, yes}.
function ask(key, fn) {
$("#dlg-t").textContent = t("dialog." + key + ".name");
$("#dlg-b").textContent = t("dialog." + key + ".description");
$("#dlg-yes").textContent = t("dialog." + key + ".yes");
const w = typeof key === "string" ? t("dialog." + key) : key;
$("#dlg-t").textContent = w.name;
$("#dlg-b").textContent = w.description;
$("#dlg-yes").textContent = w.yes;
onYes = fn;
const d = $("#dlg");
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
@@ -98,7 +100,6 @@
renderControl(structured);
renderDetails(!structured);
renderBatteries();
renderSettings();
}
/* ---------------- Home: now ---------------- */
@@ -162,7 +163,7 @@
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = b.estimate ? ", " + t("plan.estimate_tip") : "";
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
@@ -289,7 +290,7 @@
if (open) $("#c-details").open = true;
}
/* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */
/* ---------------- Batteries (placeholder until P3) ---------------- */
function renderBatteries() {
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
@@ -301,10 +302,6 @@
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
}
function renderSettings() {
const line = S.plan && S.plan.region_line;
$("#set-region").textContent = line || ""; $("#set-region").hidden = !line;
}
/* ---------------- control buttons ---------------- */
// The action is a toggle, so the page reads the live state first and sends
@@ -334,6 +331,11 @@
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
// Shared with settings.js, which loads after this file.
// `first`: the page opened with no route, so settings.js may open the setup
// wizard on a new install (UX-13 P4).
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
function select(tab, focus) {
@@ -345,6 +347,7 @@
if (focus) tab.focus();
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
schedule();
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
}
TABS.forEach((x, i) => {
x.addEventListener("click", () => select(x, false));
+30 -10
View File
@@ -96,25 +96,39 @@
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="config" data-t="batteries.settings"></a>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="card">
<h2 data-t="settings.name"></h2>
<p class="small muted" data-t="settings.description"></p>
<p class="small" id="set-region" hidden></p>
<a class="btn primary" href="config" data-t="settings.open"></a>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
<a class="small" href="config" id="set-classic" data-s="classic"></a>
</div>
<p class="small muted" style="margin:0 0 10px" data-s="description"></p>
<div id="set-banner" aria-live="polite"></div>
<div id="set-body">
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
<div id="groups"><p class="small muted" data-s="loading"></p></div>
<p class="small muted" id="set-nomatch" hidden data-s="no_match.description"></p>
<p class="tiny muted" data-s="footer.description"></p>
</div>
</section>
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
<div class="card">
<h2 data-t="setup.name"></h2>
<p class="small muted" data-t="setup.description"></p>
<a class="btn" href="config" data-t="settings.open"></a>
<div class="card" id="wz">
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
<div class="steps" id="wz-steps" aria-hidden="true"></div>
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
<div id="wz-banner" aria-live="polite"></div>
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
<div class="row spread" id="wz-nav" style="margin-top:14px">
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
</div>
</div>
<p class="tiny muted" data-s="setup.description"></p>
</section>
</main>
@@ -126,10 +140,16 @@
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="savebar" role="region" hidden><div class="in">
<span class="grow" id="sb-text" aria-live="polite"></span>
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
</div></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
<script>/*SETTINGS_JS*/</script>
</body>
</html>
+808
View File
@@ -0,0 +1,808 @@
"use strict";
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
from app/uimodel.py), so this file names no option key: keys, labels,
ranges and choices all arrive as data. It posts the page model back; the
add-on turns it into options and runs its own start checks, and nothing
they refuse is saved. Every word is from app/ui_text or the add-on, and
data is set as text, never as HTML.
P4: the Setup tab's wizard is the same page model, drawn one step at a
time from each field's `wizard` step, and saved through the same routes. */
(function () {
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
const W = ROOT.settings;
const { h, ask, toast, maint } = window.UX13;
const $ = (s) => document.querySelector(s);
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
$("#savebar").setAttribute("aria-label", W.savebar.label);
let M = null; // the last /config/model
let V = null; // the page model being edited
let ORIG = null; // the page model as stored
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
let MSG = []; // [{field, card, tier, text}] from the add-on
let DEVICE = "";
let LIST = null; // the inverter list field
let FILLED = {}; // field key -> the preset field that fills it
let view = null; // "settings" or "setup": the tab drawing V
let STEPS = {}; // wizard step -> refusals that block it
let uid = 0;
const ROWS = [];
const OPEN = new Set();
const UNLOCKED = new Set();
/* ---------------- the page model ---------------- */
const sub = (f) => f.key.slice(LIST.key.length + 1);
function valueIn(model, f, card) {
if (card == null) return model[f.key];
const c = (model[LIST.key] || [])[card];
return c ? c[sub(f)] : undefined;
}
const get = (f, card) => valueIn(V, f, card);
function visible(f) {
return (f.show || []).every(([name, op, want]) => {
const have = V[name];
if (op === "==") return same(have, want);
if (op === "!=") return !same(have, want);
if (op === "in") return want.some((x) => same(x, have));
if (have == null || have === "" || isNaN(+have)) return false;
return op === ">" ? +have > want : +have < want;
});
}
function put(f, card, v, redraw) {
if (card == null) V[f.key] = v;
else V[LIST.key][card][sub(f)] = v;
if (f.mirror) {
const m = findField(f.mirror);
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
}
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
if (redraw || f.fills) redrawView(); else paint();
queueValidate();
}
function findField(key) {
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.key === key) return f;
for (const s of f.fields || []) if (s.key === key) return s;
}
return null;
}
/* ---------------- numbers: stored units <-> shown units ---------------- */
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
function shown(f, v) {
if (v == null || v === "") return "";
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
}
function stored(f, x) {
const st = f.step || (f.type === "integer" ? 1 : 0);
let v = x * (f.scale || 1);
if (st) v = Math.round(v / st) * st;
if (f.min != null) v = Math.max(f.min, v);
if (f.max != null) v = Math.min(f.max, v);
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
}
/* ---------------- controls ---------------- */
// A choice the page can only keep stays offered while it is the stored value.
function choicesOf(f, card) {
const cur = get(f, card), was = valueIn(ORIG, f, card);
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
return out;
}
function toggle(f, card, lid) {
const on = !!get(f, card);
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
b.addEventListener("click", () => {
const n = b.getAttribute("aria-checked") !== "true";
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
put(f, card, n);
});
return h("span", { class: "swrap" }, b, txt);
}
function radios(f, card, lid, cards) {
const cur = get(f, card);
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
const items = choicesOf(f, card);
const buttons = items.map(([v, label], i) => {
const on = same(v, cur);
const hint = cards && f.hints ? f.hints[v] : "";
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
b.addEventListener("click", () => put(f, card, v, true));
b.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = (i + d + items.length) % items.length;
put(f, card, items[j][0], true);
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
if (again) again.focus();
});
return b;
});
g.dataset.rk = f.key + "|" + card;
g.append(...buttons);
return g;
}
function select(f, card, lid) {
const cur = get(f, card);
const s = h("select", { class: "sel", "aria-labelledby": lid });
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
const preset = f.presets && f.presets[cur];
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
}
function slider(f, card, lid) {
if (f.min == null || f.max == null) return number(f, card, lid);
const sc = f.scale || 1;
const st = f.step || (f.type === "integer" ? 1 : 0.01);
const v = get(f, card);
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
n.addEventListener("change", () => {
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
});
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
}
function number(f, card, lid) {
const sc = f.scale || 1;
const by = FILLED[f.key];
const preset = by && by.presets[V[by.key]];
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
n.addEventListener("input", () => {
if (n.value === "") { if (f.optional) put(f, card, null); return; }
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
});
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
}
function text(f, card, lid) {
const v = get(f, card);
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
t.addEventListener("input", () => put(f, card, t.value));
return t;
}
function locked(f, card, lid) {
const box = h("div", {});
const id = f.key + "#" + card;
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
if (UNLOCKED.has(id)) { open(); return box; }
const v = get(f, card);
box.append(h("div", { class: "row" },
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
h("span", { class: "chip" }, W.locked),
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
return box;
}
/* entity picker: search, the matching kind first, the live reading */
const ENT = {};
function entities(kind) {
const now = Date.now();
const e = ENT[kind];
if (e && now - e.at < 30000) return e.p;
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
.catch(() => ({ available: false, entities: [] }));
ENT[kind] = { at: now, p };
return p;
}
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
function picker(f, lid, value, pick) {
let data = null, all = false;
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
const pop = h("div", { class: "ent-pop", hidden: true });
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
const note = h("div", { class: "tiny muted ent-note" });
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
const allBox = h("label", { class: "tiny muted ent-all" },
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
function drawBtn() {
const id = value();
const e = data && data.entities.find((x) => x.entity_id === id);
btn.replaceChildren();
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
}
function opt(id, name, val) {
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
}
function fill() {
const typed = q.value.trim();
const t = typed.toLowerCase();
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
note.textContent = data && data.text ? data.text : "";
note.hidden = !note.textContent;
}
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
btn.addEventListener("click", () => {
if (!pop.hidden) { close(); return; }
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
setTimeout(() => q.focus(), 0);
});
q.addEventListener("input", fill);
pop.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const opts = [...list.querySelectorAll(".ent-opt")];
const i = opts.indexOf(document.activeElement);
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
});
pop.append(q, note, list, allBox);
drawBtn();
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
return h("div", { class: "ent" }, btn, pop,
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
}
function entity(f, card, lid) {
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
}
function entityList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((_id, i) => {
box.append(h("div", { class: "erow" },
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
return box;
}
function solarList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((row, i) => {
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
box.append(h("div", { class: "erow" },
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
return box;
}
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList };
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
if (view === "setup") return ""; // the wizard shows no keys
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
}
function fieldRow(f, card, gid) {
if (f.control === "inverter_list") return cardList(f, gid);
const lid = "sf" + (++uid);
const keys = keyText(f, card);
const msg = h("div", { class: "msgs" });
const make = CONTROLS[f.control] || text;
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""),
keys ? h("div", { class: "key" }, keys) : null),
h("div", { class: "ctl" }, make(f, card, lid)),
f.help ? h("div", { class: "help" }, f.help) : null, msg);
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
return row;
}
function blankCard(f, first) {
const out = {};
for (const s of f.fields) {
const k = sub(s);
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
}
return out;
}
function cardList(f, gid) {
const wrap = h("div", { class: "invs", "data-key": f.key });
const msg = h("div", { class: "msgs" });
const cards = V[f.key];
if (!Array.isArray(cards)) {
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
} else {
cards.forEach((c, i) => {
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
// Back to one card, the stored shape returns: a flat inverter stays flat.
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
put(f, null, V[f.key], true);
} }, W.remove) : null));
for (const s of f.fields) box.append(fieldRow(s, i, gid));
wrap.append(box);
});
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
put(f, null, V[f.key], true);
} }, "+ " + W.add_inverter));
}
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
return wrap;
}
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
function render() {
const host = $("#groups");
const y = window.scrollY;
host.replaceChildren();
ROWS.length = 0;
for (const g of M.layout.groups) {
const d = h("details", { class: "grp", "data-g": g.id },
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
d.open = OPEN.has(g.id);
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
const body = h("div", { class: "body" });
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
for (const p of g.parts) {
const pid = g.id + "." + p.id;
let box;
if (p.collapsed) {
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
box.open = OPEN.has(pid);
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
} else {
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
}
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
body.append(box);
}
if (g.id === "maintenance") {
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
h("div", { class: "row" },
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
}
d.append(body);
host.append(d);
}
paint();
window.scrollTo(0, y);
}
/* ---------------- messages, visibility, search, badges ---------------- */
const ICON = { bad: "×", warn: "!", info: "i" };
const RANK = { bad: 0, warn: 1, info: 2 };
function mine(r, m) {
if (m.field !== r.f.key) return false;
if (r.list || r.card == null) return true;
return m.card == null ? r.card === 0 : m.card === r.card;
}
function paint() {
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
const placed = new Set();
for (const r of ROWS) {
r.visible = visible(r.f);
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible) placed.add(i); return hit; })
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
r.tiers = new Set(msgs.map((m) => m.tier));
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
// A card list counts as one change when cards were added or removed;
// an edit inside a card counts on its own row.
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
r.el.classList.toggle("changed", r.changed && !r.list);
}
// Search: a row shows when it is visible and matches. A card list shows
// while any of its rows does.
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
let any = false;
document.querySelectorAll("#groups details.grp").forEach((d) => {
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
d.querySelectorAll(".part").forEach((p) => {
const inside = rows.filter((r) => p.contains(r.el));
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
});
const hit = rows.some((r) => !r.el.hidden);
any = any || hit;
d.hidden = !!q && !hit;
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
const shownRows = rows.filter((r) => r.visible && !r.list);
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
const changed = rows.filter((r) => r.changed).length;
d.querySelector(".gbadge").replaceChildren(...[
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
});
$("#set-nomatch").hidden = !q || any;
// A sentence shown beside any field is not repeated here.
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
if (DEVICE) other.push({ tier: "info", text: DEVICE });
$("#set-other").hidden = !other.length;
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
savebar();
}
function dirtyCount() {
const seen = new Set();
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
return seen.size;
}
function savebar() {
const n = V ? dirtyCount() : 0;
$("#savebar").hidden = !(view === "settings" && n);
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
}
$("#set-search").addEventListener("input", () => { if (V) paint(); });
/* ---------------- talking to the add-on ---------------- */
let vtimer = null, vseq = 0;
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
// or null when it failed or a newer check overtook it.
async function validate(extra) {
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
const b = await r.json();
if (seq !== vseq) return null;
MSG = b.messages || [];
DEVICE = b.device_note || "";
STEPS = b.steps || {};
paint();
return b;
} catch (e) { return null; /* the next change tries again */ }
}
function banner(level, title, sub, button, onClick) {
const host = $("#set-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
function restart() {
ask(W.dialog, async () => {
try {
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
} catch (e) { /* the add-on stops answering while it restarts */ }
toast(W.toast.restarting.description);
});
}
async function load(after) {
let m;
try {
const r = await fetch("config/model?lang=en", { cache: "no-store" });
if (!r.ok) throw new Error("model " + r.status);
m = await r.json();
if (!m.layout || !m.ui) throw new Error("no layout");
} catch (e) {
$("#groups").replaceChildren();
$("#set-search").hidden = true;
banner("bad", W.load_failed.name, W.load_failed.description);
return;
}
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
UNLOCKED.clear();
$("#set-search").hidden = false;
FILLED = {};
LIST = null;
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.fills) FILLED[f.fills] = f;
if (f.control === "inverter_list") LIST = f;
}
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
else banner();
if (view === "setup") drawStep(); else render();
validate();
}
function firstBad() {
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
r.el.scrollIntoView({ block: "center" });
const input = r.el.querySelector("input,select,button");
if (input) input.focus({ preventScroll: true });
}
// 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.
async function postSave(extra) {
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, fingerprint: FP }, extra || {})) });
const b = await r.json().catch(() => ({}));
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
return { r, b };
}
// The settings another surface changed (not this page's own edits).
function staleText(b) {
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
}
async function save() {
const btn = $("#sb-save");
btn.disabled = true; btn.textContent = W.savebar.saving;
try {
clearTimeout(vtimer);
await validate();
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
const { r, b } = await postSave();
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
if (r.status === 409 && b.kind === "stale") {
banner("bad", W.stale.name, staleText(b));
return;
}
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
toast(W.toast.failed);
} catch (e) { toast(W.toast.failed); }
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
}
$("#sb-save").addEventListener("click", save);
// Discard reloads what is stored: after a stale save ORIG is no longer it.
$("#sb-discard").addEventListener("click", async () => {
await load(); toast(W.toast.discarded);
});
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
/* ---------------- the setup wizard (P4) ---------------- */
const WS = W.setup;
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
function redrawView() { if (view === "setup") drawStep(); else render(); }
function allFields() {
const out = [];
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
return out;
}
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
function watts(w, signed) {
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
return (signed && w < 0 ? "-" : "") + n + " W";
}
function line(tier, text) {
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
}
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
// uipage.kettle() (test_setup.py grades that one) on the page.
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
function kettleOutcome(src, before, after) {
const d = after - before;
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
const o = src[d > 0 ? "up" : "down"];
return { say: o.say, sets: o.sets, d };
}
function kettleOk() {
const src = kettleSource();
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
}
async function readMeter(src) {
let total = 0;
for (const [key, sign] of src.read) {
if (!V[key]) return null;
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
if (!r || r.watts == null) return null;
total += sign * r.watts;
}
return total;
}
function kettleBox() {
const src = kettleSource();
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
const sig = kettleSig(src);
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
const k = WZ.kettle || { sig };
box.append(h("div", { class: "help" }, WS.kettle.description));
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn" }[k.say] || "bad";
const words = k.say ? WS.kettle[k.say] : k.before != null ? WS.kettle.before : null;
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) : "" })));
const busy = (b) => { b.disabled = true; };
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
read.addEventListener("click", async () => {
busy(read);
const sig = kettleSig(src); // the entity may have changed since drawing
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
const w = await readMeter(src);
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong };
else {
const o = kettleOutcome(src, k.before, w);
// `wrong`: the reading went the way no option can fix. A skip after
// that keeps saying so on the last step (review R2).
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
}
drawStep();
});
const row = h("div", { class: "row" }, read);
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
} }, WS.kettle.skip));
box.append(row);
return box;
}
// Step 6: the shadow switch, the checks, and Save and restart.
// The shadow switch goes only on the model the wizard posts, never into V,
// which the Installer view saves too (review R3).
function shadowValue() {
const s = M.setup.shadow;
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
}
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
function shadowRow() {
const lid = "wzs";
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); });
return h("div", { class: "f inline" },
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
h("div", { class: "ctl" }, b));
}
async function runChecks() {
const list = $("#wz-checks");
if (list) list.replaceChildren(line("info", WS.check.running.description));
clearTimeout(vtimer);
const b = await validate({ setup: true, model: posted() });
WZ.checked = b;
if (!list || !list.isConnected) return b;
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
const items = [], seen = new Set();
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
const bad = (b.messages || []).filter((m) => m.tier === "bad");
if (!bad.length) add("ok", WS.check.pass.description);
for (const m of bad) add("bad", m.text);
for (const m of b.setup || []) add(m.tier, m.text);
if (b.device_note) add("info", b.device_note);
const src = kettleSource();
if (!src) add("info", WS.kettle.self.description);
else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
else if (kettleOk()) add("ok", WS.kettle.tested.description);
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
WZ.blocked = items.some(([t]) => t === "bad");
return b;
}
function finalBox() {
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
setTimeout(runChecks, 0);
return [h("div", { class: "subhead" }, WS.check.name),
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
shadowRow()];
}
function wzBanner(level, title, sub, button, onClick) {
const host = $("#wz-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
async function finish() {
const b = await runChecks();
if (!b) { toast(WS.check.failed.description); return; }
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
const shadow = shadowValue() === M.setup.shadow.on;
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
wzBanner();
let res;
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
const { r, b: body } = res;
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
if (r.status === 422) {
MSG = body.messages || []; STEPS = body.steps || {};
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
if (first) WZ.step = first;
drawStep(); toast(W.toast.blocked.description); firstBad();
return;
}
if (!r.ok) { toast(W.toast.failed); return; }
let restarted = true;
try {
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
restarted = rr.ok;
} catch (e) { /* the add-on stops answering while it restarts */ }
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
drawStep();
if (!restarted) await load("saved"); // still running: read what is stored
});
}
function drawDone() {
const w = WS[WZ.done];
$("#wz-count").textContent = "";
$("#wz-steps").replaceChildren();
$("#wz-desc").textContent = "";
$("#wz-nav").hidden = true;
ROWS.length = 0;
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
else wzBanner("ok", w.name, w.description);
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
}
function drawStep() {
if (!M) return;
if (WZ.done) { drawDone(); return; }
const S = M.setup, n = WZ.step, words = WS.steps[n];
$("#wz-nav").hidden = false;
$("#wz-title").textContent = words.name;
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
$("#wz-desc").textContent = words.description;
ROWS.length = 0;
const body = [];
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
if (n === 1) body.push(kettleBox());
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
if (n === S.final) body.push(...finalBox());
$("#wz-body").replaceChildren(...body);
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
paint();
}
$("#wz-back").addEventListener("click", () => {
if (WZ.step === 1) { location.hash = "#home"; return; }
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
});
$("#wz-next").addEventListener("click", async () => {
if (!M) return;
const btn = $("#wz-next");
btn.disabled = true;
try {
const n = WZ.step;
if (n === M.setup.final) { await finish(); return; }
clearTimeout(vtimer);
const b = await validate();
if (!b) { toast(W.toast.failed); return; }
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
} finally { btn.disabled = false; }
});
document.addEventListener("ux13:tab", (e) => {
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
});
// app.js chose the first tab before this file ran.
const sel = document.querySelector(".tab[aria-selected=true]");
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
// First run: opened with no route, on a site the add-on says is new
// (uipage.setup_needed), the page goes to Setup by itself.
else if (window.UX13.first) {
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
}
})();
+4
View File
@@ -585,6 +585,9 @@ class Store:
def __init__(self, path: str = "/data/state.json"):
self.path = path
self.data = dict(DEFAULTS)
# 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).
self.fresh = False
self.load()
def load(self) -> None:
@@ -598,6 +601,7 @@ class Store:
self.data.get("phase"), self.data.get("last_completed"),
self.data.get("sign_tripped"))
except FileNotFoundError:
self.fresh = True
_LOG.info("no saved state, starting fresh")
except (json.JSONDecodeError, OSError) as err:
# A corrupt state file must not stop the controller: losing the
+139 -4
View File
@@ -29,7 +29,12 @@
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"}
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"}
},
"applies": {
"restart": {"name": "Applies after a restart"},
@@ -58,7 +63,8 @@
"estimate_tip": "estimate from yesterday",
"none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words"
"list": "The plan in words",
"average_tip": "hour average"
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
@@ -74,7 +80,136 @@
"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."},
"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 new settings screens arrive in a later update, so use the settings page for now.", "open": "Open the settings page"},
"setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."}
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
},
"choice_help": {
"meter_source": {
"off": {"description": "A Home Assistant entity that reads grid power in W, positive when you take from the grid."},
"ha_dsmr": {"description": "Two Home Assistant entities from the DSMR integration, one for import and one for export."},
"mqtt_p1": {"description": "The meter's readings arrive over MQTT on a topic you name."},
"ha_signed": {"description": "One Home Assistant entity that reads net grid power, positive for import."},
"homewizard_local": {"description": "The add-on reads a HomeWizard meter on your network, and can spot a stuck meter."}
}
},
"settings": {
"name": "Settings",
"description": "Every setting, grouped by task, and the simple view follows in a later update.",
"classic": "Classic settings",
"search": {"name": "Find a setting", "description": "Find a setting, for example peak or meter."},
"no_match": {"description": "No setting matches your search."},
"loading": "Loading the settings",
"load_failed": {"name": "Cannot read the settings", "description": "The add-on could not read its stored settings, so use Classic settings or the Configuration tab."},
"footer": {"description": "Every change is checked by the add-on's own start checks before it is saved."},
"to_fix": "to fix",
"to_check": "to check",
"changed": "changed",
"on": "On",
"off": "Off",
"not_set": "Not set",
"locked": "Locked",
"change": "Change",
"support": {"description": "These were measured on real hardware, so change them only when support asks."},
"picker": {
"choose": "Choose an entity",
"search": {"description": "Search by name or entity id."},
"all": "Show every entity",
"use": "Use this entity id",
"clear": "Clear",
"missing": "Not in Home Assistant",
"none": {"description": "Nothing matches, so check the entity in Developer Tools."}
},
"add_inverter": "Add an inverter",
"inverter": "Inverter",
"remove": "Remove",
"add_row": "Add a row",
"add_solar": "Add a solar inverter",
"reversed": "Reads negative",
"phase": "Phase",
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"region_year": "{year} figures",
"hour": "{hour}:00 UTC",
"preset": {"description": "Filled in by the region above, so choose your own figure to change it."},
"cycle": {"name": "Run a cycle by hand", "description": "The same actions as on Home, each asking first."},
"savebar": {"label": "Unsaved changes", "one": "change not saved", "many": "changes not saved", "discard": "Discard", "save": "Save", "saving": "Saving"},
"saved": {"name": "Saved, not running yet", "description": "The add-on runs the settings it started with until you restart it."},
"differs": {"name": "Stored settings are not running", "description": "Some stored settings differ from what runs now, so restart the add-on to apply them."},
"stale": {"name": "Changed somewhere else", "description": "These settings changed elsewhere while you edited, so save again to keep yours or discard to load theirs."},
"other": "Other messages",
"restart": "Restart now",
"dialog": {"name": "Restart the add-on?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Restart now"},
"toast": {
"saved": "Saved. Restart to apply.",
"discarded": "Changes discarded.",
"failed": "That did not work. Try again.",
"blocked": {"description": "Fix the settings marked in red, then save again."},
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
},
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"},
"derive": {
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"no_inverter": {"description": "Add at least one inverter."},
"flat_named": {"description": "Add a second inverter before naming this one."},
"no_source": {"description": "Choose where the dynamic prices come from."},
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
"no_price": {"description": "Enter a price per kWh."},
"unknown": {"description": "The page cannot store this choice, so reload the page."}
},
"setup": {
"name": "Setup",
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
"step": "Step {n} of {total}",
"back": "Back",
"next": "Next",
"skip": "Next, or skip",
"finish": "Save and restart",
"not_now": "Not now",
"steps": {
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
},
"modes_later": {"description": "Choosing a mode for each battery comes in a later update."},
"kettle": {
"name": "Check the direction",
"description": "Read the meter, switch on a kettle, then read the meter again.",
"read": "Read the meter",
"again": "Read again",
"skip": "Skip this test",
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
"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_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
"tested": {"description": "The meter direction was tested with a kettle."},
"needed": {"description": "Do the kettle test first, or skip it."}
},
"check": {
"name": "What the add-on checked",
"running": {"description": "Checking with the add-on's own start checks."},
"pass": {"description": "The add-on's start checks pass."},
"failed": {"description": "The check did not run, so try again."},
"again": "Check again",
"blocked": {"description": "Fix the items marked in red, then check again."}
},
"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_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"},
"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."},
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
"home": "Open Home"
}
}
}
+40 -4
View File
@@ -43,6 +43,8 @@ class Field:
applies: str = RESTART
simple: bool = False # also on Settings, Simple
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
mirror: str = "" # a choice also copied to this field, if offered
fills: str = "" # a preset choice fills this field (uimodel)
@property
def text(self) -> str:
@@ -73,17 +75,20 @@ _MAINT = (("maintenance_enabled", "==", True),)
_ONE = (("inverter_count", "==", 1),)
_SEVERAL = (("inverter_count", ">", 1),)
_PHASES = (("meter_phases", ">", 1),)
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
_LISTED = (("inverter_shape", "!=", "flat"),)
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
ROSTER_FIELDS = (
Field("inverters.name", TEXT, wizard=2),
Field("inverters.name", TEXT, show=_LISTED, wizard=2),
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
Field("inverters.batt_invert", TOGGLE, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
wizard=2),
)
GROUPS = (
@@ -123,7 +128,9 @@ GROUPS = (
)),
Group("tariff", (
Part("", (
Field("ui.contract", SEGMENTED, wizard=4),
# A contract without a feed is also the price used when a feed
# is down, so the fallback follows it (uimodel._tariff_encode).
Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"),
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
Field("price_formula", TEXT,
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
@@ -131,7 +138,8 @@ GROUPS = (
Field("price_csv_path", TEXT,
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
wizard=4),
Field("ui.region", SELECT, wizard=4),
Field("ui.region", SELECT, wizard=4,
fills="price_taxes_eur_kwh"),
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
wizard=4),
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
@@ -226,6 +234,34 @@ GROUPS = (
), support_only=True),
)
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
# --------------------------------------------------------------------------- #
WIZARD_STEPS = 6
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
# read before and after a kettle is switched on, each with the sign it adds to
# the grid reading, and what each direction means as (outcome, fields it
# sets). Sets None: the options cannot fix it, so the step is not passed. A
# source not listed is read by the add-on itself, so the page cannot test it.
KETTLE_SOURCE = "meter_source"
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
KETTLE = {
"off": ((("meter_entity", 1),),
{"up": ("ok", {"meter_invert": False}),
"down": ("flip", {"meter_invert": True})}),
"ha_signed": ((("p1_net_entity", 1),),
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
}
# Step 6 starts the site in shadow mode unless the installer switches it off:
# (field, value when on, value when off).
SHADOW = ("shadow_mode", "on", "auto")
# Option keys the page never shows. uimodel.py writes each from page fields.
DERIVED = {
"setpoint_entity": "inverters", # one inverter, stored flat
+31 -12
View File
@@ -21,11 +21,23 @@ from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
"arbitrage_from_batteries", "DeriveError"]
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
class DeriveError(ValueError):
"""The page model asks for something no options blob can say."""
"""The page model asks for something no options blob can say. `code` names
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
page field it is shown beside."""
def __init__(self, text, code="", field=""):
super().__init__(text)
self.code, self.field = code, field
# The page model's inverter count, which show conditions read, and what adding
# a card sets: a second inverter turns the one flat inverter into a list.
COUNT = "inverter_count"
GROWN = {"inverter_shape": "list"}
# --------------------------------------------------------------------------- #
@@ -80,13 +92,16 @@ def _roster_encode(m, keep):
out = {}
shape, cards = m.get("inverter_shape"), m.get("inverters")
if shape == "raw":
raise DeriveError("the inverter list is edited in the Configuration tab")
raise DeriveError("the inverter list is edited in the Configuration tab",
"raw_roster", "inverters")
if not cards:
raise DeriveError("at least one inverter is needed")
raise DeriveError("at least one inverter is needed", "no_inverter",
"inverters")
if shape == "flat" and len(cards) == 1:
card = cards[0]
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
raise DeriveError("one inverter stored flat has no name or capacity")
raise DeriveError("one inverter stored flat has no name or "
"capacity", "flat_named", "inverters")
for k in _ROSTER_FLAT:
if card.get(k) is not None or k in keep:
out[k] = card.get(k)
@@ -96,8 +111,9 @@ def _roster_encode(m, keep):
for c in cards]
# The flat keys beside a list are not read: they stay as stored.
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
if shape == "flat":
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat":
# CFG-04 rule 9e: a flat power request entity beside a list is refused,
# so a list that replaces the flat inverter clears it.
out["setpoint_entity"] = ""
return out
@@ -210,7 +226,8 @@ def _tariff_encode(m, keep):
contract = m.get("ui.contract")
if contract == "dynamic":
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
raise DeriveError("a dynamic contract needs a price source")
raise DeriveError("a dynamic contract needs a price source",
"no_source", "price_provider")
out["price_provider"] = m["price_provider"]
kind = m.get("ui.fallback")
else:
@@ -226,17 +243,19 @@ def _tariff_encode(m, keep):
elif kind == "custom":
if "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab")
"Configuration tab", "custom_profile",
"ui.contract")
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}")
raise DeriveError(f"unknown contract {kind!r}", "unknown",
"ui.contract")
return out
def _price_of(m, name):
value = _num(m.get(name))
if value is None:
raise DeriveError(f"{name} needs a price")
raise DeriveError(f"{name} needs a price", "no_price", name)
return value
@@ -259,7 +278,7 @@ def _region_encode(m, keep):
out["price_taxes_profile"] = keep["price_taxes_profile"]
return out
if region not in REGION_PRESETS:
raise DeriveError(f"unknown region {region!r}")
raise DeriveError(f"unknown region {region!r}", "unknown", "ui.region")
p = REGION_PRESETS[region]
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
if p["taxes_profile"] or "price_taxes_profile" in keep:
+357
View File
@@ -0,0 +1,357 @@
"""UX-13 P2: the settings view's server half. It names no option key.
It turns app/uilayout.py's table into the JSON the page draws from (labels
from the Supervisor's translations, ranges and choices from its schema, page
words from app/ui_text), turns a posted page model into options through
app/uimodel.py, and says beside which page field each validator sentence
goes. The rules stay in validate.py and the sentences are passed on as they
are (DECISIONS/UX-13-one-layout-table.md).
"""
import copy
import io
import json
import os
import re
from . import uilayout as L
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
# A choice the page cannot make, only keep: odd tariff windows (P5 editor).
ONLY_IF_HELD = ("custom",)
_TIERS = ("bad", "warn", "info")
def ui_text() -> dict:
with io.open(UI_TEXT, encoding="utf-8") as f:
return json.load(f)
def _roster():
"""The inverter list field's key, read off the table."""
return next(f.key for _g, _p, f in L.fields()
if f.control == L.INVERTER_LIST)
def _node(schema_ui, key):
"""The Supervisor's schema entry for `key` (`list.sub` too), or {}."""
nodes, path = schema_ui or [], key.split(".")
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
return next((n for n in nodes if n.get("name") == path[-1]), None) or {}
def _words(translations, lang, key, T):
"""(label, help) for a field: ui_text for `ui.` fields, else the
translation without its `Group · ` prefix, and the raw key last."""
if key.startswith("ui."):
entry = T["fields"].get(key[3:], {})
return entry.get("name", key), entry.get("description", "")
path = tuple(key.split(".")[:-1])
name = key.split(".")[-1]
label, _lang = label_of(translations, lang, name, path)
text, _lang = help_of(translations, lang, name, path)
return split_group(label)[1], text
def _choices(fld, node, T, value_type):
"""[[value, label, only_if_held], ...] for a choice control, or None."""
if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE):
return None
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
words = T["choices"].get(name) or {}
if fld.key.startswith("ui."):
values = list(words)
elif node.get("options"):
values = [v for v in node["options"] if not words or v in words]
elif value_type == "integer" and node.get("lengthMax") is not None:
values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1))
if words:
values = [v for v in values if str(v) in words]
else:
return []
out = []
for v in values:
label = words.get(str(v))
if label is None:
label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}")
if isinstance(v, int) else str(v))
preset = U.REGION_PRESETS.get(v) if fld.fills else None
if preset:
label = f"{label}, " + T["settings"]["region_year"].replace(
"{year}", str(preset["year"]))
out.append([v, label, v in ONLY_IF_HELD])
return out
def _field(fld, schema_ui, translations, lang, T):
node = _node(schema_ui, fld.key)
label, text = _words(translations, lang, fld.key, T)
if fld.unit:
# The page shows the unit beside the control, in its own scale (kW),
# so the translation's "(W)" goes; "(optional)" stays.
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
else node.get("type") or "")
out = {"key": fld.key, "control": fld.control, "label": label,
"help": text, "unit": fld.unit, "scale": fld.scale,
"step": fld.step, "kind": fld.kind,
"show": [list(c) for c in fld.show], "applies": fld.applies,
"wizard": fld.wizard,
"type": value_type, "optional": not node.get("required", False),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()
if how in (fld.key, fld.mirror))}
if node.get("lengthMin") is not None:
out["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
out["max"] = node["lengthMax"]
choices = _choices(fld, node, T, value_type)
if choices is not None:
out["choices"] = choices
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
later = T.get("choices_later", {}).get(name)
if later:
out["later"] = [[v, words] for v, words in later.items()]
hints = T.get("choice_help", {}).get(name)
if hints:
out["hints"] = {v: e.get("description", "")
for v, e in hints.items()}
if fld.mirror:
out["mirror"] = fld.mirror
if fld.fills:
out["fills"] = fld.fills
out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]}
for rid, p in U.REGION_PRESETS.items()}
roster = _roster()
if fld.key.startswith(roster + "."):
sub = fld.key[len(roster) + 1:]
if L.DERIVED.get(sub) == roster:
out["flat"] = sub # how the one flat inverter stores it
if fld.control == L.INVERTER_LIST:
out["fields"] = [_field(sub, schema_ui, translations, lang, T)
for sub in L.ROSTER_FIELDS]
out["count"] = U.COUNT
out["grow"] = dict(U.GROWN)
out["shape"] = next(iter(U.GROWN))
return out
def layout(schema_ui, translations, lang="en") -> dict:
"""Every group, part and field of the Installer view, as the page draws
it. Built from the table only: a field the table places is on the page."""
T = ui_text()
groups = []
for g in L.GROUPS:
parts = []
for p in g.parts:
words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {}
parts.append({"id": p.id, "name": words.get("name", ""),
"description": words.get("description", ""),
"collapsed": p.collapsed,
"fields": [_field(f, schema_ui, translations, lang, T)
for f in p.fields]})
words = T["groups"].get(g.id, {})
applies = sorted({f.applies for p in g.parts for f in p.fields})
groups.append({"id": g.id, "name": words.get("name", g.id),
"description": words.get("description", ""),
"support_only": g.support_only,
"applies": [T["applies"][a]["name"] for a in applies],
"parts": parts})
return {"groups": groups}
# --------------------------------------------------------------------------- #
# The page model -> options, for the validator and the save
# --------------------------------------------------------------------------- #
def _same(a, b) -> bool:
"""Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not."""
if isinstance(a, bool) or isinstance(b, bool):
return type(a) is type(b) and a == b
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a == b
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
return type(a) is type(b) and a == b
def to_options(model: dict, stored: dict | None = None) -> dict:
"""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
nothing writes an identical blob (the browser reads 3.0 as 3)."""
out = U.ui_to_options(model)
stored = stored or {}
for k, v in out.items():
if k in stored and _same(v, stored[k]):
out[k] = copy.deepcopy(stored[k])
return ({k: out[k] for k in stored if k in out}
| {k: v for k, v in out.items() if k not in stored})
def derive_message(err: U.DeriveError) -> dict:
words = ui_text()["settings"]["derive"]
text = words.get(err.code, words["unknown"])["description"]
return {"field": err.field or None, "card": None, "tier": "bad",
"text": text}
# --------------------------------------------------------------------------- #
# Where a sentence goes on the page
# --------------------------------------------------------------------------- #
# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card.
_SUB = re.compile(r"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
def _mirror_of(key):
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
def targets(key: str, text: str, model: dict) -> list:
"""[(page field, card)] for a sentence that names option `key`. `card` is
the inverter card's index, or None."""
roster = _roster()
cards = model.get(roster) if isinstance(model.get(roster), list) else []
names = [c.get("name") for c in cards if isinstance(c, dict)]
# messages.setting(): “Inverter rating” for inverter-b (inverters.max_w),
# or a roster rule's own `inverters[1] (inverter-b)`.
card = next((i for i, n in enumerate(names)
if n and f"for {n} ({roster}." in text), None)
index = re.search(re.escape(roster) + r"\[(\d+)\]", text)
if card is None and index and int(index.group(1)) < len(cards):
card = int(index.group(1))
if key == roster:
subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card)
for r, i, s in _SUB.findall(text) if r == roster]
subs = [x for x in dict.fromkeys(subs) if L.field(x[0])]
return subs or [(roster, card)]
if key in L.DERIVED:
how = L.DERIVED[key]
if how == roster:
return [(f"{roster}.{key}", 0 if card is None else card)]
fld = L.field(how)
if fld is not None and not L.visible(fld, model) and _mirror_of(how):
return [(_mirror_of(how), None)]
return [(how, None)]
if L.field(key) is not None:
return [(key, None)]
return [(None, None)]
def anchors(problems, notes, model, keys) -> list:
"""[{field, card, tier, text}] for the refusals and notes. `field` is
None for a sentence that names no setting on the page."""
out, seen = [], set()
def add(field, card, tier, text):
if (field, card, text) not in seen:
seen.add((field, card, text))
out.append({"field": field, "card": card, "tier": tier,
"text": text})
by_key, unanchored = problems_by_field(problems, keys)
for key, texts in by_key.items():
for text in texts:
for field, card in targets(key, text, model):
add(field, card, "bad", text)
for text in unanchored:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
add(field, card, tier, note.text)
# A sentence placed on one inverter's field is not also put on the list.
roster = _roster()
placed = {m["text"] for m in out
if (m["field"] or "").startswith(roster + ".")}
return [m for m in out if not (m["field"] == roster
and m["text"] in placed)]
def fields_of(keys, model) -> list:
"""The page fields that show these option keys (a stale save's list)."""
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
if f))
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
# --------------------------------------------------------------------------- #
# Store keys (app/store.py), not options. SETUP_DONE is written when the
# wizard saves. STARTED is the one control's Start and Stop write.
SETUP_DONE = "setup_done"
STARTED = "auto"
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
"""Why the page opens the wizard by itself, or None.
"no_power_request": the stored options name no inverter to control, so
the add-on cannot start on them. "first_start": a new install. This boot
found no Store file (`fresh`), the wizard never saved and control was never
started or stopped. `fresh` is what tells a new install from an old site
that was never started, e.g. one left in shadow for weeks (TEST-23).
`store_data` is the Store's dict (None: unknown, so no), `started` is
whether control is started now."""
if not power_request_entities(stored or {}):
return "no_power_request"
if store_data is None or not fresh:
return None
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
return None
return "first_start"
def setup_spec() -> dict:
"""What the wizard needs beyond each field's step: read off the table."""
kettle = {}
for source, (read, outcomes) in L.KETTLE.items():
kettle[source] = {"read": [[f, sign] for f, sign in read]}
for way, (say, sets) in outcomes.items():
kettle[source][way] = {"say": say, "sets": sets}
field, on, off = L.SHADOW
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
"optional": list(L.OPTIONAL_STEPS),
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
"sources": kettle},
"shadow": {"field": field, "on": on, "off": off}}
def kettle(source, before_w, after_w):
"""The kettle test's verdict, as the page reaches it (settings.js
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
for a source the page cannot read."""
spec = L.KETTLE.get(source)
if spec is None:
return None
change = float(after_w) - float(before_w)
if abs(change) < L.KETTLE_MIN_W:
return "none", None, change
say, sets = spec[1]["up" if change > 0 else "down"]
return say, (dict(sets) if sets is not None else None), change
def step_of(field_key, model) -> int:
"""The wizard step a sentence on this page field blocks: the field's own
step while it is shown, else the last step, which lists everything."""
fld = L.field(field_key) if field_key else None
if fld is None or not fld.wizard or not L.visible(fld, model):
return L.FINAL_STEP
return fld.wizard
def step_problems(messages, model) -> dict:
"""{"1": n, ...}: how many refusals block each wizard step."""
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
for m in messages:
if m.get("tier") == "bad":
out[str(step_of(m.get("field"), model))] += 1
return out
+253 -13
View File
@@ -374,7 +374,8 @@ class Note(NamedTuple):
"""UX-13: a warning or info for the settings page. Never a refusal: the
add-on starts. `keys` are the options it is about, so the page can show it
beside them."""
tier: str # "warn" or "info"
tier: str # "warn" or "info"; the wizard's checks
# also "ok" and "bad" (setup_checks)
keys: tuple
text: str
@@ -1749,18 +1750,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
"""
notes: list[Note] = []
if entities is not None:
for key, where, entity in _entities_read(opts):
row = entities.get(entity)
if row is None:
_advise(notes, "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
_entity_notes(notes, opts, entities)
cap = _number(opts, "peak_cap_w", None)
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
@@ -1769,6 +1759,256 @@ def config_notes(opts: dict, *, entities: dict | None = None,
return notes
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
"""A note for each entity these options read that Home Assistant does not
have, or that reads in kW where watts are taken. A missing entity whose
key is in `bad` is a "bad" note, which blocks the setup wizard."""
for key, where, entity in _entities_read(opts):
row = entities.get(entity)
if row is None:
_advise(notes, "bad" if key in bad else "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard's last step
# --------------------------------------------------------------------------- #
# Below this a battery or solar reading counts as idle for a sign check.
IDLE_W = 50.0
# Meter + solar + battery is the house's use, never below zero. Below minus
# this, read seconds apart, a direction is reversed (a battery giving power).
BALANCE_W = 300.0
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
# MQTT) is only known live when the running options read the same one.
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
def power_request_entities(opts: dict) -> list:
"""The power request entity of each inverter these options name: the flat
one, or each entry's. Blank ones are left out."""
entries = roster_entries(opts)
if not entries:
values = [opts.get("setpoint_entity")]
else:
values = [e.get("setpoint_entity") for e in entries
if isinstance(e, dict)]
return [str(v).strip() for v in values
if isinstance(v, str) and v.strip()]
def _reading(entities, entity, convert=False):
"""(number, unit) of an entity's state, or (None, state text). The number
is as the add-on reads it: raw for the meter and battery power, which it
takes as watts, and in W for solar (`convert`), which pv.py converts."""
row = (entities or {}).get(entity) or {}
unit = str(row.get("unit") or "")
try:
value = float(row.get("state"))
except (TypeError, ValueError):
return None, str(row.get("state") or "nothing")
if value != value or value in (float("inf"), float("-inf")):
return None, str(row.get("state"))
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
def _signed(w) -> str:
return ("-" if w < 0 else "") + messages.watts(w)
def _cards(opts: dict) -> list:
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
entries = roster_entries(opts)
if not entries:
return [("", {k: opts.get(k) for k in (
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
out = []
for i, entry in enumerate(entries):
if isinstance(entry, dict):
name = _unit_label(opts, i).strip().strip("()")
out.append((f" for {name}" if name else "", entry))
return out
def setup_checks(opts: dict, *, entities: dict | None, running=None,
meter_now_w=None, prices_known=None) -> list[Note]:
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
Live checks on the proposed options: the meter reads, each power request
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
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
request entity, which the wizard will not save. Reads nothing itself:
`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
reading and `prices_known` how many of today's hours have a price.
"""
notes: list[Note] = []
if entities is None:
# Red: only the list can tell a missing power request entity (SAFETY-77).
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
"answering, so press Check again once it is back.")
else:
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
meter = _meter_check(notes, opts, entities, running, meter_now_w)
batts = [_card_checks(notes, who, entry, entities)
for who, entry in _cards(opts)]
solar = []
if entities is not None:
flipped = pv.inverted(opts)
for entity in pv.entities(opts):
w, _unit = _reading(entities, entity, convert=True)
if entity not in entities or w is None:
solar.append(None)
continue
if w < -IDLE_W and entity not in flipped:
solar.append(None)
_advise(notes, "warn", ("pv_invert_entities",),
f"{entity} reads below zero now, so turn on its "
"“Reads negative” switch in the Solar step.")
else:
w = -w if entity in flipped else w
solar.append(w)
_advise(notes, "ok", ("pv_entity",),
f"{entity} reads {_signed(w)} of solar production "
"now.")
# A battery sensor that reads the wrong way while giving power makes the
# house use less than nothing. Only with every term known: a missing
# solar reading could be the difference.
known = [meter] + batts + solar
if solar and batts and all(w is not None for w in known) \
and sum(known) < -BALANCE_W:
_advise(notes, "warn", ("batt_invert",),
f"The readings add up to {_signed(sum(known))} of house "
"use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
_price_check(notes, opts, running, prices_known)
return notes
def _meter_check(notes, opts, entities, running, meter_now_w):
"""The meter line. Returns the grid reading in W (positive: taken from
the grid) as the add-on will read it, or None when it is not known."""
source = str(opts.get("meter_source") or "off").strip()
if source == "off":
parts = [(opts.get("meter_entity"), 1.0)]
elif source == SOURCE_HA_SIGNED:
parts = [(opts.get("p1_net_entity"), 1.0)]
elif source == SOURCE_HA:
parts = [(opts.get("p1_import_entity"), 1.0),
(opts.get("p1_export_entity"), -1.0)]
else:
same = isinstance(running, dict) and all(
running.get(k) == opts.get(k) for k in _METER_KEYS)
if same and meter_now_w is not None:
_meter_line(notes, float(meter_now_w))
return float(meter_now_w)
_advise(notes, "info", ("meter_source",), "The add-on reads "
"this meter itself, so Home shows its reading after "
"the restart.")
return None
if entities is None:
return None
total = 0.0
for entity, sign in parts:
entity = str(entity or "").strip()
if not entity or entity not in entities:
return None # a refusal or a missing-entity note says so
w, text = _reading(entities, entity)
if w is None:
_advise(notes, "warn", ("meter_source",),
f"{entity} reads {text} now, so check it in Home "
"Assistant before you start.")
return None
total += sign * w
if source == "off" and opts.get("meter_invert"):
total = -total
_meter_line(notes, total)
return total
def _meter_line(notes, w) -> None:
if abs(w) < 1.0:
text = "The meter reads 0 W now."
elif w > 0:
text = f"The meter reads {messages.watts(w)} taken from the grid now."
else:
text = f"The meter reads {messages.watts(w)} fed into the grid now."
_advise(notes, "ok", ("meter_source",), text)
def _card_checks(notes, who, entry, entities):
"""The checks for one inverter's card. Returns its battery power in W,
positive while giving power, or None when it is not known."""
if entities is None:
return None
setpoint = str(entry.get("setpoint_entity") or "").strip()
if setpoint and setpoint in entities:
_advise(notes, "ok", ("setpoint_entity",),
f"Home Assistant has {setpoint}, the power request "
f"entity{who}.")
soc = str(entry.get("soc_entity") or "").strip()
if not soc:
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
f"level entity{who}, so pick one in step 2 or the add-on "
"holds 0 W.")
elif soc in entities:
level, _unit = _reading(entities, soc)
if level is None or not 0.0 <= level <= 100.0:
_advise(notes, "warn", ("soc_entity",), f"The battery "
f"level{who} reads nothing now, so check {soc}.")
else:
_advise(notes, "ok", ("soc_entity",),
f"The battery level{who} reads {level:.0f} %.")
batt = str(entry.get("batt_entity") or "").strip()
if batt and batt in entities:
w, _unit = _reading(entities, batt)
if w is None:
_advise(notes, "warn", ("batt_entity",), f"The battery "
f"power{who} reads nothing now, so check {batt}.")
return None
w = -w if entry.get("batt_invert") else w
if abs(w) < IDLE_W:
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
"idle now, so check its direction on Home later.")
else:
doing = "giving power" if w > 0 else "charging"
_advise(notes, "info", ("batt_invert",),
f"The battery{who} reads {messages.watts(w)} {doing} "
"now, so check the inverter shows the same.")
return w
return None
def _price_check(notes, opts, running, prices_known) -> None:
provider = str(opts.get("price_provider") or "off").strip()
profile = str(opts.get("static_profile") or "").strip()
if provider in ("", "off"):
if profile:
_advise(notes, "ok", ("static_profile",),
"Your prices come from your contract, so they are "
"always known.")
else:
_advise(notes, "info", ("price_provider",),
"No prices are set, so the batteries do not charge "
"on price.")
return
same = (isinstance(running, dict)
and str(running.get("price_provider") or "") == provider)
if same and prices_known:
_advise(notes, "ok", ("price_provider",),
f"Prices for today are in, for {prices_known} of 24 hours.")
else:
_advise(notes, "info", ("price_provider",),
"Prices are fetched after the restart, and Home shows them.")
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+127 -16
View File
@@ -28,21 +28,26 @@ do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
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
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).
"""
import io
import json
import logging
import os
from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig
from . import messages, selfconfig, uipage
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
@@ -65,13 +70,16 @@ def page() -> str:
"""The assembled index page. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
script = _read(os.path.join(STATIC, "app.js"))
if "</script" in script.lower():
raise ValueError("app.js may not contain a closing script tag")
scripts = {}
for name in ("app.js", "settings.js"):
scripts[name] = _read(os.path.join(STATIC, name))
if "</script" in scripts[name].lower():
raise ValueError(f"{name} may not contain a closing script tag")
return (_read(os.path.join(STATIC, "index.html"))
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
.replace("/*APP_JS*/", script))
.replace("/*APP_JS*/", scripts["app.js"])
.replace("/*SETTINGS_JS*/", scripts["settings.js"]))
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
@@ -99,6 +107,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
controller.hass.token)
entities = EntityCache(controller.hass)
index_page = page()
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
# save can name only the keys that moved underneath the page (finding a).
handed: dict = {}
def hand_out(options):
fp = selfconfig.fingerprint(options)
handed.pop(fp, None)
handed[fp] = options
while len(handed) > 16:
handed.pop(next(iter(handed)))
return fp
def store_data():
store = getattr(controller, "store", None)
data = getattr(store, "data", None)
return data if isinstance(data, dict) else None
async def index(_request):
return web.Response(text=index_page, content_type="text/html")
@@ -121,15 +145,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def config_index(_request):
return web.Response(text=CONFIG_PAGE, content_type="text/html")
async def _proposed(request):
async def _proposed(request, stored=None):
"""(body, options, model). UX-13's settings view posts its page
`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
page posts `options`. Raises DeriveError for a model no blob can say."""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = body.get("model")
if isinstance(model, dict):
return body, uipage.to_options(model, stored), model
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
return body, options
return body, options, None
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
@@ -154,13 +185,23 @@ def build_app(controller, selfcfg=None) -> web.Application:
info.get("translations") or {}, lang)
model.update({
"options": options,
"fingerprint": selfconfig.fingerprint(options),
"fingerprint": hand_out(options),
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
# screens, which show the year beside the region choice.
"region": region_of(options),
# UX-13 P2: the Installer view draws from these two.
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
options, store_data(), bool(getattr(controller, "auto", False)),
fresh=getattr(getattr(controller, "store", None), "fresh",
False) is True),
})
return web.json_response(model)
@@ -177,7 +218,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
product this card is delivering; anything this layer did to them would
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
try:
_body, options, model = await _proposed(request)
except DeriveError as err:
return web.json_response({"problems": [], "by_key": {},
"unanchored": [], "notes": [],
"messages": [uipage.derive_message(err)]})
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -190,8 +236,30 @@ def build_app(controller, selfcfg=None) -> web.Application:
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
if model is not None:
payload["messages"] = uipage.anchors(problems, notes, model,
_keys_of(options))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
return web.json_response(payload)
async def _setup_checks(options):
"""The wizard's live checks: a list at most a few seconds old, and
what the running add-on already knows (its meter, today's prices)."""
rows, age = await entities.get(max_age=5.0)
if age is not None and age >= 30.0:
rows = None # Home Assistant is not answering
try:
prices = (controller.status_payload().get("plan") or {}).get(
"prices") or []
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
except Exception: # noqa: BLE001 - a display figure, never a failure
known = None
return setup_checks(options, entities=rows, running=controller.o,
meter_now_w=getattr(controller, "grid", None),
prices_known=known)
def _keys_of(options):
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
# sound only because the page posts the WHOLE blob it read - which is
@@ -202,34 +270,73 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def config_save(request):
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
body, options = await _proposed(request)
# The page model carries no stored values of the keys it shows, so the
# blob is settled against what is stored: an unchanged value is written
# exactly as stored (uipage.to_options).
try:
posted = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
stored_now = None
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
try:
stored_now = (await selfcfg.info()).get("options") or {}
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
try:
body, options, model = await _proposed(request, stored_now)
except DeriveError as err:
return web.json_response(
{"kind": "invalid", "problems": [], "by_key": {},
"unanchored": [], "messages": [uipage.derive_message(err)]},
status=422)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
hass=controller.hass)
hass=controller.hass,
base=handed.get(expect))
except selfconfig.SaveRefused as refused:
if refused.kind == "stale":
stored = refused.stored or {}
# The fields to look at are the ones the OTHER surface moved,
# not this page's own edits (UX-13 P2 test, finding a).
moved = (refused.changed if refused.moved is None
else refused.moved)
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"stored": stored,
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
# made in front of what changed.
"fingerprint": selfconfig.fingerprint(stored)},
"fingerprint": hand_out(stored)},
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
return web.json_response(
{"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}, status=422)
payload = {"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}
if model is not None:
payload["messages"] = uipage.anchors(refused.problems, [],
model, _keys_of(options))
payload["steps"] = uipage.step_problems(payload["messages"],
model)
return web.json_response(payload, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
# `handed` keeps `expect`: another page may have opened the same blob.
store = getattr(controller, "store", None)
if (model is not None and body.get("setup") == "done"
and store is not None):
store.set(uipage.SETUP_DONE,
datetime.now(timezone.utc).isoformat(timespec="seconds"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -261,6 +368,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""UX-13: Home Assistant's entities for the picker. A read only, and
never an error: when Home Assistant is down the list says so."""
kind = str(request.query.get("kind", "") or "")[:16]
one = str(request.query.get("entity", "") or "")[:255]
if one:
# UX-13 P4's kettle test: one entity, read now.
return web.json_response(await entities.reading(one))
return web.json_response(await entities.payload(kind))
app = web.Application()
+21
View File
@@ -10,6 +10,7 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
import asyncio
import json
import logging
import sys
import time
@@ -96,9 +97,29 @@ for name, handler in (("502 while HA restarts", bad_gateway),
("a body that is not JSON", garbage),
("JSON that is not a list", not_a_list)):
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
class _Lines(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
if record.levelno >= logging.WARNING:
self.lines.append(record.getMessage())
_lines = _Lines()
logging.getLogger().addHandler(_lines)
check("an answer slower than the timeout -> None, at the timeout",
asyncio.run(read_with(slow, timeout_s=0.3)) is None
and read_with.took < 1.0)
# UX-13 P1 test finding (c): a timeout's own text is empty, which left
# "Reading the entity list failed: " with no reason.
check("...and its WARNING line gives the reason, not an empty one "
f"({_lines.lines})", any(
x == "Reading the entity list failed: no answer within 0.3 seconds."
for x in _lines.lines)
and not any(x.rstrip().endswith("failed:") for x in _lines.lines))
logging.getLogger().removeHandler(_lines)
check("no server at all -> None", asyncio.run(closed_port()) is None)
check("the timeout for the list is the same 10 s as every other read",
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
+64 -4
View File
@@ -123,11 +123,13 @@ MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
auto=True):
auto=True, wrote=0.0):
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt = soc[i], batt[i]
inv.last_write = wrote
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.grid = grid
return c
@@ -321,11 +323,11 @@ check("the price bars are today's 24 hours, from the real series, with the "
and s_arb["plan"]["prices"][19]["kind"] == "dear"
and s_arb["plan"]["prices"][0]["kind"] == "mid"
and s_arb["plan"]["prices"][13]["label"] == "13:00")
check("the price line names the price now, today's cheapest and dearest, and "
check("the price line names the price now, today's lowest and highest, and "
"that tomorrow's prices are in",
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
"Today: cheapest €0.14 at 13:00, "
"dearest €0.36 at 19:00.")
"Today: lowest €0.14 at 13:00, "
"highest €0.36 at 19:00.")
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
c_late = make(ARB)
@@ -454,6 +456,64 @@ check("...and every red, amber and settling row reaches Needs attention with "
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
# UX-13 P1 test finding (a): the page said "Held at 0 W" about 15 s before
# the inverter's power request reached 0 W. Until the last accepted request
# is 0 W it says "Going to 0 W".
c_al.inverters[0].last_write = 20.0
s_go = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
c_al.inverters[0].last_write = 0.0
s_at = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
check("an alarm while the last power request is still 20 W says 'Going to "
"0 W', and 'Held at 0 W' once 0 W was accepted",
s_go["plan"]["verdict"] == "Going to 0 W"
and s_go["batteries"][0]["now"] == "Going to 0 W until the alarm clears."
and s_go["plan"]["head"].startswith("Every inverter goes to 0 W")
and s_at["plan"]["verdict"] == "Held at 0 W"
and s_at["batteries"][0]["now"] == "Held at 0 W until the alarm clears.")
c_sp = make(TWO, auto=False)
c_sp.inverters[1].last_write = -1500.0
s_sp = status(c_sp)
s_new = status(make(TWO, auto=False, wrote=None))
check("...and before any power request was accepted (just started, or every "
"write refused) it says 'Going to 0 W', not 'Held'",
[b["now"] for b in s_new["batteries"]]
== ["Going to 0 W while control is stopped."] * 2)
check("...and a stop does the same, per battery",
[b["now"] for b in s_sp["batteries"]]
== ["Held at 0 W while control is stopped.",
"Going to 0 W while control is stopped."])
# Finding (b): a quarter-hourly feed. The block's price and the price line
# name the same 15-minute point, and nothing says "the list".
_q = []
for _k, _p in enumerate(MOCK):
for _m in range(4):
_q.append(PricePoint(DAY0 + timedelta(hours=_k, minutes=15 * _m),
round(_p + (0.0 if _m != 3 else -0.01), 4), False))
_q += [PricePoint(p.start + timedelta(days=1), p.eur_per_kwh, False)
for p in list(_q)]
c_q = make(ARB)
c_q.tariff_plan = TariffPlan(PriceRead.series(tuple(_q), PriceProduct.DAY_AHEAD,
"generic_dayahead", NOW), None,
"provider")
c_q.tariff_at = NOW
c_q.peak_guard = quiet_guard()
s_q = status(c_q)
_grid = [r for r in s_q["plan"]["runs"] if r["kind"] == "grid"]
_lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
check("quarter-hour prices: the charge block, its price and the price line's "
"lowest are the same 15-minute period",
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
"per kWh."
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
and "the list" not in json.dumps(s_q["plan"]))
check("...and the hour bar says it is an hour average",
s_q["plan"]["prices"][13]["points"] == 4
and '"plan.average_tip"' in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read())
# --------------------------------------------------------------------------- #
print("\n4. a fault in the Home view never costs the page its rows")
# --------------------------------------------------------------------------- #
+3 -1
View File
@@ -883,6 +883,8 @@ DATA = {
"hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err",
"hass.py:obj", "hass.py:raw", "hass.py:domain", "hass.py:service",
"hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what",
# UX-13 P2: the entity list's wait, and an error's text or else its name
"hass.py:STATES_TIMEOUT_S", "hass.py:str(err) or type(err).__name__",
# main.py
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
@@ -918,7 +920,7 @@ DATA = {
"price_history.py:messages.count(len(points), 'price')",
"price_history.py:err",
"selfconfig.py:messages.count(len(problems), 'problem')",
"selfconfig.py:', '.join(changed) or 'none this page also edited'",
"selfconfig.py:', '.join(keys) or 'none this page also edited'",
"static_tariff.py:e", "store.py:err", "store.py:key", "store.py:raw",
"tariff.py:self.path", "tariff.py:err", "tariff.py:start", "tariff.py:month",
# validate.py: option values and keys (the refusals are part 3's)
+466
View File
@@ -0,0 +1,466 @@
"""UX-13 P2: the Installer settings view. `python3 test_settings.py`
The view is drawn from app/uilayout.py through app/uipage.py and saves the
page model through app/uimodel.py and the shipped validator. Part 1 checks
that every group and every mapped field reaches the page with its words,
range and choices. Part 2 checks the page script names no option key. Parts
3-5 drive the real routes over HTTP against a fake Supervisor: the save round
trip, where a refusal lands, and a stale save. Part 6 holds the view's words
to STYLE. The headless-browser click-through is in the PR, not in CI (the CI
image has no browser).
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import copy
import glob
import io
import json
import logging
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING) # the validator's own log lines
from aiohttp import ClientSession, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
from app import selfconfig, uilayout as L, uimodel as U, uipage, web # noqa: E402
from app.validate import validate_startup_config # noqa: E402
from named_absence import field # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
DEFAULTS = D.defaults(D.CONFIG)
SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint",
soc_entity="sensor.soc", batt_entity="sensor.batt")
TWO = dict(SITE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": 2500,
"bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 2500,
"bms_soc_floor": 10}])
UI_TEXT = uipage.ui_text()
W = UI_TEXT["settings"]
def browser(value):
"""What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3."""
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, list):
return [browser(v) for v in value]
if isinstance(value, dict):
return {k: browser(v) for k, v in value.items()}
return value
# --------------------------------------------------------------------------- #
print("1. every group and every mapped field reaches the page")
# --------------------------------------------------------------------------- #
LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS)
_flat = []
for _g in LAYOUT["groups"]:
for _p in _g["parts"]:
for _f in _p["fields"]:
_flat.append((_g["id"], _f))
_flat += [(_g["id"], _s) for _s in _f.get("fields", [])]
_keys = [f["key"] for _g, f in _flat]
FIELD = {f["key"]: f for _g, f in _flat}
check("the six groups, in the design's order",
[g["id"] for g in LAYOUT["groups"]]
== ["meter", "inverters", "tariff", "safety", "maintenance", "tuning"]
and [g["name"] for g in LAYOUT["groups"]]
== ["Meter", "Inverters and batteries", "Tariff and prices",
"Safety limits", "Maintenance", "Control tuning"])
check(f"every field of the table is on the page once ({len(_keys)} fields)",
sorted(_keys) == sorted(f.key for _g, _p, f in L.fields())
and len(_keys) == len(set(_keys)))
_counts = {g["id"]: sum(1 for gid, _f in _flat if gid == g["id"])
for g in LAYOUT["groups"]}
print(f" fields per group: {_counts}")
check("every field has a label, and every option field's label is its "
"translation without the group prefix",
all(f["label"] and " · " not in f["label"] for _g, f in _flat)
and field(FIELD, "meter_source")["label"]
== "Where the meter reading comes from")
_ranged = [(f["key"], f.get("min"), f.get("max")) for _g, f in _flat
if f["control"] in (L.SLIDER, L.LOCKED)]
check(f"every slider and locked value has its range from the schema "
f"({len(_ranged)})", all(lo is not None and hi is not None
for _k, lo, hi in _ranged)
and ("peak_cap_w", 500.0, 15000.0) in _ranged)
check("the peak limit is a kW slider over a value stored in W, and its label "
"does not say W (the unit is shown beside the control)",
field(FIELD, "peak_cap_w")["scale"] == 1000
and field(FIELD, "peak_cap_w")["label"] == "Peak limit"
and not [f["key"] for _g, f in _flat
if f["unit"] and re.search(r"\([^()\s]{1,6}\)", f["label"])])
_choice = [f for _g, f in _flat
if f["control"] in (L.SEGMENTED, L.SELECT, L.CHOICE)]
check("every choice control has choices with words",
all(f["choices"] and all(c[1] for c in f["choices"]) for f in _choice))
_ms = field(FIELD, "meter_source")
check("the meter source is radio cards with a line of help on each",
_ms["control"] == L.CHOICE and set(_ms["hints"]) == {c[0] for c in
_ms["choices"]})
_reg = field(FIELD, "ui.region")
check("the region shows its preset year beside the choice (P0 review)",
["flanders", "Flanders, Fluvius Antwerpen, 2026 figures", False]
in _reg["choices"] and _reg["presets"]["flanders"]["year"] == 2026)
check("...and says plainly that Wallonia and Brussels have no preset yet "
"(TARIFF-22)", _reg["later"] == [["wallonia", "Wallonia, no preset yet"],
["brussels", "Brussels, no preset yet"]]
and "Wallonia or Brussels" in _reg["help"])
_locked = sorted(f["key"] for _g, f in _flat if f["control"] == L.LOCKED)
check(f"measured and derived values are locked behind 'Change' "
f"({len(_locked)}): every control tuning value, every safety stop and "
"the peak reserve",
{f.key for p in L.GROUPS[-1].parts for f in p.fields} <= set(_locked)
and "sign_disagree_w" in _locked and "peak_soc_floor" in _locked
and LAYOUT["groups"][-1]["support_only"])
check("each group says when its settings apply",
all(g["applies"] == [UI_TEXT["applies"]["restart"]["name"]]
for g in LAYOUT["groups"]))
_inv = field(FIELD, "inverters")
check("the inverter cards carry their count, and adding one makes a list",
_inv["count"] == U.COUNT and _inv["grow"] == U.GROWN
and [s["key"] for s in _inv["fields"]]
== [f.key for f in L.ROSTER_FIELDS])
check("a page-only field names the option keys it writes, for the grey key "
"line", _reg["writes"] == ["price_taxes_profile"]
and field(FIELD, "ui.solar")["writes"]
== ["pv_entities", "pv_entity", "pv_invert_entities"]
and "static_profile" in field(FIELD, "ui.contract")["writes"])
check("without a Supervisor schema the page still draws, with no ranges",
len(uipage.layout([], {})["groups"]) == 6)
# --------------------------------------------------------------------------- #
print("\n2. the page script names no option key")
# --------------------------------------------------------------------------- #
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
ORDER, _nested = TL.config_surface(D.CONFIG)
_leak = sorted(k for k in ORDER if re.search(
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
check(f"settings.js names no option key ({_leak})", not _leak)
check("data is set as text, never as HTML",
"innerHTML" not in JS and "insertAdjacentHTML" not in JS)
_fetch = sorted(set(re.findall(r'fetch\("([^"?]+)', JS)))
check(f"every request is relative, to the add-on's own routes ({_fetch})",
_fetch == ["config/model", "config/restart", "config/save",
"config/validate", "ha/entities"])
def _resolve(path, node=W):
for k in path.split("."):
if not isinstance(node, dict) or k not in node:
return None
node = node[k]
return node
_used = {re.sub(r"\.(replace|join|length)$", "", k) for k in
re.findall(r'\bW\.([a-z_]+(?:\.[a-z_]+)*)', JS)}
_used |= set(re.findall(r'data-s(?:-ph|-label)?="([\w.]+)"', io.open(
os.path.join(HERE, "app", "static", "index.html"), encoding="utf-8").read()))
_miss = sorted(k for k in _used if _resolve(k) is None)
check(f"every word the view asks for is in app/ui_text ({len(_used)} keys, "
f"missing: {_miss})", len(_used) > 25 and not _miss)
check("search matches a field's label, its key and its help",
"(f.label + \" \" + keys + \" \" + f.key + \" \" + (f.help || \"\"))"
in JS)
check("a locked value shows no input until 'Change' is pressed",
re.search(r"function locked\(.*?\n }\n", JS, re.S)
and "UNLOCKED.has(id)) { open(); return box; }" in JS)
check("nothing is saved while the add-on's own checks refuse the model",
"if (MSG.some((m) => m.tier === \"bad\")) { toast(" in JS)
check("Discard reloads the stored settings (after a stale save the page's "
"copy is no longer what is stored)",
re.search(r'"#sb-discard"\)\.addEventListener\("click", async \(\) => '
r'\{\s*await load\(\);', JS) is not None)
check("a load locks every locked value again, and a choice the page can only "
"keep stays offered while it is the stored value",
"UNLOCKED.clear();" in JS.split("async function load")[1].split("render();")[0]
and "same(c[0], was)" in JS)
check("removing cards back to one restores the stored shape (a flat inverter "
"is not turned into a list of one)",
"if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);"
in JS)
check("the old settings page stays reachable (until P5)",
'href="config" id="set-classic"' in web.page())
# --------------------------------------------------------------------------- #
# The real routes, over HTTP, against a fake Supervisor
# --------------------------------------------------------------------------- #
class Supervisor:
def __init__(self, options):
self.options = copy.deepcopy(options)
self.writes = []
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS}
async def write_options(self, options):
self.writes.append(copy.deepcopy(options))
self.options = copy.deepcopy(options)
async def restart(self, confirm):
raise AssertionError("no restart here")
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return []
class Controller:
def __init__(self, opts):
self.o, self.hass, self.inverters = dict(opts), Hass(), []
def log_event(self, text, hint=None):
pass
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": []}
async def handle_action(self, what):
raise AssertionError(f"the settings view reached {what!r}")
async def session(stored, steps):
"""Run `steps(s, root)` against the app over a fake Supervisor."""
sup = Supervisor(stored)
runner = aioweb.AppRunner(web.build_app(Controller(stored), sup))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
try:
async with ClientSession() as s:
out = await steps(s, root, sup)
finally:
await runner.cleanup()
return out, sup
async def _get(s, url):
async with s.get(url) as r:
return await r.json()
async def _post(s, url, body):
async with s.post(url, json=body) as r:
return r.status, await r.json()
# --------------------------------------------------------------------------- #
print("\n3. the save round trip")
# --------------------------------------------------------------------------- #
check("the fixture site starts (so a refusal below is the edit's)",
not validate_startup_config(TWO) and not validate_startup_config(SITE))
async def _round(s, root, sup):
out = {}
m = await _get(s, root + "config/model?lang=en")
out["model"] = m
model = browser(m["ui"])
out["same"] = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
out["after_same"] = copy.deepcopy(sup.options)
m = await _get(s, root + "config/model?lang=en")
model = browser(m["ui"])
model["peak_cap_w"] = 4200
model["inverters"][1]["max_w"] = 2200
out["edit"] = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
return out
_stored = dict(TWO, gain=0.5, target_grid_w=-10.0, peak_cap_w=3500)
_r, _sup = asyncio.run(session(_stored, _round))
check("config/model carries the layout and the page model",
_r["model"]["layout"] == json.loads(json.dumps(uipage.layout(
SCHEMA, TRANSLATIONS))) and _r["model"]["ui"]["inverter_count"] == 2)
check("a save that changed nothing writes an identical blob: same values, "
"types and key order, although the browser turned -10.0 into -10",
_r["same"][0] == 200 and json.dumps(_r["after_same"])
== json.dumps(_stored) and isinstance(_r["after_same"]["target_grid_w"],
float))
_written = _sup.writes[-1]
print(f" the edit: {_r['edit']}, changed "
f"{sorted(k for k in _stored if _written[k] != _stored[k])}")
check("an edit writes exactly the fields edited, the peak limit in W",
_r["edit"][0] == 200
and sorted(k for k in _stored if _written[k] != _stored[k])
== ["inverters", "peak_cap_w"] and _written["peak_cap_w"] == 4200
and _written["inverters"][1]["max_w"] == 2200
and _written["inverters"][0] == _stored["inverters"][0])
_blobs = [("config.yaml", DEFAULTS), ("site", SITE), ("two", TWO)] + [
(os.path.basename(f), json.load(open(f))) for f in sorted(glob.glob(
os.path.join(HERE, "scenarios", "ux13", "test23", "*.json")))]
_bad = [n for n, o in _blobs if json.dumps(uipage.to_options(browser(
json.loads(json.dumps(U.options_to_ui(o)))), o)) != json.dumps(o)]
check(f"every known blob comes back byte for byte through the browser "
f"({len(_blobs)} blobs, not: {_bad})", not _bad and len(_blobs) >= 8)
_grown = U.options_to_ui(SITE)
_grown["inverters"].append(dict(_grown["inverters"][0], name="b",
setpoint_entity="number.b"))
_grown["inverters"][0]["name"] = "a"
_grown.update(U.GROWN)
_g = U.ui_to_options(_grown)
check("'Add an inverter' on a flat site writes a list and clears the flat "
"power request entity, which the validator refuses beside a list",
[e["name"] for e in _g["inverters"]] == ["a", "b"]
and _g["setpoint_entity"] == "" and not validate_startup_config(_g))
# --------------------------------------------------------------------------- #
print("\n4. a refusal lands beside the field it is about")
# --------------------------------------------------------------------------- #
async def _refuse(s, root, sup):
out = {}
m = await _get(s, root + "config/model?lang=en")
base = m["ui"]
async def validate(**edits):
model = copy.deepcopy(base)
for k, v in edits.items():
if k == "card":
model["inverters"][v[0]][v[1]] = v[2]
else:
model[k.replace("__", ".")] = v
return (await _post(s, root + "config/validate", {"model": model}))[1]
out["card"] = await validate(card=(1, "setpoint_entity", ""))
out["same"] = await validate(card=(1, "setpoint_entity", "number.a"))
out["floor"] = await validate(peak_soc_floor=5)
out["derive"] = await validate(ui__contract="dynamic", price_provider="")
model = copy.deepcopy(base)
model.update({"ui.contract": "dynamic", "price_provider": ""})
out["derive_save"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"]})
out["derive_writes"] = len(sup.writes)
model = copy.deepcopy(base)
model["inverters"][1]["setpoint_entity"] = ""
out["save"] = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
return out
_r, _sup = asyncio.run(session(TWO, _refuse))
def _at(payload, tier="bad"):
return {(m["field"], m["card"]) for m in payload["messages"]
if m["tier"] == tier}
print(f" blank power request entity on card 2: {_r['card']['messages']}")
check("a blank power request entity on the second card is refused beside "
"that card's field, and not beside the list as well",
("inverters.setpoint_entity", 1) in _at(_r["card"])
and ("inverters", None) not in _at(_r["card"]))
print(f" two cards on one entity: {_r['same']['messages']}")
check("two cards on one entity are refused beside that field on both cards",
{("inverters.setpoint_entity", 0), ("inverters.setpoint_entity", 1)}
== _at(_r["same"]))
check("a peak reserve under the cut-out level lands on the peak reserve and "
"on each card's cut-out level",
{("peak_soc_floor", None), ("inverters.bms_soc_floor", 0),
("inverters.bms_soc_floor", 1)} <= _at(_r["floor"]))
check("a model no blob can say is refused beside its field, in plain words",
_r["derive"]["messages"] == [{
"field": "price_provider", "card": None, "tier": "bad",
"text": W["derive"]["no_source"]["description"]}])
check("a save no blob can say answers 422 with its sentence and writes nothing",
_r["derive_save"][0] == 422 and _r["derive_writes"] == 0
and _r["derive_save"][1]["messages"] == _r["derive"]["messages"])
check("a refused save answers 422 with the same placement and writes nothing",
_r["save"][0] == 422 and ("inverters.setpoint_entity", 1)
in _at(_r["save"][1]) and _sup.writes == [])
_fixed = U.options_to_ui(dict(TWO, static_profile="not json"))
check("a sentence about a key the page derives goes to the field that writes "
"it, or to the visible choice that sets it",
uipage.targets("static_profile", "", _fixed) == [("ui.contract", None)]
and uipage.targets("max_w", "", U.options_to_ui(SITE))
== [("inverters.max_w", 0)]
and uipage.targets("pv_entity", "", _fixed) == [("ui.solar", None)])
check("a sentence naming no setting on the page is kept, with no field",
uipage.anchors(["Something odd."], [], _fixed, ["gain"])
== [{"field": None, "card": None, "tier": "bad",
"text": "Something odd."}])
# --------------------------------------------------------------------------- #
print("\n5. a stale save names the fields that changed elsewhere")
# --------------------------------------------------------------------------- #
async def _stale(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
sup.options["peak_cap_w"] = 5000 # the Configuration tab saves
model = copy.deepcopy(m["ui"])
model["gain"] = 0.4
return await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
_r, _sup = asyncio.run(session(TWO, _stale))
check("409 with the page fields to look at, and nothing written. Only the "
"field the other surface changed, not the page's own edit (P4, P2 "
"test finding a)",
_r[0] == 409 and _r[1]["kind"] == "stale"
and _r[1]["changed_fields"] == ["peak_cap_w"] and _sup.writes == []
and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options))
# --------------------------------------------------------------------------- #
print("\n6. the view's words keep to STYLE")
# --------------------------------------------------------------------------- #
def _texts(node, path=""):
for k, v in node.items():
if isinstance(v, dict):
yield from _texts(v, f"{path}{k}.")
elif isinstance(v, str):
yield f"{path}{k}", v
_long = [(p, t) for p, t in _texts(W)
if len(t.split()) > (25 if p.endswith("description") else 6)
or (p.endswith("description") and len(re.findall(r"[.!?](?:\s|$)", t)) > 1)
or ";" in t]
check(f"every settings string is one sentence of at most 25 words, or a "
f"name of at most 6 ({_long})", not _long)
_labels = [c[1] for _g, f in _flat for c in f.get("choices", [])]
check("every choice, including the region with its year, is at most 6 words",
all(len(x.split()) <= 6 for x in _labels))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+764
View File
@@ -0,0 +1,764 @@
"""UX-13 P4: the setup wizard. `python3 test_setup.py`
The wizard is the Installer view's page model drawn one step at a time from
each field's `wizard` step (app/uilayout.py), saved through the same routes.
Part 1 checks the steps and the tables the page reads. Part 2 the kettle
test's sign logic. Part 3 which refusals block which step. Part 4 the
first-run rule on new and existing sites. Part 5 the last step's live checks.
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
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
words. The headless-browser click-through is in the PR.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import copy
import io
import json
import logging
import os
import re
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING) # the validator's own log lines
from aiohttp import ClientSession, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
import ux13_wizard_blobs as B # noqa: E402
from app import uilayout as L, uimodel as U, uipage, web # noqa: E402
from app.validate import setup_checks, validate_startup_config # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
DEFAULTS = D.defaults(D.CONFIG)
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
UI_TEXT = uipage.ui_text()
WS = UI_TEXT["settings"]["setup"]
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
# --------------------------------------------------------------------------- #
print("1. six steps, drawn from the layout table")
# --------------------------------------------------------------------------- #
LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS)
TOP = [f for g in LAYOUT["groups"] for p in g["parts"] for f in p["fields"]]
BY_STEP = {n: [f["key"] for f in TOP if f["wizard"] == n]
for n in range(1, L.WIZARD_STEPS + 1)}
print(f" {BY_STEP}")
check("every field reaches the page with its wizard step",
all("wizard" in f for f in TOP)
and all(s["wizard"] == 2 for f in TOP for s in f.get("fields", [])))
check("the mockup's six steps: meter, inverters, solar, prices, peak limit, "
"check and start",
BY_STEP[1][0] == "meter_source" and BY_STEP[2] == ["inverters"]
and BY_STEP[3] == ["ui.solar"] and BY_STEP[4][0] == "ui.contract"
and BY_STEP[5] == ["peak_cap_w", "peak_forecast_entity"]
and BY_STEP[6] == [L.SHADOW[0]]
and [WS["steps"][str(n)]["name"] for n in range(1, 7)]
== ["Your meter", "Inverters and batteries", "Solar", "Prices",
"Peak limit", "Check and start"])
check("battery modes are not a step yet (OPT-11): step 5 says they come later",
"later update" in WS["modes_later"]["description"]
and not any("mode" in k for k in BY_STEP[5]))
SPEC = uipage.setup_spec()
check("the spec the page reads is the table's",
SPEC["steps"] == 6 and SPEC["final"] == 6 and SPEC["optional"] == [3]
and SPEC["kettle"]["min_w"] == L.KETTLE_MIN_W
and set(SPEC["kettle"]["sources"]) == set(L.KETTLE)
and SPEC["shadow"] == {"field": "shadow_mode", "on": "on",
"off": "auto"})
_src = L.field(L.KETTLE_SOURCE)
_choices = {c[0] for f in TOP if f["key"] == L.KETTLE_SOURCE
for c in f["choices"]}
_bad = []
for source, (read, outcomes) in L.KETTLE.items():
model = {L.KETTLE_SOURCE: source}
for key, _sign in read:
fld = L.field(key)
if not (fld and fld.wizard == 1 and fld.control == L.ENTITY
and L.visible(fld, model)):
_bad.append(key)
for _say, sets in outcomes.values():
for key in (sets or {}):
fld = L.field(key)
if not (fld and fld.wizard == 1 and L.visible(fld, model)):
_bad.append(key)
check(f"each kettle source is a meter choice, and reads and sets only step-1 "
f"fields shown for it ({_bad})",
set(L.KETTLE) <= _choices and not _bad and _src.wizard == 1)
_shadow = next((f for f in TOP if f["key"] == L.SHADOW[0]), {})
check("the shadow switch writes two of shadow mode's own choices",
{L.SHADOW[1], L.SHADOW[2]} <= {c[0] for c in _shadow.get("choices", [])})
# --------------------------------------------------------------------------- #
print("\n2. the kettle test's sign logic")
# --------------------------------------------------------------------------- #
K = uipage.kettle
check("one grid entity: up keeps the sign, down flips it",
K("off", -10, 1990) == ("ok", {"meter_invert": False}, 2000)
and K("off", 400, -1600) == ("flip", {"meter_invert": True}, -2000))
check("the direction is the change, not the sign: exporting 3 kW, a kettle "
"still moves the reading up",
K("off", -3000, -1000)[0] == "ok" and K("off", 3000, 1000)[0] == "flip")
check(f"a change under {L.KETTLE_MIN_W:.0f} W sets nothing and is not passed",
K("off", 0, 499)[:2] == ("none", None)
and K("off", 0, -499)[:2] == ("none", None)
and K("off", 0, 500)[0] == "ok")
check("a net entity that goes down cannot be fixed by an option, so the step "
"is not passed",
K("ha_signed", 0, 2000) == ("ok", {}, 2000)
and K("ha_signed", 0, -2000)[:2] == ("wrong_signed", None))
check("import and export are read as their difference",
L.KETTLE["ha_dsmr"][0] == (("p1_import_entity", 1),
("p1_export_entity", -1))
and K("ha_dsmr", -3000, -1000)[0] == "ok"
and K("ha_dsmr", 0, -2000)[0] == "wrong_dsmr")
check("a meter the add-on reads itself has no kettle test on the page",
K("homewizard_local", 0, 2000) is None and K("mqtt_p1", 0, 2000) is None)
_js = re.search(r"function kettleOutcome\(src, before, after\) \{(.*?)\n \}",
JS, re.S)
check("the page reaches the same verdict: settings.js kettleOutcome() is "
"uipage.kettle() line for line",
_js and "const d = after - before;" in _js.group(1)
and "Math.abs(d) < M.setup.kettle.min_w" in _js.group(1)
and 'src[d > 0 ? "up" : "down"]' in _js.group(1))
check("...and it applies what the verdict sets, and passes the step only on "
"a verdict that sets something",
"if (o.sets) { Object.assign(V, o.sets); queueValidate(); }" in JS
and "ok: o.sets != null" in JS)
check("a changed meter or entity forgets the result, so the test runs again",
"if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null;" in JS)
check("each verdict has words", all(say in WS["kettle"] for _r, o in
L.KETTLE.values()
for say, _s in o.values())
and {"none", "unread", "skipped", "self"} <= set(WS["kettle"]))
# --------------------------------------------------------------------------- #
print("\n3. which refusals block which step")
# --------------------------------------------------------------------------- #
_m = U.options_to_ui(dict(NEW, meter_source="ha_signed"))
_msgs = [{"field": "p1_net_entity", "card": None, "tier": "bad", "text": "a"},
{"field": "meter_entity", "card": None, "tier": "bad", "text": "b"},
{"field": "inverters.setpoint_entity", "card": 0, "tier": "bad",
"text": "c"},
{"field": "maintenance_charge_w", "card": None, "tier": "bad",
"text": "d"},
{"field": None, "card": None, "tier": "bad", "text": "e"},
{"field": "peak_cap_w", "card": None, "tier": "warn", "text": "f"}]
check("a refusal blocks the step of its field, a hidden or non-wizard field's "
"and an unplaced one the last step, and a warning blocks nothing",
uipage.step_problems(_msgs, _m)
== {"1": 1, "2": 1, "3": 0, "4": 0, "5": 0, "6": 3})
class Store:
"""app/store.py's shape: a dict under .data, and set()."""
def __init__(self, data=None, fresh=False):
self.data = dict(data or {})
self.fresh = fresh
def set(self, key, value):
self.data[key] = value
def state(eid, value, unit="W", cls=None):
return {"entity_id": eid, "state": value,
"attributes": {"friendly_name": eid, "unit_of_measurement": unit,
"device_class": cls}}
class Hass:
session = None
token = ""
def __init__(self, states):
self.states_now = states
self.reads = []
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return copy.deepcopy(self.states_now)
async def state(self, entity):
self.reads.append(entity)
return next((copy.deepcopy(s) for s in self.states_now
if s["entity_id"] == entity), None)
class Supervisor:
def __init__(self, options):
self.options = copy.deepcopy(options)
self.writes = []
self.restarts = 0
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS}
async def write_options(self, options):
self.writes.append(copy.deepcopy(options))
self.options = copy.deepcopy(options)
async def restart(self, confirm):
self.restarts += 1
class Controller:
def __init__(self, opts, states, store=None, auto=False, grid=None):
self.o, self.hass, self.inverters = dict(opts), Hass(states), []
self.store, self.auto, self.grid = store, auto, grid
def log_event(self, text, hint=None):
pass
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": [],
"plan": {"prices": [{"eur_kwh": 0.2}] * 20
+ [{"eur_kwh": None}] * 4}}
async def handle_action(self, what):
raise AssertionError(f"the wizard reached {what!r}")
async def session(ctl, stored, steps):
sup = Supervisor(stored)
runner = aioweb.AppRunner(web.build_app(ctl, sup))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
try:
async with ClientSession() as s:
out = await steps(s, root, sup)
finally:
await runner.cleanup()
return out, sup
async def _get(s, url):
async with s.get(url) as r:
return await r.json()
async def _post(s, url, body):
async with s.post(url, json=body) as r:
return r.status, await r.json()
STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"),
state("sensor.inv1_level", "62", "%", "battery"),
state("sensor.inv1_battery", "-800"),
state("sensor.pv", "-1200", "W", "power"),
state("sensor.net", "-3.1", "kW", "power")]
async def _steps(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
model = copy.deepcopy(m["ui"])
model["inverters"][0]["setpoint_entity"] = ""
return (await _post(s, root + "config/validate", {"model": model}))[1]
_r, _sup = asyncio.run(session(Controller(NEW, STATES), NEW, _steps))
print(f" blank power request entity: steps {_r['steps']}")
check("over HTTP, a blank power request entity blocks step 2 and no other",
_r["steps"]["2"] >= 1
and all(v == 0 for k, v in _r["steps"].items() if k != "2"))
check("the page moves on only when the step's count is 0, the kettle test is "
"done on step 1, and the last step saves only with nothing red",
"if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); "
"firstBad(); return; }" in JS
and "if (n === 1 && !kettleOk())" in JS
and "if (WZ.blocked) { toast(WS.check.blocked.description); return; }"
in JS)
# --------------------------------------------------------------------------- #
print("\n4. first run: a new install opens the wizard, a working site not")
# --------------------------------------------------------------------------- #
S = uipage.setup_needed
check("a new install: no Store file at this boot, never set up, control "
"never started or stopped",
S(NEW, {}, False, fresh=True) == "first_start"
and S(NEW, {"phase": "idle", "sign_tripped": False}, False, fresh=True)
== "first_start")
check("a site that runs control, or ever ran or stopped it, is not sent to "
"the wizard", S(NEW, {}, True, fresh=True) is None
and S(NEW, {"auto": True}, False, fresh=True) is None
and S(NEW, {"auto": False}, False, fresh=True) is None)
check("a site the wizard saved is not sent again",
S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False,
fresh=True) is None)
# Review R1: the Stores the two TEST-23 add-ons really have (2026-09-28): no
# `auto` (Start/Stop never pressed, auto_start off), no setup_done, stopped,
# one of them in shadow for weeks. A Store read from a file is an old site.
_T23_ADDON = dict.fromkeys((
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
"maint_schema_version", "phase", "phase_started", "schema_version",
"sign_reason", "sign_tripped", "site_sign_reason", "site_sign_tripped",
"unit/inverter-a/last_completed", "unit/inverter-a/last_start_attempt",
"unit/inverter-a/phase", "unit/inverter-a/phase_started",
"unit/inverter-a/sign_reason", "unit/inverter-a/sign_tripped"))
_T23_ARB = dict.fromkeys((
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
"phase", "phase_started", "schema_version", "sign_reason", "sign_tripped",
"site_sign_reason", "site_sign_tripped", "unit/inverter-a/sign_reason",
"unit/inverter-a/sign_tripped"))
check("an old site never started (the real TEST-23 Stores, one in shadow) is "
"not sent to the wizard: its Store came from a file (review R1)",
S(NEW, _T23_ADDON, False) is None and S(NEW, _T23_ARB, False) is None
and S(dict(NEW, shadow_mode="on"), _T23_ARB, False) is None
and S(NEW, {}, False) is None)
check("stored options with no inverter to control always open it, whatever "
"the Store says",
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
== "no_power_request"
and S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": " "}]),
None, True) == "no_power_request")
check("a list roster with a power request entity is set up",
S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": "number.a"}]),
{"auto": True}, True) is None)
check("without a Store the answer is no (never nag on a guess)",
S(NEW, None, False) is None)
_sites = [(n, dict(DEFAULTS, **json.load(open(os.path.join(
HERE, "scenarios", "ux13", "test23", n))))) for n in sorted(os.listdir(
os.path.join(HERE, "scenarios", "ux13", "test23")))]
_starting = [(n, o) for n, o in _sites if not validate_startup_config(o)]
check(f"the TEST-23 sites that start, and run control, are not sent to the "
f"wizard ({[n for n, _o in _starting]})", len(_starting) >= 3
and all(S(o, {"auto": True, "phase": "idle"}, True) is None
for _n, o in _starting))
check("...and the one that cannot start (no power request entity) is",
[S(o, {"auto": True}, True) for n, o in _sites
if (n, o) not in _starting] == ["no_power_request"])
async def _first(s, root, sup):
out = {"model": await _get(s, root + "config/model?lang=en")}
m = out["model"]
model = copy.deepcopy(m["ui"])
model["peak_cap_w"] = 4000
out["plain"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"]})
out["store_plain"] = dict(_store.data)
out["after_plain"] = await _get(s, root + "config/model?lang=en")
m = out["after_plain"]
model = copy.deepcopy(m["ui"])
out["wizard"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"], "setup": "done"})
out["after"] = await _get(s, root + "config/model?lang=en")
return out
_store = Store({"phase": "idle"}, fresh=True)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first))
check("over HTTP, /config/model says a new install needs setup, with the spec",
_r["model"]["setup_needed"] == "first_start"
and _r["model"]["setup"] == json.loads(json.dumps(SPEC)))
check("a save from the Installer view does not count as setup",
_r["plain"][0] == 200 and _r["after_plain"]["setup_needed"]
== "first_start" and uipage.SETUP_DONE not in _r["store_plain"])
check("the wizard's save writes the Store marker, and the wizard stops "
"opening by itself",
_r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE)
and _r["after"]["setup_needed"] is None)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store(_T23_ARB)), NEW,
_first))
check("over HTTP, an old site never started (a Store read from a file) is not "
"sent to the wizard (review R1)", _r["model"]["setup_needed"] is None)
with tempfile.TemporaryDirectory() as _d:
from app.store import Store as _RealStore
_path = os.path.join(_d, "state.json")
_new = _RealStore(_path)
_new.set("phase", "idle")
_old = _RealStore(_path)
with open(_path, "w") as _fh:
_fh.write("{not json")
_bad = _RealStore(_path)
check("app/store.py: fresh only when this boot found no state file (a new "
"/data), not after a save and not for an unreadable file",
_new.fresh is True and _old.fresh is False and _bad.fresh is False)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}),
auto=True), NEW, _first))
check("a working site's /config/model says no", _r["model"]["setup_needed"]
is None)
check("the page goes to Setup by itself only when opened with no route and "
"only while still on Home",
"first: !location.hash" in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read()
and 'if (M && M.setup_needed && location.hash === "#home") '
'location.hash = "#setup";' in JS)
# --------------------------------------------------------------------------- #
print("\n5. the last step's live checks")
# --------------------------------------------------------------------------- #
ROWS = {s["entity_id"]: {"entity_id": s["entity_id"], "state": s["state"],
"unit": s["attributes"]["unit_of_measurement"]}
for s in STATES}
SITE = dict(NEW, meter_entity="sensor.grid", soc_entity="sensor.inv1_level",
batt_entity="sensor.inv1_battery", pv_entity="sensor.pv")
def tiers(notes):
return [(n.tier, n.text) for n in notes]
_c = tiers(setup_checks(SITE, entities=ROWS))
for t in _c:
print(f" {t}")
check("the meter reads, as the add-on will see it",
("ok", "The meter reads 402 W taken from the grid now.") in _c)
check("with the sign flipped, the same reading is fed in",
("ok", "The meter reads 402 W fed into the grid now.") in tiers(
setup_checks(dict(SITE, meter_invert=True), entities=ROWS)))
check("the power request entity exists in Home Assistant (a write cannot "
"tell: SAFETY-77)",
("ok", "Home Assistant has number.inv1_power, the power request "
"entity.") in _c)
check("the battery level reads, and the battery power with its direction",
("ok", "The battery level reads 62 %.") in _c
and ("info", "The battery reads 800 W charging now, so check the "
"inverter shows the same.") in _c)
check("a solar entity below zero, not flipped, looks reversed",
("warn", "sensor.pv reads below zero now, so turn on its “Reads "
"negative” switch in the Solar step.") in _c
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
entities=ROWS)))
_miss = tiers(setup_checks(dict(SITE, setpoint_entity="number.typo"),
entities=ROWS))
check("a power request entity Home Assistant does not have is red, so the "
"wizard does not save",
("bad", "Home Assistant has no entity number.typo, which “Inverter "
"setpoint entity” (setpoint_entity) names.") in _miss)
check("a missing battery level entity is a warning, not red (the add-on "
"holds 0 W and says so)",
[t for t, x in tiers(setup_checks(dict(SITE, soc_entity="sensor.x"),
entities=ROWS))
if "sensor.x" in x] == ["warn"])
_two = dict(DEFAULTS, meter_source="ha_signed", p1_net_entity="sensor.net",
inverters=[{"name": "a", "setpoint_entity": "number.inv1_power",
"soc_entity": "sensor.inv1_level", "max_w": 2000,
"bms_soc_floor": 10},
{"name": "b", "setpoint_entity": "number.b",
"max_w": 2000, "bms_soc_floor": 10}])
_c2 = tiers(setup_checks(_two, entities=ROWS))
check("with two inverters each is named, and a kW meter is shown as the "
"add-on reads it, beside the warning to pick a sensor in W",
("ok", "The meter reads 3 W fed into the grid now.") in _c2
and [t for t, x in _c2 if "reads in kW" in x] == ["warn"]
and ("bad", "Home Assistant has no entity number.b, which “Inverter "
"setpoint entity” for b (inverters.setpoint_entity) names.")
in _c2
and ("ok", "The battery level for a reads 62 %.") in _c2
and ("warn", "There is no battery level entity for b, so pick one in "
"step 2 or the add-on holds 0 W.") in _c2)
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
p1_export_entity="sensor.inv1_level")
check("import and export are read as their difference",
("ok", "The meter reads 340 W taken from the grid now.")
in tiers(setup_checks(dict(_dsmr), entities=dict(
ROWS, **{"sensor.inv1_level": {"state": "62", "unit": "W"}}))))
_hw = dict(SITE, meter_source="homewizard_local", p1_host="192.0.2.5")
check("a meter the add-on reads itself: its live reading when it already "
"runs on it, else a note that Home shows it after the restart",
("ok", "The meter reads 250 W taken from the grid now.") in tiers(
setup_checks(_hw, entities=ROWS, running=_hw, meter_now_w=250))
and [t for t, x in tiers(setup_checks(_hw, entities=ROWS, running=SITE,
meter_now_w=250))
if "meter" in x] == ["info"])
check("an unreadable meter is a warning",
("warn", "sensor.grid reads unavailable now, so check it in Home "
"Assistant before you start.") in tiers(setup_checks(SITE, entities=dict(
ROWS, **{"sensor.grid": {"state": "unavailable",
"unit": "W"}}))))
_down = tiers(setup_checks(SITE, entities=None))
check("Home Assistant down: red, because the power request entity cannot be "
"checked (review R4), and nothing claimed ok about an entity",
_down[0] == ("bad", "Home Assistant is not answering, so press Check "
"again once it is back.")
and not [x for t, x in _down if t in ("ok", "bad") and "entity" in x])
# Review R5: meter + solar + battery is the house's use, never below zero. A
# battery giving 1 500 W whose sensor reads it the wrong way (-1 500 W).
_rev = dict(ROWS, **{"sensor.pv": {"state": "0", "unit": "W"},
"sensor.inv1_battery": {"state": "-1500", "unit": "W"}})
_bal = ("warn", "The readings add up to -1 098 W of house use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
check("a battery sensor reading the wrong way while giving power is flagged "
"(review R5); not once its switch is on, not without solar, not with "
"the battery reading unknown, and not on the normal site",
_bal in tiers(setup_checks(SITE, entities=_rev))
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_invert=True),
entities=_rev))
if "add up" in x]
and not [x for t, x in tiers(setup_checks(dict(SITE, pv_entity=""),
entities=_rev))
if "add up" in x]
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_entity=""),
entities=_rev))
if "add up" in x]
and not [x for x in (t[1] for t in _c) if "add up" in x])
_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
_dyn = dict(SITE, price_provider="generic_dayahead")
check("prices: known from a contract; for a feed, today's count when it "
"already runs, else after the restart; none when none is set",
("ok", "Your prices come from your contract, so they are always known.")
in tiers(setup_checks(_fixed, entities=ROWS))
and ("ok", "Prices for today are in, for 20 of 24 hours.")
in tiers(setup_checks(_dyn, entities=ROWS, running=_dyn,
prices_known=20))
and ("info", "Prices are fetched after the restart, and Home shows "
"them.") in tiers(setup_checks(_dyn, entities=ROWS,
running=SITE,
prices_known=20))
and ("info", "No prices are set, so the batteries do not charge on "
"price.") in _c)
async def _final(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
model = copy.deepcopy(m["ui"])
model["inverters"][0]["setpoint_entity"] = "number.typo"
out = {"ok": (await _post(s, root + "config/validate",
{"model": m["ui"], "setup": True}))[1],
"typo": (await _post(s, root + "config/validate",
{"model": model, "setup": True}))[1],
"plain": (await _post(s, root + "config/validate",
{"model": m["ui"]}))[1]}
return out
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
SITE, _final))
check("over HTTP, the last step gets the checks beside the refusals, read "
"from a fresh entity list",
[x["tier"] for x in _r["ok"]["setup"] if "number.inv1_power" in x["text"]]
== ["ok"] and not [m for m in _r["ok"]["messages"] if m["tier"] == "bad"]
and "setup" not in _r["plain"])
check("...and a typed power request entity HA does not have is red there",
["bad"] == [x["tier"] for x in _r["typo"]["setup"]
if "number.typo" in x["text"]])
async def _kettle(s, root, sup):
return (await _get(s, root + "ha/entities?entity=sensor.net"),
await _get(s, root + "ha/entities?entity=sensor.gone"))
(_k, _gone), _sup = asyncio.run(session(Controller(SITE, STATES), SITE,
_kettle))
check("the kettle test reads one entity now, in W, and says when it is not "
"there", _k == {"entity_id": "sensor.net", "found": True,
"state": "-3.1", "unit": "kW", "watts": -3100.0}
and _gone["found"] is False and _gone["watts"] is None)
# --------------------------------------------------------------------------- #
print("\n6. the save goes through the P2 path and writes a valid blob")
# --------------------------------------------------------------------------- #
_plan = {"meter": "signed", "roster": "two", "solar": "two",
"prices": "daynight", "region": "flanders", "peak": "forecast",
"shadow": "on"}
async def _wizard(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
model = B.browser(B.apply(m["ui"], _plan))
v = (await _post(s, root + "config/validate", {"model": model,
"setup": True}))[1]
saved = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"], "setup": "done"})
return v, saved
_store = Store()
(_v, _saved), _sup = asyncio.run(session(Controller(NEW, STATES, _store),
NEW, _wizard))
_blob = _sup.writes[-1] if _sup.writes else {}
check("the wizard's model validates clean and saves with 200",
not [m for m in _v["messages"] if m["tier"] == "bad"]
and _saved[0] == 200 and len(_sup.writes) == 1)
check("the blob written starts: the shipped validator accepts it",
not validate_startup_config(_blob))
check("it has what the six steps set: the net meter, two named inverters, "
"two solar rows with one flipped, day and night prices, the Flanders "
"levies, the peak limit, and shadow mode on",
_blob["meter_source"] == "ha_signed"
and [i["name"] for i in _blob["inverters"]] == ["inverter-1",
"inverter-2"]
and _blob["setpoint_entity"] == ""
and _blob["pv_entities"] == ["sensor.pv_a", "sensor.pv_b"]
and _blob["pv_invert_entities"] == ["sensor.pv_b"]
and json.loads(_blob["static_profile"])["periods"][0]["eur_per_kwh"]
== 0.34 and _blob["price_taxes_eur_kwh"]
== U.REGION_PRESETS["flanders"]["taxes_eur_kwh"]
and _blob["peak_cap_w"] == 4200 and _blob["shadow_mode"] == "on")
check("every key config.yaml declares is written, in the stored order",
list(_blob)[:len(NEW)] == list(NEW) and set(_blob) == set(NEW))
check("the wizard saved without a restart of its own: the restart is a "
"second, confirmed request", _sup.restarts == 0
and 'JSON.stringify({ confirm: "restart" })' in JS.split(
"async function finish")[1])
_all = B.blobs()
_new = [(n, r) for n, _b, r in _all if n.startswith("new-")]
_ok = [n for n, _b, r in _all if not r]
print(f" {len(_all)} blobs from {len(B.starts())} starts; "
f"{len(_ok)} accepted")
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
if r])
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 "
"last step then lists",
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
for n, _b, r in _all) and len(_ok) >= 80)
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))
# --------------------------------------------------------------------------- #
print("\n7. a stale save names only what changed elsewhere (P2 finding a)")
# --------------------------------------------------------------------------- #
async def _stale(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
sup.options["peak_cap_w"] = 5000 # the Configuration tab
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12 # the operator's own edit
first = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
again = await _post(s, root + "config/save",
{"model": model, "fingerprint": first[1]["fingerprint"]})
return first, again
(_first_r, _again), _sup = asyncio.run(session(Controller(SITE, STATES), SITE,
_stale))
print(f" changed_fields {_first_r[1]['changed_fields']}, moved "
f"{_first_r[1]['moved']}, changed {_first_r[1]['changed']}")
check("the banner's list is the other surface's key only, not the operator's "
"own edit", _first_r[0] == 409
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
and _first_r[1]["moved"] == ["peak_cap_w"])
check("`changed` (stored against mine, which the classic page shows as "
"theirs -> mine) is unchanged",
_first_r[1]["changed"] == ["peak_cap_w", "price_timeout_s"])
check("saving again with the new token is the deliberate second press: it "
"stores the page as shown (Discard loads theirs instead)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 3500
and _sup.options["price_timeout_s"] == 12)
async def _two_pages(s, root, sup):
"""The classic page and this one open the same blob; the classic one
saves first (the browser run found the token was dropped then)."""
m = await _get(s, root + "config/model?lang=en")
classic = await _get(s, root + "config/model?lang=en")
theirs = dict(classic["options"], peak_cap_w=5000)
await _post(s, root + "config/save", {"options": theirs,
"fingerprint": classic["fingerprint"]})
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12
return await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _two_pages))
check("two pages on one blob: after the other page saved, this one's banner "
"still names only the other page's change",
_r[0] == 409 and _r[1]["changed_fields"] == ["peak_cap_w"])
async def _unknown(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
sup.options["peak_cap_w"] = 5000
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12
return await _post(s, root + "config/save",
{"model": model, "fingerprint": "not-handed-out"})
_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _unknown))
check("a token the add-on did not hand out (it restarted) falls back to "
"every key that differs, as before",
_r[0] == 409 and _r[1]["moved"] is None
and _r[1]["changed_fields"] == ["peak_cap_w", "price_timeout_s"])
# --------------------------------------------------------------------------- #
print("\n8. the wizard's words keep to STYLE")
# --------------------------------------------------------------------------- #
def _texts(node, path=""):
for k, v in node.items():
if isinstance(v, dict):
yield from _texts(v, f"{path}{k}.")
elif isinstance(v, str):
yield f"{path}{k}", v
def _sentences(t):
return len(re.findall(r"[.!?](?:\s|$)", t))
_long = [(p, t) for p, t in _texts(WS)
if len(t.split()) > (25 if p.endswith("description") else 6)
or (p.endswith("description") and _sentences(t) > 1) or ";" in t]
check(f"every wizard string is one sentence of at most 25 words, or a name "
f"of at most 6 ({_long})", not _long and len(list(_texts(WS))) > 50)
_rendered = [x for notes in (
setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS),
setup_checks(SITE, entities=None), setup_checks(_hw, entities=ROWS),
setup_checks(_fixed, entities=ROWS), setup_checks(SITE, entities=_rev),
setup_checks(dict(
SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"),
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
for x in (n.text for n in notes)]
_over = [t for t in _rendered if len(t.split()) > 25 or _sentences(t) > 1
or ";" in t]
check(f"every live check, rendered with real values, is one sentence of at "
f"most 25 words ({len(_rendered)} rendered, over: {_over})",
len(_rendered) > 20 and not _over)
_dialogs = [WS["dialog"]["description"], WS["dialog_live"]["description"]]
check("both restart dialogs name the 0 W, and the shadow one says it stays",
all("0 W" in d for d in _dialogs) and "shadow mode keeps them at 0 W"
in _dialogs[0])
check("the wizard defaults to shadow mode: the switch starts on",
"shadowOn: true" in JS)
check("the shadow switch never writes into V, which the Installer view saves "
"too: it goes only on the model the wizard posts (review R3)",
"V[M.setup.shadow.field]" not in JS and "V[s.field]" not in JS
and JS.count("model: posted()") == 2)
check("a skip after a fall keeps saying the reading went down (review R2)",
"wrong: !!k.wrong }; drawStep();" in JS
and "WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped" in JS
and 'const wrong = o.say === "none" ? !!k.wrong : o.sets == null;' in JS)
_leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
check(f"settings.js, wizard included, names no option key ({_leak})",
not _leak)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+7 -4
View File
@@ -82,8 +82,9 @@ check("every control, entity kind and show operator is a known one",
all(f.control in L.CONTROLS and (not f.kind or f.kind in L.ENTITY_KINDS)
and all(op in L.SHOW_OPS for _n, op, _v in f.show)
for _g, _p2, f in ROWS))
_names = set(ORDER) | set(KEYS) | {"inverter_count"}
check("every show condition reads an option, a page field or inverter_count",
_names = set(ORDER) | set(KEYS) | {uimodel.COUNT} | set(uimodel.GROWN)
check("every show condition reads an option, a page field, or the inverter "
"count or shape",
all(n in _names for _g, _p2, f in ROWS for n, _o, _v in f.show))
check("every entity control has a kind, and no other control has one",
all((f.control in (L.ENTITY, L.ENTITY_LIST, L.SOLAR_LIST)) == bool(f.kind)
@@ -189,11 +190,13 @@ _owned = set(L.DERIVED) | {"inverters", "price_provider",
_extra = sorted({k for k in _KEY.findall(_src) if k in ORDER} - _owned)
check(f"uimodel.py names only the keys its derivations write (others: "
f"{_extra})", not _extra)
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py")
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py",
"uipage.py")
for k in _KEY.findall(io.open(os.path.join(HERE, "app", name),
encoding="utf-8").read())
if k in ORDER})
check(f"haentities.py and web.py name no option key ({_leak})", not _leak)
check(f"haentities.py, web.py and uipage.py name no option key ({_leak})",
not _leak)
print()
if fails:
+167 -11
View File
@@ -1,19 +1,35 @@
"""UX-13 P1: serve the real page and routes over a fixture controller, for a
headless browser check. `python3 tools/ux13_demo.py STATE PORT`
"""UX-13: serve the real page and routes over a fixture controller, for a
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
STATE is running, shadow or alarm. The controller is the shipped `Controller`
with its readings set by hand (two batteries, solar, today's prices in the
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
inverter: `/action` changes only this fixture. Used for the P1 render check
(no JS errors, tooltips, phone width, dark mode) described in the PR.
P2: the fixture Supervisor serves config.yaml's defaults with this site's
options, a schema read off config.yaml the way the Supervisor renders it
(`schema_ui()`) and translations/en.yaml, and keeps what the settings view
saves in memory, so the Installer view can be clicked through end to end.
P4: STATE `new` is a new install (config.yaml's defaults with one power
request entity, an empty Store made at this boot, control never started), so the page opens
the setup wizard by itself. A one-entity read of sensor.grid steps through
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
reversed meter (down).
"""
import asyncio
import io
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
from aiohttp import web as aioweb # noqa: E402
@@ -22,12 +38,15 @@ from app.main import Controller # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
import translations_lint as TL # noqa: E402
PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
.14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
class Store(dict):
fresh = False # app/store.py: no state file at this boot
def set(self, k, v):
self[k] = v
@@ -63,8 +82,12 @@ class Hass:
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def state(self, e):
s = next((dict(x) for x in STATES if x["entity_id"] == e), None)
if s and e == "sensor.grid":
n = KETTLE_COUNT[0] = KETTLE_COUNT[0] + 1
s["state"] = str(KETTLE_READS[(n - 1) % len(KETTLE_READS)])
return s
async def set_number(self, _e, _v):
return True
@@ -73,12 +96,123 @@ class Hass:
return True
async def states(self):
return []
return STATES
KETTLE_READS = (-10, 1990, 1990, -10)
KETTLE_COUNT = [0]
def _state(eid, name, state, unit=None, cls=None):
return {"entity_id": eid, "state": state,
"attributes": {"friendly_name": name, "unit_of_measurement": unit,
"device_class": cls}}
STATES = [
_state("sensor.grid", "Grid power", "-10", "W", "power"),
_state("sensor.grid_kw", "Grid power in kW", "-0.01", "kW", "power"),
_state("sensor.pv", "Solar power", "3900", "W", "power"),
_state("sensor.soc_a", "Battery A level", "62", "%", "battery"),
_state("sensor.soc_b", "Battery B level", "35", "%", "battery"),
_state("sensor.batt_a", "Battery A power", "-2500", "W", "power"),
_state("sensor.batt_b", "Battery B power", "-300", "W", "power"),
_state("number.a", "Battery A power request", "0", "W"),
_state("number.b", "Battery B power request", "0", "W"),
_state("sensor.peak_forecast", "Quarter-hour peak forecast", "1240", "W",
"power"),
]
# --- config.yaml as the Supervisor renders it (App.schema_ui) --------------- #
def _node(name, spec):
spec = spec.strip()
optional = spec.endswith("?")
spec = spec.rstrip("?")
out = {"name": name, "required": not optional, "optional": optional}
m = re.match(r"(int|float)\(([^,]*),([^)]*)\)$", spec)
if m:
out.update(type="integer" if m.group(1) == "int" else "float",
lengthMin=float(m.group(2)), lengthMax=float(m.group(3)))
elif spec.startswith("list("):
out.update(type="select", options=spec[5:-1].split("|"))
elif spec in ("bool",):
out["type"] = "boolean"
elif spec in ("int", "float"):
out["type"] = "integer" if spec == "int" else "float"
else:
out["type"] = "string"
return out
def schema_ui(text):
"""config.yaml's `schema:` block in the Supervisor's schema_ui shape: the
fixture for tests and this demo, which cannot run the Supervisor."""
block = text[text.index("\nschema:"):].splitlines()[1:]
out, i = [], 0
while i < len(block):
line = block[i]
m = re.match(r"^ ([a-z_0-9]+):\s*(\S.*?)?\s*$", line)
i += 1
if not m:
continue
name, spec = m.group(1), m.group(2)
if spec:
out.append(_node(name, spec))
continue
items = []
while i < len(block) and (block[i].startswith(" ") or not block[i].strip()):
if block[i].strip() and not block[i].strip().startswith("#"):
items.append(block[i].strip())
i += 1
if items and items[0].startswith("- ") and ":" in items[0]:
fields = [items[0][2:]] + items[1:]
out.append({"name": name, "type": "schema", "optional": True,
"multiple": True,
"schema": [_node(*f.split(":", 1)) for f in fields]})
else:
node = _node(name, items[0][2:] if items else "str")
node.update(multiple=True, required=False, optional=True)
out.append(node)
return out
def defaults(text):
"""config.yaml's `options:` block (as test_uimodel.py reads it)."""
block = text[text.index("\noptions:"):text.index("\nschema:")]
out = {}
for key, raw in re.findall(r"^ ([a-z_0-9]+):(.*)$", block, re.M):
raw = raw.split(" #")[0].strip()
try:
out[key] = json.loads(raw)
except ValueError:
out[key] = raw
return out
CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
class Supervisor:
"""The stored options, kept in memory: a save here writes nothing else."""
def __init__(self, options):
self.options = options
self.writes = 0
async def info(self):
return {"options": {}, "schema": [], "translations": {}}
return {"options": json.loads(json.dumps(self.options)),
"schema": schema_ui(CONFIG),
"translations": {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}}
async def write_options(self, options):
self.options = json.loads(json.dumps(options))
self.writes += 1
print(f"options saved ({self.writes})", flush=True)
async def restart(self, confirm):
print(f"restart asked ({confirm!r})", flush=True)
OPTS = {"meter_entity": "sensor.grid", "meter_source": "off",
@@ -112,6 +246,8 @@ def controller(state):
for inv, soc, batt in zip(c.inverters, (62.0, 35.0), (-2500.0, -300.0)):
inv.soc, inv.batt = soc, (0.0 if state == "shadow" else batt)
inv.dev_min, inv.dev_max = -5000.0, 5000.0
if state != "running": # 0 W accepted: Home says "Held"
inv.last_write = 0.0
c.grid = -10.0 if state != "shadow" else -2810.0
c.pv_w = 3900.0
if state == "alarm":
@@ -119,13 +255,33 @@ def controller(state):
return c
async def main(state, port):
runner = aioweb.AppRunner(web.build_app(controller(state), Supervisor()))
async def main(state, port, host="127.0.0.1"):
if state == "new":
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
store = Store()
store.fresh = True # a new /data: the wizard opens by itself
c = Controller(dict(stored), Hass(), store, Pub(),
stop=asyncio.Event())
c.auto = False
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving new on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
c = controller(state)
stored = dict(defaults(CONFIG), **OPTS)
stored.update(setpoint_entity="", meter_source="ha_signed",
p1_net_entity="sensor.grid", price_provider="generic_dayahead",
peak_forecast_entity="sensor.peak_forecast",
shadow_mode=c.o.get("shadow_mode", "off"),
inverters=[dict(i, bms_soc_floor=10)
for i in OPTS["inverters"]])
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving {state} on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1], int(sys.argv[2])))
asyncio.run(main(sys.argv[1], int(sys.argv[2]), *sys.argv[3:4]))
@@ -0,0 +1,254 @@
"""UX-13 P4: every options blob the setup wizard can save, and the Supervisor's
own validator run over them.
python3 tools/ux13_wizard_blobs.py --dump DIR # write the blobs
docker run --rm --network none -v "$PWD:/w:ro" -v DIR:/blobs:ro \
--entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/w/tools/ux13_wizard_blobs.py --supervisor /blobs
The wizard edits the page model one step at a time and saves it through
uipage.to_options(), like the Installer view. `blobs()` walks each step's
choices (meter source and sign, one to three inverters, solar rows, every
contract and price source, region, peak limit, shadow mode) from a new
install and from the TEST-23 sites, and keeps what the shipped validator
accepts, because the wizard saves nothing it refuses. test_setup.py checks
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
"""
import copy
import glob
import json
import os
import sys
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def browser(value):
"""What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3."""
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, list):
return [browser(v) for v in value]
if isinstance(value, dict):
return {k: browser(v) for k, v in value.items()}
return value
# --- the wizard's steps, as edits to the page model ------------------------ #
METERS = {
"entity": {"meter_source": "off", "meter_entity": "sensor.grid"},
"entity_flipped": {"meter_source": "off", "meter_entity": "sensor.grid",
"meter_invert": True},
"signed": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net"},
"signed_3": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
"meter_phases": 3, "p1_phase_net_entities": [
"sensor.l1", "sensor.l2", "sensor.l3"]},
"dsmr": {"meter_source": "ha_dsmr", "p1_import_entity": "sensor.imp",
"p1_export_entity": "sensor.exp"},
"homewizard": {"meter_source": "homewizard_local",
"p1_host": "192.0.2.50"},
"mqtt": {"meter_source": "mqtt_p1", "meter_mqtt_topic": "p1/power"},
}
def _card(i, flat=False, **more):
card = {"setpoint_entity": f"number.inv{i}_power",
"soc_entity": f"sensor.inv{i}_level",
"batt_entity": f"sensor.inv{i}_battery", "batt_invert": False,
"max_w": 2500, "bms_soc_floor": 10}
if flat:
card.update(name="", capacity_kwh=None)
else:
card.update(name=f"inverter-{i}", capacity_kwh=5.0)
card.update(more)
return card
ROSTERS = {
"one": ("flat", [_card(1, flat=True)]),
"one_flipped": ("flat", [_card(1, flat=True, batt_invert=True,
max_w=3000)]),
"two": ("list", [_card(1), _card(2, max_w=4600, bms_soc_floor=15)]),
"three": ("list", [_card(1), _card(2), _card(3, capacity_kwh=9.6)]),
}
SOLAR = {
"none": [],
"one": [{"entity": "sensor.pv", "reversed": False}],
"one_reversed": [{"entity": "sensor.pv", "reversed": True}],
"two": [{"entity": "sensor.pv_a", "reversed": False},
{"entity": "sensor.pv_b", "reversed": True}],
}
PRICES = {
"none": {"ui.contract": "none", "ui.fallback": "none"},
"fixed": {"ui.contract": "fixed", "ui.fallback": "fixed",
"ui.fixed_price": 0.31},
"daynight": {"ui.contract": "daynight", "ui.fallback": "daynight",
"ui.day_price": 0.34, "ui.night_price": 0.25},
"dynamic": {"ui.contract": "dynamic", "price_provider": "generic_dayahead",
"ui.fallback": "none"},
"dynamic_fixed": {"ui.contract": "dynamic",
"price_provider": "generic_dayahead",
"ui.fallback": "fixed", "ui.fixed_price": 0.3},
"dynamic_daynight": {"ui.contract": "dynamic",
"price_provider": "generic_dayahead",
"ui.fallback": "daynight", "ui.day_price": 0.3,
"ui.night_price": 0.2},
"formula": {"ui.contract": "dynamic", "price_provider": "custom_formula",
"price_formula": "<the documented example>",
"ui.fallback": "fixed",
"ui.fixed_price": 0.3},
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
"ui.fixed_price": 0.3},
}
REGIONS = {"flanders": {"ui.region": "flanders"},
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
PEAKS = {"low": {"peak_cap_w": 2500, "peak_forecast_entity": ""},
"forecast": {"peak_cap_w": 4200,
"peak_forecast_entity": "sensor.peak_forecast"},
"high": {"peak_cap_w": 10000, "peak_forecast_entity": ""}}
SHADOWS = ("on", "auto") # the switch on, or off (uilayout.SHADOW)
BASE = {"meter": "entity", "roster": "one", "solar": "none",
"prices": "none", "region": "own", "peak": "low", "shadow": "on"}
def plans():
"""Each step's choices once, with the other steps at BASE, then a few
whole sites that combine them."""
axes = {"meter": METERS, "roster": ROSTERS, "solar": SOLAR,
"prices": PRICES, "region": REGIONS, "peak": PEAKS,
"shadow": dict.fromkeys(SHADOWS)}
seen = []
for axis, choices in axes.items():
for choice in choices:
plan = dict(BASE, **{axis: choice})
if plan not in seen:
seen.append(plan)
for plan in (
{"meter": "dsmr", "roster": "two", "solar": "two",
"prices": "dynamic_daynight", "region": "flanders",
"peak": "forecast", "shadow": "on"},
{"meter": "signed_3", "roster": "three", "solar": "one_reversed",
"prices": "daynight", "region": "flanders", "peak": "high",
"shadow": "auto"},
{"meter": "homewizard", "roster": "one_flipped", "solar": "one",
"prices": "fixed", "region": "own", "peak": "forecast",
"shadow": "on"},
{"meter": "mqtt", "roster": "two", "solar": "none",
"prices": "formula", "region": "flanders", "peak": "low",
"shadow": "on"}):
seen.append(plan)
return seen
def apply(model, plan):
"""The page model after the wizard's six steps, as the page leaves it."""
from app import uilayout as L, uimodel as U
m = copy.deepcopy(model)
m.update(copy.deepcopy(METERS[plan["meter"]]))
shape, cards = ROSTERS[plan["roster"]]
cards = copy.deepcopy(cards)
if m.get("inverter_shape") == "list":
# A site stored as a list stays one: one card keeps a name.
for i, card in enumerate(cards, 1):
card.update(name=card["name"] or f"inverter-{i}",
capacity_kwh=card["capacity_kwh"] or 5.0)
m["inverters"] = cards
m[U.COUNT] = len(cards)
if shape == "list":
m.update(U.GROWN)
m["ui.solar"] = copy.deepcopy(SOLAR[plan["solar"]])
m.update(copy.deepcopy(PRICES[plan["prices"]]))
if plan["prices"] == "formula":
from app.custom_formula import _LEGS
m["price_formula"] = _LEGS["price_formula"].example
m.update(copy.deepcopy(REGIONS[plan["region"]]))
if plan["region"] != "own":
m["price_taxes_eur_kwh"] = U.REGION_PRESETS[plan["region"]][
"taxes_eur_kwh"]
m.update(PEAKS[plan["peak"]])
field, on, off = L.SHADOW
m[field] = on if plan["shadow"] == "on" else (
m.get(field) if m.get(field) != on else off)
return m
def starts():
"""(name, stored options) the wizard opens on: a new install (config.yaml's
defaults and one power request entity, typed in the Configuration tab so
the add-on starts) and the TEST-23 sites."""
sys.path.insert(0, os.path.join(HERE, "tools"))
import ux13_demo as D
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
out = [("new", new)]
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
"*.json")))[:3]:
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
**json.load(open(f)))))
return out
def blobs():
"""[(name, blob, refusals)] for every plan from every start."""
sys.path.insert(0, HERE)
from app import uimodel as U, uipage
from app.validate import validate_startup_config
out = []
for start, stored in starts():
model = json.loads(json.dumps(U.options_to_ui(stored)))
for plan in plans():
name = start + "-" + "-".join(f"{k}.{v}" for k, v in plan.items())
posted = browser(json.loads(json.dumps(apply(model, plan))))
blob = uipage.to_options(posted, stored)
out.append((name, blob, validate_startup_config(blob)))
return out
# --- run inside the Supervisor image --------------------------------------- #
def supervisor(folder):
sys.path.insert(0, "/usr/src/supervisor")
import voluptuous as vol
from pathlib import Path
from supervisor.apps.options import AppOptions
from supervisor.apps.validate import SCHEMA_APP_CONFIG
from supervisor.utils.yaml import read_yaml_file
cfg = SCHEMA_APP_CONFIG(read_yaml_file(Path(HERE) / "config.yaml"))
check = vol.Schema(vol.All(dict, AppOptions(
None, cfg["schema"], cfg["name"], "local_goodwe_controller")))
bad = 0
files = sorted(glob.glob(os.path.join(folder, "*.json")))
for f in files:
blob = json.load(open(f))
try:
got = check(copy.deepcopy(blob))
dropped = sorted(set(blob) - set(got))
ok = not dropped
why = f"dropped {dropped}" if dropped else f"{len(got)} keys"
except vol.Invalid as err:
ok, why = False, str(err)
bad += not ok
print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(f)}: {why}")
print(f"{len(files) - bad} of {len(files)} accepted by AppOptions "
f"({cfg['version']})")
return 1 if bad or not files else 0
if __name__ == "__main__":
if sys.argv[1:2] == ["--supervisor"]:
sys.exit(supervisor(sys.argv[2]))
if sys.argv[1:2] == ["--dump"]:
os.makedirs(sys.argv[2], exist_ok=True)
n = 0
for name, blob, refused in blobs():
if refused:
print("refused, not written:", name, refused[0])
continue
with open(os.path.join(sys.argv[2], name + ".json"), "w") as f:
json.dump(blob, f, indent=1)
n += 1
print(f"{n} blobs written to {sys.argv[2]}")
sys.exit(0)
print(__doc__)