Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
308adc9277 | ||
|
|
c664e38ef4 | ||
|
|
7995e4df0f | ||
|
|
a9f7d78527 | ||
|
|
b310282729 | ||
|
|
2d2b6afb07 | ||
|
|
ed739d4cdb |
@@ -4,6 +4,6 @@ Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
|
||||
edit them by hand: run the script again.
|
||||
|
||||
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
|
||||
- `settings-*.png`: Settings (Installer view), and the time-window editor with an overlap.
|
||||
- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap.
|
||||
- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off.
|
||||
- `setup-step-1*.png`: Setup, step 1.
|
||||
- Battery modes (the Batteries screen and the Simple view): not built yet (UX-13 P3), so no screenshot.
|
||||
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -17,5 +17,16 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
capped grid charge release an Off battery. Both are graded in
|
||||
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
|
||||
rail to the rail, so SAFETY-03 still flags a loop winding into it.
|
||||
- P2 (HA entities): a select and a switch per battery, only while modes are
|
||||
on; commands go through `handle_action()`, the page's path. A retained
|
||||
command is dropped, not applied: the store already holds the last choice,
|
||||
and a replay after a restart could otherwise undo a newer one. The config
|
||||
topics announced are kept in the store (`mqtt_mode_configs`), so turning
|
||||
modes off removes exactly those, and an install that never turned them on
|
||||
publishes what ed739d4 did (`test_mqtt.py` part 10). A removed topic stays
|
||||
in that list and its empty config is re-sent on each start, so a start with
|
||||
no broker cannot lose it; re-sending is harmless. The HA options are the
|
||||
page's English names, pinned by a test, and are not renamed with the page:
|
||||
automations send them.
|
||||
- In P1 only Off closes a direction; Leftover solar differs by charging last.
|
||||
Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4.
|
||||
|
||||
@@ -87,6 +87,14 @@ What UX-02 made authoritative stays authoritative:
|
||||
Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133
|
||||
wizard blobs, 17 of them with time windows
|
||||
(`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28).
|
||||
- P3: Settings, Simple draws the fields the table marks `simple`, with an
|
||||
optional `note` under each (words in `settings.simple.note`), plus the
|
||||
batteries' switches and modes. Those are store state set through the
|
||||
existing `/action` values, not options, so the table places no key for
|
||||
them. The Batteries screen's Now, Today and Why lines are built in
|
||||
`homeview.py` from what the add-on knows this cycle; the plan per mode is
|
||||
OPT-11 P3/P4, so until then Today says "Not planned yet" when no block
|
||||
is planned for the battery (`test_batteries_screen.py`).
|
||||
|
||||
## 4. What this costs
|
||||
|
||||
|
||||
@@ -515,13 +515,17 @@ below.
|
||||
|
||||
### Battery modes (`battery_modes_enabled`)
|
||||
|
||||
Off by default. Turn it on, restart the add-on, and the Batteries screen gives each battery two controls. They apply at once, with no restart.
|
||||
Off by default. Turn it on, restart the add-on, and the Batteries screen (and Settings, Simple) gives each battery two controls. They apply at once, with no restart.
|
||||
|
||||
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
|
||||
- **Mode.** *Solar first* charges from spare solar before the other batteries. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update.
|
||||
|
||||
The peak limit, the safety stops and the monthly cycle always come before a battery's mode.
|
||||
|
||||
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
|
||||
|
||||
*For installers:* commands arrive on `goodwe_ctl/unit/<battery>/mode/set` (a *Mode* option, such as `Solar first`) and `.../use/set` (`ON` or `OFF`). They go through the same checks as the Batteries screen. A refusal is logged once, and later refusals are counted in the log. A retained command is never applied, so the broker cannot replay an old choice after a restart; the add-on keeps the last choice itself.
|
||||
|
||||
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
|
||||
|
||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||
@@ -3245,12 +3249,29 @@ The page is in Dutch when your browser's language is Dutch, and in English
|
||||
otherwise. Status lines, alarms and the start checks' messages stay in English.
|
||||
|
||||
Screenshots of each screen are in the add-on's repository, under
|
||||
`docs/img/ux13/`. The battery-mode screens are not built yet, so there are no
|
||||
screenshots of them.
|
||||
`docs/img/ux13/`, including the Batteries screen and Settings, Simple.
|
||||
|
||||
*For installers: regenerate the screenshots with
|
||||
`tools/ux13_screenshots.sh`, which serves the page from test fixtures.*
|
||||
|
||||
### The Batteries screen and Settings, Simple
|
||||
|
||||
The **Batteries** tab has a card per battery: its level, and three lines.
|
||||
**Now** says what it does, **Today** lists the blocks planned for it (price
|
||||
trades, the monthly cycle, the peak limit), and **Why** says the reason. With
|
||||
battery modes on, each card also has the On/Off switch and the mode list; both
|
||||
apply at once. Charging in cheap hours and selling are greyed out until a later
|
||||
update, and Today reads "Not planned yet" when nothing is planned for the
|
||||
battery.
|
||||
|
||||
An Off battery that the peak limit still needs says so, with the power it
|
||||
gives: "Off, but gives 0.5 kW for the peak limit."
|
||||
|
||||
**Settings** opens on the **Simple** view, or on the view you last picked:
|
||||
the peak limit, the batteries' switches and modes, and shadow mode, each
|
||||
marked "Applies right away" or "Applies after a restart". **Installer** shows
|
||||
every setting.
|
||||
|
||||
### Setup, for a new install
|
||||
|
||||
The **Setup** tab walks an installer through six steps: the meter, the
|
||||
|
||||
@@ -20,6 +20,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
@@ -284,6 +285,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
continue
|
||||
hrs = (end - pt.start).total_seconds() / 3600.0
|
||||
for i, inv in enumerate(c.inverters):
|
||||
if _off(c, inv):
|
||||
continue # OPT-11: an Off battery does not trade
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
cap = inv.capacity_kwh
|
||||
if cap:
|
||||
@@ -317,8 +320,8 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
waiting = []
|
||||
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
|
||||
cfg = m.cfg
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
if not cfg.enabled or _off(c, inv):
|
||||
continue # OPT-11: no monthly cycle while it is off
|
||||
cap = inv.capacity_kwh
|
||||
if m.phase != M_IDLE:
|
||||
hours = _cycle_hours(m.phase, inv.soc, cap, cfg)
|
||||
@@ -377,9 +380,10 @@ def _cycle_hours(phase, soc, cap_kwh, cfg):
|
||||
|
||||
def peak_run(c, day0, day1, now, shadow, limit_w):
|
||||
"""While the batteries hold the grid under the peak limit, one block for
|
||||
the current 15-minute period on every battery."""
|
||||
the current 15-minute period on every battery. An Off battery gets it
|
||||
only while it gives power (OPT-11: Off covers only the shortfall)."""
|
||||
pg = c.peak_guard
|
||||
if pg is None or not pg.engaged:
|
||||
if not peak_engaged(c):
|
||||
return []
|
||||
start = now - timedelta(minutes=now.minute % 15, seconds=now.second,
|
||||
microseconds=now.microsecond)
|
||||
@@ -387,15 +391,17 @@ def peak_run(c, day0, day1, now, shadow, limit_w):
|
||||
return [_run(i, "peak", start, end, day0, day1,
|
||||
run_peak_what(pg.ceiling_w or limit_w, shadow),
|
||||
run_peak_why(), None)
|
||||
for i in range(len(c.inverters))]
|
||||
for i, inv in enumerate(c.inverters)
|
||||
if not _off(c, inv) or flow(inv.batt) == "giving"]
|
||||
|
||||
|
||||
def house_runs(n, day0, day1, now, shadow):
|
||||
""""Follows the house" for the rest of the day, drawn under the others."""
|
||||
def house_runs(n, day0, day1, now, shadow, skip=()):
|
||||
""""Follows the house" for the rest of the day, drawn under the others.
|
||||
`skip`: the batteries that are switched off (OPT-11)."""
|
||||
return [_run(i, "house", max(now, day0), day1, day0, day1,
|
||||
run_house_what(shadow), run_house_why(shadow),
|
||||
run_house_amount())
|
||||
for i in range(n)]
|
||||
for i in range(n) if i not in skip]
|
||||
|
||||
|
||||
def region_of(opts):
|
||||
@@ -429,7 +435,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
if running:
|
||||
runs += house_runs(len(c.inverters), day0, day1, now, shadow)
|
||||
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
|
||||
runs += house_runs(len(c.inverters), day0, day1, now, shadow, off)
|
||||
if verdict is not None:
|
||||
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
|
||||
now, shadow)
|
||||
@@ -507,16 +514,57 @@ def at_zero(inv) -> bool:
|
||||
return inv.last_write is not None and abs(inv.last_write) < 1.0
|
||||
|
||||
|
||||
def _off(c, inv) -> bool:
|
||||
"""OPT-11: is this battery switched off? Only with battery modes on."""
|
||||
return bool(getattr(c, "modes_on", False)) and not c.pack_mode(inv).use
|
||||
|
||||
|
||||
def refusing(c, inv) -> int:
|
||||
"""SAFETY-52/73: the direction this battery refuses (+1 give, -1 charge,
|
||||
0 none). Per inverter at two or more, the site's own at one."""
|
||||
if len(c.inverters) > 1:
|
||||
return int(getattr(inv, "refused", 0) or 0)
|
||||
return int(getattr(c, "batt_refused", 0) or 0)
|
||||
|
||||
|
||||
def peak_engaged(c) -> bool:
|
||||
"""The peak guard asks the batteries for power this cycle. Not when it is
|
||||
blind or floor-limited: those are engaged with a 0 W floor (no charging
|
||||
only), so no battery gives power for it."""
|
||||
pg = getattr(c, "peak_guard", None)
|
||||
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0)
|
||||
|
||||
|
||||
def battery_rows(c, shadow, running, held=False):
|
||||
shares = {}
|
||||
alloc = getattr(c, "allocation", None)
|
||||
if shadow and alloc is not None and getattr(alloc, "shares", None):
|
||||
shares = dict(alloc.shares)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
pms = {inv.name: c.pack_mode(inv) for inv in c.inverters} \
|
||||
if modes_on else {}
|
||||
peak = peak_engaged(c)
|
||||
rows = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
m = c.maints[i] if i < len(c.maints) else None
|
||||
phase = m.phase if m is not None else M_IDLE
|
||||
cap = inv.capacity_kwh
|
||||
pm = pms.get(inv.name)
|
||||
off = pm is not None and not pm.use
|
||||
# UX-13 P3: does a Solar first battery that is on come before this one?
|
||||
sf_ahead = any(o.use and o.mode == SOLAR_FIRST
|
||||
for n, o in pms.items() if n != inv.name)
|
||||
# ...and does another battery refuse to give power, so this one
|
||||
# carries its share (SAFETY-73)?
|
||||
other = next((o.name for o in c.inverters if o is not inv
|
||||
and refusing(c, o) > 0 and not _off(c, o)), None)
|
||||
why = battery_why(
|
||||
modes_on=modes_on, mode=None if pm is None else pm.mode, off=off,
|
||||
reading=inv.soc is not None and inv.batt is not None,
|
||||
phase=phase, shadow=shadow, running=running, held=held,
|
||||
refused=refusing(c, inv), peak=peak,
|
||||
giving=flow(inv.batt) == "giving", other_refuses=other,
|
||||
sf_ahead=sf_ahead)
|
||||
rows.append({
|
||||
"name": inv.name,
|
||||
"level_pct": None if inv.soc is None else float(inv.soc),
|
||||
@@ -530,21 +578,79 @@ def battery_rows(c, shadow, running, held=False):
|
||||
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
|
||||
running=running, held=held,
|
||||
would_w=shares.get(inv.name),
|
||||
settled=at_zero(inv)),
|
||||
# Per-battery reasons arrive with the plan (OPT-11 P3/P4).
|
||||
"why": None,
|
||||
settled=at_zero(inv), off=off, peak=peak),
|
||||
# UX-13 P3: why it does what "now" says, from what the add-on
|
||||
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
|
||||
"why_key": why,
|
||||
"why": why_words(why, name=other),
|
||||
})
|
||||
if getattr(c, "modes_on", False):
|
||||
if modes_on:
|
||||
# OPT-11 P1: the battery's switch and mode, for the Batteries
|
||||
# screen. Only with battery modes on, so the page is unchanged
|
||||
# without them. UX-13 P3 builds the real screen on these keys.
|
||||
pm = c.pack_mode(inv)
|
||||
# without them.
|
||||
rows[-1].update({"use": pm.use, "mode_key": pm.key,
|
||||
"mode_choice": pm.mode,
|
||||
"mode": mode_word(pm.key)})
|
||||
return rows
|
||||
|
||||
|
||||
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
held, refused, peak, sf_ahead, giving=False,
|
||||
other_refuses=None) -> str:
|
||||
"""The key of one battery's "why" line (`home_words.why`), most urgent
|
||||
first. Pure, so every branch is tested without a controller."""
|
||||
if not reading:
|
||||
return "no_reading"
|
||||
if held:
|
||||
return "held"
|
||||
if not running:
|
||||
return "stopped"
|
||||
if shadow:
|
||||
return "shadow"
|
||||
if phase != M_IDLE:
|
||||
return "cycle_mode" if modes_on else "cycle"
|
||||
if off:
|
||||
# Off gives way to the peak limit only (OPT-11 design section 2).
|
||||
return "off_peak" if peak and giving else "off"
|
||||
if refused:
|
||||
return "refuses_give" if refused > 0 else "refuses_charge"
|
||||
if peak:
|
||||
return "peak"
|
||||
if giving and other_refuses:
|
||||
return "other_refuses"
|
||||
if not modes_on:
|
||||
return "modes_off"
|
||||
if giving:
|
||||
return "covers_house" # both modes cover the house until P3
|
||||
if mode == LEFTOVER_SOLAR:
|
||||
return "leftover" if sf_ahead else "leftover_alone"
|
||||
return "solar_first"
|
||||
|
||||
|
||||
def attach_today(rows, runs, *, modes_on, running=True, held=False,
|
||||
shadow=False) -> None:
|
||||
"""UX-13 P3: each battery's runs today from the plan (the same blocks as
|
||||
Home's chart), or one line saying there are none. With battery modes on
|
||||
and no run, the mode's own plan is OPT-11 P3/P4, so it says so."""
|
||||
for i, row in enumerate(rows):
|
||||
mine = [r for r in runs if r["battery"] == i and r["kind"] != "house"]
|
||||
row["today"] = [{"from": r["from"], "to": r["to"], "kind": r["kind"],
|
||||
"what": r["what"]} for r in mine]
|
||||
if mine:
|
||||
row["today_line"] = ""
|
||||
elif held:
|
||||
row["today_line"] = today_words("held")
|
||||
elif not running:
|
||||
row["today_line"] = today_words("stopped")
|
||||
elif row.get("use") is False:
|
||||
row["today_line"] = today_words("off")
|
||||
elif modes_on:
|
||||
row["today_line"] = today_words("not_planned")
|
||||
else:
|
||||
row["today_line"] = today_words("house_would" if shadow
|
||||
else "house")
|
||||
|
||||
|
||||
# Row id -> the DOCS.md heading under Notifications, where one exists.
|
||||
ATTN_DOCS = {
|
||||
"meter_frozen": messages.T_METER_FROZEN,
|
||||
@@ -640,6 +746,11 @@ def _build(c, *, level, checks, now):
|
||||
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)
|
||||
rows = battery_rows(c, shadow, running, held)
|
||||
plan = build_plan(c, now, shadow, running, held=held, settled=settled)
|
||||
attach_today(rows, plan["runs"],
|
||||
modes_on=bool(getattr(c, "modes_on", False)),
|
||||
running=running, held=held, shadow=shadow)
|
||||
return {
|
||||
"state": {"kind": kind, "pill": pill(kind),
|
||||
"ran": ctl.get("text", "") if ctl else "",
|
||||
@@ -647,9 +758,9 @@ def _build(c, *, level, checks, now):
|
||||
"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,
|
||||
settled=settled),
|
||||
"batteries": battery_rows(c, shadow, running, held),
|
||||
"plan": build_plan(c, now, shadow, running, held=held,
|
||||
settled=settled),
|
||||
"batteries": rows,
|
||||
"plan": plan,
|
||||
"modes_on": bool(getattr(c, "modes_on", False)),
|
||||
"attention": ([{"severity": INFO, "id": "shadow",
|
||||
"title": attn_shadow(), "body": attn_shadow_body(),
|
||||
"docs": None}] if shadow else []) + attention(checks),
|
||||
@@ -811,7 +922,7 @@ def tile_batt(batt_w, shadow, settled=True) -> str:
|
||||
|
||||
|
||||
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
||||
would_w=None, settled=True) -> str:
|
||||
would_w=None, settled=True, off=False, peak=False) -> str:
|
||||
if soc is None or batt_w is None:
|
||||
return _t("now.no_reading")
|
||||
zero = _zero(settled)
|
||||
@@ -828,6 +939,14 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
||||
return _t("now." + {M_DRAIN: "cycle_drain", M_CHARGE: "cycle_charge",
|
||||
M_HOLD: "cycle_hold"}.get(phase, "cycle"))
|
||||
st = flow(batt_w)
|
||||
if off:
|
||||
# OPT-11: an Off battery is asked for 0 W, but the peak limit may
|
||||
# still need it. Say which, and never "0 W" beside a power figure.
|
||||
if st == "resting":
|
||||
return _t("now.off_resting")
|
||||
if st == "giving" and peak:
|
||||
return _t("now.off_peak", kw=_kw(batt_w))
|
||||
return _t("now.off_going", kw=_kw(batt_w))
|
||||
if st == "giving":
|
||||
return _t("now.giving", kw=_kw(batt_w))
|
||||
if st == "charging":
|
||||
@@ -835,6 +954,17 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
||||
return _t("now.resting")
|
||||
|
||||
|
||||
def why_words(key, name=None) -> str:
|
||||
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
|
||||
`name`: the battery that refuses, for `other_refuses`."""
|
||||
return _t("why." + key, name=name or "")
|
||||
|
||||
|
||||
def today_words(key) -> str:
|
||||
"""One battery's "today" line when the plan has no run for it."""
|
||||
return _t("today." + key)
|
||||
|
||||
|
||||
def plan_head(key, shadow, settled=True) -> str:
|
||||
if key == "held":
|
||||
return _t("head.held" if settled else "head.held_going")
|
||||
@@ -1039,6 +1169,14 @@ def _samples() -> list:
|
||||
("plan", line_region("flanders", 2026)),
|
||||
("plan", line_region("own", None)),
|
||||
("plan", line_peak_not_covered())]
|
||||
for off in (False, True):
|
||||
for peak in (False, True):
|
||||
for w in (2500, -2500, 0, 500):
|
||||
out.append(("plan", battery_now(62, w, phase=M_IDLE,
|
||||
shadow=False, running=True,
|
||||
off=off, peak=peak)))
|
||||
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
|
||||
out += [("plan", today_words(k)) for k in words["today"]]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
|
||||
@@ -72,8 +72,9 @@ from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
|
||||
arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
|
||||
unit_state_key)
|
||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||
MqttPublisher, announced_units, mode_configs,
|
||||
optional_keys, parse_command, unit_slugs, unit_state_key)
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from . import pv as pv_read
|
||||
@@ -901,9 +902,12 @@ class Controller:
|
||||
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
|
||||
# battery's mode and on/off switch live in the store, not in options,
|
||||
# so a change applies at once (`pack_use`, `pack_mode` actions).
|
||||
self.modes_on = opts.get("battery_modes_enabled") is True
|
||||
self.modes_on = packmode.enabled(opts)
|
||||
# The units whose mode fault is logged already (edge-logged).
|
||||
self._mode_faults = set()
|
||||
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
|
||||
self._mqtt_refused = set()
|
||||
self._mqtt_refused_more = 0
|
||||
# [(pack name, why)] for packs that are due and waiting their turn.
|
||||
# Empty at a roster of one, where nothing can defer anything.
|
||||
self._maint_deferred = []
|
||||
@@ -2053,6 +2057,65 @@ class Controller:
|
||||
pm = self.pack_mode(inv)
|
||||
return {"unit": inv.name, "use": pm.use, "mode": pm.mode}
|
||||
|
||||
def mode_values(self) -> dict:
|
||||
"""OPT-11 P2: each battery's mode and switch, for their HA state
|
||||
topics. Read from the store, so they are right after a restart. Empty
|
||||
while battery modes are off."""
|
||||
if not self.modes_on:
|
||||
return {}
|
||||
out = {}
|
||||
names = [inv.name for inv in self.inverters]
|
||||
for inv, slug in zip(self.inverters, unit_slugs(names)):
|
||||
pm = self.pack_mode(inv)
|
||||
out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode,
|
||||
pm.mode)
|
||||
out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF
|
||||
return out
|
||||
|
||||
async def mqtt_command(self, topic, payload, retained) -> None:
|
||||
"""OPT-11 P2: one command from a battery's HA switch or mode list.
|
||||
|
||||
It goes through handle_action(), the page's /action path, so the
|
||||
checks and the store write are the same. A retained command (one the
|
||||
broker replays on subscribe, e.g. after a restart) is never applied:
|
||||
the store already holds the last choice. A refusal is logged once per
|
||||
topic and reason, at most 32, and further refusals are counted in the
|
||||
log at 1, 10, 100 and so on. Never raises: MQTT must not reach control.
|
||||
"""
|
||||
try:
|
||||
slug, action, value, why = parse_command(
|
||||
topic, payload, retained, packmode.MODES)
|
||||
if why is None:
|
||||
names = [inv.name for inv in self.inverters]
|
||||
unit = dict(zip(unit_slugs(names), names)).get(slug)
|
||||
result = await self.handle_action(action, unit=unit,
|
||||
value=value)
|
||||
if not (isinstance(result, dict) and "error" in result):
|
||||
self.mqtt.publish(self.mode_values())
|
||||
return
|
||||
why = result["error"]
|
||||
else:
|
||||
why = messages.pack_refused(why)
|
||||
# Printable only: a topic is sender-chosen, so no log injection.
|
||||
topic_s = "".join(c if c.isprintable() else "?"
|
||||
for c in str(topic)[:128])
|
||||
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
|
||||
repr(payload)[:48], why)
|
||||
if (topic_s, why) in self._mqtt_refused \
|
||||
or len(self._mqtt_refused) >= 32:
|
||||
# Not logged again, but counted: 1, 10, 100... stay visible.
|
||||
self._mqtt_refused_more += 1
|
||||
more = self._mqtt_refused_more
|
||||
if str(more).rstrip("0") == "1":
|
||||
_LOG.warning("Home Assistant commands still ignored: %d "
|
||||
"more so far.", more)
|
||||
return
|
||||
self._mqtt_refused.add((topic_s, why))
|
||||
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
|
||||
why)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.exception("Home Assistant command could not be handled")
|
||||
|
||||
def _maint_stop_off(self, machine, now) -> None:
|
||||
"""OPT-11: stop this battery's maintenance cycle because it is off."""
|
||||
if machine.phase == IDLE:
|
||||
@@ -5144,6 +5207,8 @@ class Controller:
|
||||
values[unit_state_key(slug, "soc")] = inv.soc
|
||||
values[unit_state_key(slug, "sign_latch")] = (
|
||||
"tripped" if inv.sign_wd.tripped else "clear")
|
||||
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
|
||||
values.update(self.mode_values())
|
||||
self.mqtt.publish(values)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
@@ -6059,6 +6124,21 @@ class ControlTaskDied(RuntimeError):
|
||||
"""The control task ended while nothing had asked it to stop."""
|
||||
|
||||
|
||||
def mode_announcement(opts, store) -> tuple:
|
||||
"""OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up.
|
||||
|
||||
The config topics announced are kept in the store, so turning modes off
|
||||
removes exactly those (`mqtt.mode_configs()`). An install that never
|
||||
turned modes on gets nothing, and its store is not written.
|
||||
"""
|
||||
modes = packmode.MODES if packmode.enabled(opts) else ()
|
||||
stored = store.data.get(MODE_CONFIGS_KEY)
|
||||
keep, retract = mode_configs(roster_names(opts), modes, stored)
|
||||
if keep and keep != stored:
|
||||
store.set(MODE_CONFIGS_KEY, keep)
|
||||
return modes, retract
|
||||
|
||||
|
||||
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
||||
died: list) -> None:
|
||||
"""Done-callback on the control task: log its death now and wake amain()
|
||||
@@ -6189,6 +6269,9 @@ async def amain() -> None:
|
||||
# took the control loop down with it.
|
||||
broker = None
|
||||
try:
|
||||
# OPT-11 P2: each battery's switch and mode list, only while
|
||||
# battery modes are on (`mode_announcement()`).
|
||||
_modes, _retract = mode_announcement(opts, store)
|
||||
broker = await hass.mqtt_service()
|
||||
pub = MqttPublisher(
|
||||
broker.get("host") if broker else None,
|
||||
@@ -6208,6 +6291,8 @@ async def amain() -> None:
|
||||
# is what `build_roster()` itself reads. At a roster of one this
|
||||
# announces nothing new - see `announced_units()`.
|
||||
units=roster_names(opts),
|
||||
modes=_modes,
|
||||
retract=_retract,
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
||||
@@ -6221,6 +6306,8 @@ async def amain() -> None:
|
||||
|
||||
stop = asyncio.Event()
|
||||
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||
if callable(getattr(pub, "bind", None)):
|
||||
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
|
||||
runner = await web.start(controller, port=INGRESS_PORT)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -932,6 +932,11 @@ PACK_REFUSED = {
|
||||
"off": "Battery modes are off. Turn on “Battery modes” first.",
|
||||
"unit": "There is no battery with that name.",
|
||||
"value": "That choice is not possible for this battery.",
|
||||
# OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
|
||||
"retained": "An old saved command is not used.",
|
||||
"size": "That command is too long.",
|
||||
"encoding": "That command is not plain text.",
|
||||
"topic": "That is not a battery command.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ That separation is deliberate; observability must never be able to take down
|
||||
control.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
@@ -375,7 +377,26 @@ def _payload(name, object_id, unique_id, state_topic, device,
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
def unit_unique_id(slug: str, key: str) -> str:
|
||||
"""One unit's entity `unique_id` (also its config topic's middle part)."""
|
||||
return f"{BASE}_unit_{slug}_{key}"
|
||||
|
||||
|
||||
def _unit_places(names):
|
||||
"""`(slug, device, prefix)` for each roster name, in order."""
|
||||
slugs = unit_slugs(names)
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
yield slug, device, prefix
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
@@ -389,6 +410,8 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
|
||||
ordered LIST precisely because a re-order is a zero-byte edit that
|
||||
re-announces every entity on a live install).
|
||||
|
||||
`modes` (OPT-11 P2) is the battery modes offered, empty when they are off.
|
||||
"""
|
||||
omit = set(omit)
|
||||
include = [k for k in include if k not in omit]
|
||||
@@ -400,19 +423,10 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
names = announced_units(units)
|
||||
slugs = unit_slugs(names)
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` below can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
for slug, device, prefix in _unit_places(announced_units(units)):
|
||||
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
||||
unique_id = f"{BASE}_unit_{slug}_{key}"
|
||||
unique_id = unit_unique_id(slug, key)
|
||||
out.append((
|
||||
f"homeassistant/sensor/{unique_id}/config",
|
||||
_payload(prefix + label,
|
||||
@@ -427,12 +441,134 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
# OPT-11 P2: last, and only when battery modes are on.
|
||||
out.extend(mode_payloads(units, modes))
|
||||
return out
|
||||
|
||||
|
||||
# --- OPT-11 P2: each battery's switch and mode, as HA entities ---------------
|
||||
# Announced only when battery modes are on, after every other row, so no
|
||||
# existing row moves. A command goes through Controller.handle_action(), the
|
||||
# page's /action path. DECISIONS/OPT-11-battery-modes.md has the record.
|
||||
#
|
||||
# The select's options are these labels, in `packmode.MODES` order. A new mode
|
||||
# (P3) is a new entry here and a new option at the end; no id changes.
|
||||
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar"}
|
||||
USE_ON, USE_OFF = "ON", "OFF"
|
||||
# A longer command payload is refused before it is decoded.
|
||||
MAX_COMMAND_BYTES = 64
|
||||
# The store key holding the mode config topics last announced, so that turning
|
||||
# modes off can remove exactly those (an empty retained config) and an install
|
||||
# that never turned them on publishes nothing new.
|
||||
MODE_CONFIGS_KEY = "mqtt_mode_configs"
|
||||
_MODE_CONFIG_RE = re.compile(
|
||||
rf"homeassistant/(select|switch)/{BASE}_unit_[a-z0-9_]+_(mode|use)/config")
|
||||
_COMMAND_RE = re.compile(rf"{BASE}/unit/([a-z0-9_]+)/(mode|use)/set")
|
||||
# (entity key, HA component, entity name, icon)
|
||||
MODE_ENTITIES = [
|
||||
("mode", "select", "Mode", "mdi:battery-sync"),
|
||||
("use", "switch", "Use", "mdi:power"),
|
||||
]
|
||||
|
||||
|
||||
def mode_payloads(units, modes) -> list:
|
||||
"""The `(topic, payload)` for each battery's select and switch.
|
||||
|
||||
Every roster entry gets them, also at a roster of one. The device rule is
|
||||
TEL-07's: the first battery's rows go on the site device with its name in
|
||||
front, each later battery's on its own device. Empty when `modes` is empty.
|
||||
"""
|
||||
if not modes:
|
||||
return []
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
options = [MODE_LABELS[m] for m in modes]
|
||||
out = []
|
||||
for slug, device, prefix in _unit_places([str(u) for u in units]):
|
||||
for key, component, label, icon in MODE_ENTITIES:
|
||||
unique_id = unit_unique_id(slug, key)
|
||||
state = f"{BASE}/{unit_state_key(slug, key)}"
|
||||
cfg = {
|
||||
"name": prefix + label,
|
||||
"object_id": f"{site_slug}_{slug}_{key}",
|
||||
"unique_id": unique_id,
|
||||
"state_topic": state,
|
||||
"command_topic": f"{state}/set",
|
||||
}
|
||||
if component == "select":
|
||||
cfg["options"] = options
|
||||
else:
|
||||
cfg["payload_on"] = USE_ON
|
||||
cfg["payload_off"] = USE_OFF
|
||||
# HA sends commands unretained; a retained one is ignored anyway.
|
||||
cfg["retain"] = False
|
||||
cfg["availability_topic"] = AVAILABILITY
|
||||
cfg["device"] = device
|
||||
cfg["icon"] = icon
|
||||
out.append((f"homeassistant/{component}/{unique_id}/config", cfg))
|
||||
return out
|
||||
|
||||
|
||||
def mode_retractions(stored, current) -> list:
|
||||
"""Config topics announced before and not now: each gets an empty
|
||||
retained config, which removes the entity from HA. Anything in the store
|
||||
that is not one of our mode config topics is ignored."""
|
||||
if not isinstance(stored, list):
|
||||
return []
|
||||
keep = set(current)
|
||||
return list(dict.fromkeys(t for t in stored if isinstance(t, str)
|
||||
and _MODE_CONFIG_RE.fullmatch(t)
|
||||
and t not in keep))
|
||||
|
||||
|
||||
def mode_configs(units, modes, stored) -> tuple:
|
||||
"""`(keep, retract)` at start-up: the mode config topics to store, and the
|
||||
ones to remove now. A removal stays in `keep`, so a start with no broker
|
||||
does not forget it; resending an empty config is harmless."""
|
||||
current = [t for t, _ in mode_payloads(units, modes)]
|
||||
retract = mode_retractions(stored, current)
|
||||
return current + retract, retract
|
||||
|
||||
|
||||
def parse_command(topic, payload, retained, modes):
|
||||
"""`(slug, action, value, reject)` for one message on a command topic.
|
||||
|
||||
`action` is `pack_use` or `pack_mode`. `value` is what /action would be
|
||||
sent: a bool for the switch, a mode key for the select, or None for a
|
||||
payload that is neither (the store path then refuses it). `reject` is a
|
||||
reason when the message is dropped here: replayed by the broker (retained),
|
||||
too long, not UTF-8, or not a command topic.
|
||||
"""
|
||||
m = _COMMAND_RE.fullmatch(topic) if isinstance(topic, str) else None
|
||||
if m is None:
|
||||
return None, None, None, "topic"
|
||||
slug, key = m.group(1), m.group(2)
|
||||
action = "pack_use" if key == "use" else "pack_mode"
|
||||
if retained:
|
||||
return slug, action, None, "retained"
|
||||
if not isinstance(payload, (bytes, bytearray)) \
|
||||
or len(payload) > MAX_COMMAND_BYTES:
|
||||
return slug, action, None, "size"
|
||||
try:
|
||||
text = bytes(payload).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return slug, action, None, "encoding"
|
||||
if key == "use":
|
||||
value = {USE_ON: True, USE_OFF: False}.get(text)
|
||||
else:
|
||||
value = next((k for k in modes if MODE_LABELS.get(k) == text), None)
|
||||
return slug, action, value, None
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
# OPT-11 P2. Class defaults, so a publisher built without them (and every
|
||||
# test fixture that builds one with __new__) behaves as before.
|
||||
modes = ()
|
||||
retract = ()
|
||||
_loop = None
|
||||
_handler = None
|
||||
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=(), include=()):
|
||||
units=(), include=(), modes=(), retract=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
@@ -448,6 +584,10 @@ class MqttPublisher:
|
||||
# second reading of the option. Defaults to empty, so a caller that has
|
||||
# not been taught about it announces exactly what it announced before.
|
||||
self.include = [str(k) for k in include]
|
||||
# OPT-11 P2: the modes offered (empty when battery modes are off), and
|
||||
# the mode config topics to remove (`mode_retractions()`).
|
||||
self.modes = tuple(modes)
|
||||
self.retract = [str(t) for t in retract]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -470,7 +610,8 @@ class MqttPublisher:
|
||||
# paho DROPS QoS-0 publishes when disconnected, silently. The result is
|
||||
# an add-on that logs "MQTT connected" and creates no entities at all.
|
||||
# As a bonus, this also re-announces after every reconnect.
|
||||
self.client.on_connect = lambda *_args, **_kw: self._announce()
|
||||
self.client.on_connect = lambda *_args, **_kw: self._on_connect()
|
||||
self.client.on_message = self._on_message
|
||||
try:
|
||||
self.client.connect(host, int(port), keepalive=60)
|
||||
self.client.loop_start()
|
||||
@@ -485,10 +626,35 @@ class MqttPublisher:
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit,
|
||||
self.include):
|
||||
self.include, self.modes):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
for topic in self.retract:
|
||||
self.client.publish(topic, "", retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
def _on_connect(self) -> None:
|
||||
self._announce()
|
||||
if self.modes:
|
||||
for key in ("mode", "use"):
|
||||
self.client.subscribe(f"{BASE}/unit/+/{key}/set")
|
||||
|
||||
def bind(self, loop, handler) -> None:
|
||||
"""OPT-11 P2: hand each command to `handler(topic, payload, retained)`,
|
||||
a coroutine function run on `loop` (the controller's)."""
|
||||
self._loop, self._handler = loop, handler
|
||||
|
||||
def _on_message(self, _client, _userdata, msg) -> None:
|
||||
# paho's network thread: nothing is done here but the handoff.
|
||||
loop, handler = self._loop, self._handler
|
||||
if loop is None or handler is None:
|
||||
_LOG.debug("MQTT command before start-up ignored: %s", msg.topic)
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
handler(msg.topic, bytes(msg.payload), bool(msg.retain)), loop)
|
||||
except RuntimeError as err: # the loop has closed
|
||||
_LOG.debug("MQTT command dropped: %s", err)
|
||||
|
||||
def publish(self, values: dict) -> None:
|
||||
if not self.enabled or self.client is None:
|
||||
return
|
||||
|
||||
@@ -42,6 +42,12 @@ MODE_KEY = "pack_mode"
|
||||
SELL_KEY = "pack_sell" # reserved for P3
|
||||
|
||||
|
||||
def enabled(opts) -> bool:
|
||||
"""Battery modes are on only when the option is exactly true. One answer,
|
||||
read by the controller and by the MQTT announcement."""
|
||||
return opts.get("battery_modes_enabled") is True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackMode:
|
||||
"""One battery's choice: on or off, its mode, and (P3) selling."""
|
||||
|
||||
@@ -228,3 +228,19 @@ input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums}
|
||||
.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}
|
||||
/* UX-13 P3: the Batteries screen and the Simple settings view */
|
||||
.bcard .hd{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
||||
.bcard .name{font-weight:700;font-size:16px}
|
||||
.bcard .modebox .lbl{display:block;margin-bottom:4px}
|
||||
.bcard .modebox .help{font-size:13px;color:var(--muted);margin-top:4px}
|
||||
.bcard.off .modebox{opacity:.6}
|
||||
.bcard .kv dd{overflow-wrap:anywhere}
|
||||
.switch:disabled{opacity:.45;cursor:not-allowed}
|
||||
select.sel:disabled{opacity:.6}
|
||||
.simple .card>.row{margin-bottom:4px}
|
||||
.vhide{display:none!important}
|
||||
.simple .card>.f{border-top:0;padding-top:0}
|
||||
.simple .f>.head{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.simple .card .lbl{font-weight:600;font-size:15px}
|
||||
.simple .inv .hd{border-bottom:0}
|
||||
.simple .inv .f{border-top:0;padding-top:0}
|
||||
|
||||
@@ -78,10 +78,12 @@
|
||||
if (!r.ok) throw new Error("action " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function refresh() {
|
||||
// `force`: redraw the battery cards even under a focused mode list, after
|
||||
// the person used it (P3).
|
||||
async function refresh(force) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { S = await getStatus(); render(); }
|
||||
try { S = await getStatus(); render(force === true); }
|
||||
catch (e) { offline(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
@@ -104,7 +106,7 @@
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
}
|
||||
|
||||
function render() {
|
||||
function render(force) {
|
||||
renderPill();
|
||||
const structured = S.now && S.plan && !S.home_error;
|
||||
// UX-15: setup-only mode commands nothing and has no control to offer.
|
||||
@@ -114,7 +116,8 @@
|
||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
renderControl(structured, setup);
|
||||
renderDetails(!structured && !setup);
|
||||
renderBatteries();
|
||||
renderBatteries(force);
|
||||
document.dispatchEvent(new CustomEvent("ux13:status"));
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
@@ -308,50 +311,112 @@
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries (placeholder until UX-13 P3) ---------------- */
|
||||
// OPT-11 P1: with battery modes on, each card gets an On/Off switch and a
|
||||
// mode choice (`mode_key` in /status). UX-13 P3 replaces these cards.
|
||||
/* ---------------- Batteries (UX-13 P3) ---------------- */
|
||||
// One card per battery. With battery modes on (`mode_key` in /status) each
|
||||
// card has an On/Off switch and a mode list, sent as `pack_use` and
|
||||
// `pack_mode` through /action (OPT-11 P1). Selling and cheap-hours charging
|
||||
// are shown greyed out until OPT-11 P3. The Now, Today and Why lines are
|
||||
// homeview.py's sentences. The Simple settings view draws the same switch
|
||||
// and list into its own card (simpleHost).
|
||||
const MODES = ["solar_first", "leftover_solar"];
|
||||
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
|
||||
let packBusy = false;
|
||||
async function packAct(what, unit, value) {
|
||||
let simpleHost = null;
|
||||
async function packAct(what, b, value) {
|
||||
if (packBusy) return;
|
||||
packBusy = true;
|
||||
try {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what, unit: unit, value: value }) });
|
||||
body: JSON.stringify({ action: what, unit: b.name, value: value }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
toast(r.ok ? t("toast.done") : (j.error || t("toast.failed")));
|
||||
if (!r.ok) toast(j.error || t("toast.failed"));
|
||||
else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name }));
|
||||
else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
finally { packBusy = false; }
|
||||
refresh();
|
||||
await refresh(true);
|
||||
}
|
||||
function modeControls(b) {
|
||||
const sw = h("button", { class: "btn" + (b.use ? " primary" : ""), type: "button", role: "switch",
|
||||
"aria-checked": String(b.use), "aria-label": b.name + ": " + t("batteries.use"),
|
||||
onclick: () => packAct("pack_use", b.name, !b.use) },
|
||||
b.use ? t("batteries.on") : t("batteries.off"));
|
||||
const sel = h("select", { class: "sel", "aria-label": b.name + ": " + t("batteries.mode"), disabled: !b.use,
|
||||
onchange: (e) => packAct("pack_mode", b.name, e.target.value) },
|
||||
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
|
||||
return [h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.use")), sw),
|
||||
h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.mode")), sel),
|
||||
h("p", { class: "tiny muted", style: "margin:6px 0 0" }, t("batteries.choice." + b.mode_key + ".description"))];
|
||||
function onSwitch(b, i, where, lid) {
|
||||
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
|
||||
"aria-checked": String(b.use), "aria-labelledby": lid,
|
||||
onclick: () => packAct("pack_use", b, !b.use) });
|
||||
return h("span", { class: "swrap" }, sw, h("span", { class: "swtext", "aria-hidden": "true" }, b.use ? t("batteries.on") : t("batteries.off")));
|
||||
}
|
||||
function renderBatteries() {
|
||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
// Do not rebuild a card under an open mode list or a request in flight.
|
||||
if (packBusy || ($("#bcards").contains(document.activeElement) && document.activeElement.tagName === "SELECT")) return;
|
||||
const modes = S.batteries.some((b) => b.mode_key != null);
|
||||
$("#batt-desc").textContent = t(modes ? "batteries.with_modes.description" : "batteries.description");
|
||||
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
|
||||
function modeSelect(b, i, where, lid) {
|
||||
return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use,
|
||||
onchange: (e) => packAct("pack_mode", b, e.target.value) },
|
||||
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))),
|
||||
h("option", { value: "", disabled: true }, t("batteries.later")));
|
||||
}
|
||||
function sellRow(i, where) {
|
||||
const lid = "bsl-" + where + i;
|
||||
return h("div", { class: "f inline" },
|
||||
h("div", {}, h("div", { class: "lbl", id: lid }, t("batteries.sell.name")), h("div", { class: "help" }, t("batteries.sell.description"))),
|
||||
h("div", { class: "ctl" }, h("button", { type: "button", class: "switch", role: "switch", "aria-checked": "false",
|
||||
"aria-labelledby": lid, disabled: true })));
|
||||
}
|
||||
function todayLines(b) {
|
||||
if (!b.today || !b.today.length) return b.today_line || "–";
|
||||
return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what));
|
||||
}
|
||||
function batteryCard(b, i) {
|
||||
const modes = b.mode_key != null;
|
||||
const lid = "bname-" + i;
|
||||
const lvl = b.level_pct == null ? "–" : Math.round(b.level_pct) + " %";
|
||||
const stored = b.stored_kwh != null ? " · " + dec(b.stored_kwh.toFixed(1)) + " kWh " + t("batteries.stored").toLowerCase() : "";
|
||||
return h("div", { class: "card bcard" + (modes && !b.use ? " off" : "") },
|
||||
h("div", { class: "hd" },
|
||||
h("div", { class: "grow" }, h("div", { class: "name", id: lid }, b.name),
|
||||
h("div", { class: "tiny muted" }, b.capacity_kwh ? dec(Number(b.capacity_kwh).toFixed(1)) + " kWh" : "")),
|
||||
modes ? onSwitch(b, i, "c", lid) : null),
|
||||
modes ? h("div", { class: "modebox" },
|
||||
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
|
||||
modeSelect(b, i, "c", null),
|
||||
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description")),
|
||||
sellRow(i, "c")) : null,
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("dl", { class: "kv" },
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, dec(b.stored_kwh.toFixed(1)) + " kWh")] : null,
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
b.mode_key == null ? [h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)] : null),
|
||||
b.mode_key != null ? modeControls(b) : null)));
|
||||
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
|
||||
}
|
||||
function simpleRow(b, i) {
|
||||
const lid = "bsname-" + i;
|
||||
return h("div", { class: "inv" },
|
||||
h("div", { class: "hd" }, h("span", { class: "grow", id: lid }, b.name), onSwitch(b, i, "s", lid)),
|
||||
h("div", { class: "f" }, h("label", { class: "lbl", for: "bsel-s" + i }, t("batteries.mode")),
|
||||
h("div", { class: "ctl" }, modeSelect(b, i, "s", null)),
|
||||
h("div", { class: "help" }, b.now + " " + (b.why || ""))));
|
||||
}
|
||||
// Rebuild without losing the focused control, and never under an open list
|
||||
// or a request in flight.
|
||||
function keepFocus(fn) {
|
||||
const id = document.activeElement && document.activeElement.id;
|
||||
fn();
|
||||
if (id && /^bs(w|el)-/.test(id)) { const e = document.getElementById(id); if (e) e.focus(); }
|
||||
}
|
||||
// The modes-off card's button opens Settings on the Installer view, where
|
||||
// the "Battery modes" switch is (settings.js sets window.UX13.settingsView).
|
||||
$("#batt-off a").addEventListener("click", () => { if (window.UX13.settingsView) window.UX13.settingsView("installer"); });
|
||||
function renderBatteries(force) {
|
||||
if (!S || !S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
const a = document.activeElement;
|
||||
if (!force && (packBusy || (a && a.tagName === "SELECT" && /^bsel-/.test(a.id)))) return;
|
||||
const modes = S.modes_on === true || S.batteries.some((b) => b.mode_key != null);
|
||||
$("#batt-off").hidden = modes;
|
||||
$("#batt-how").hidden = !modes;
|
||||
const docs = $("#batt-docs");
|
||||
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
else $("#batt-banner").replaceChildren();
|
||||
keepFocus(() => {
|
||||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||||
if (simpleHost && simpleHost.isConnected) {
|
||||
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
|
||||
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- control buttons ---------------- */
|
||||
@@ -385,7 +450,10 @@
|
||||
// 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, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
|
||||
window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash,
|
||||
// P3: the Simple settings view's battery card, drawn from /status.
|
||||
batteries: (host) => { simpleHost = host; if (S) renderBatteries(); refresh(); },
|
||||
status: () => S, kw };
|
||||
|
||||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||
const TABS = [...document.querySelectorAll(".tab")];
|
||||
|
||||
@@ -94,11 +94,23 @@
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
||||
<div id="bcards"></div>
|
||||
<div class="card">
|
||||
<p class="small muted" style="margin:0 0 10px" id="batt-desc" data-t="batteries.description"></p>
|
||||
<div id="batt-banner"></div>
|
||||
<div class="card" id="batt-off" hidden>
|
||||
<h2 data-t="batteries.modes_off.name"></h2>
|
||||
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
|
||||
<a class="btn" href="#settings" data-t="batteries.settings"></a>
|
||||
</div>
|
||||
<div id="bcards"></div>
|
||||
<details class="grp" id="batt-how" hidden>
|
||||
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">›</span></summary>
|
||||
<div class="body small">
|
||||
<p data-t="batteries.how.solar_first.description"></p>
|
||||
<p data-t="batteries.how.leftover_solar.description"></p>
|
||||
<p data-t="batteries.how.off.description"></p>
|
||||
<p class="muted" data-t="batteries.how.rules.description"></p>
|
||||
<p><a id="batt-docs" hidden target="_top" rel="noopener" data-t="control.docs"></a></p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
|
||||
@@ -106,8 +118,15 @@
|
||||
<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 class="row" style="margin-bottom:8px">
|
||||
<div class="seg" role="radiogroup" id="set-view" data-s-label="view.name">
|
||||
<button type="button" role="radio" aria-checked="true" data-v="simple" data-s="view.simple"></button>
|
||||
<button type="button" role="radio" aria-checked="false" data-v="installer" tabindex="-1" data-s="view.installer"></button>
|
||||
</div>
|
||||
<span class="tiny muted grow" id="set-desc"></span>
|
||||
</div>
|
||||
<div id="set-banner" aria-live="polite"></div>
|
||||
<div id="simple" class="simple" hidden></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>
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
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
|
||||
// P3: "simple" (the default, for home owners) or "installer". Remembered in
|
||||
// this browser only; a blocked storage just means Simple each time.
|
||||
let mode = "simple";
|
||||
try { if (localStorage.getItem("ux13.settings.view") === "installer") mode = "installer"; } catch (e) { /* no storage */ }
|
||||
let uid = 0;
|
||||
const ROWS = [];
|
||||
const OPEN = new Set();
|
||||
@@ -337,7 +341,7 @@
|
||||
function fieldRow(f, card, gid) {
|
||||
if (f.control === "inverter_list") return cardList(f, gid);
|
||||
const lid = "sf" + (++uid);
|
||||
const keys = keyText(f, card);
|
||||
const keys = gid === "simple" ? "" : keyText(f, card); // Simple shows no keys
|
||||
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 },
|
||||
@@ -421,10 +425,81 @@
|
||||
d.append(body);
|
||||
host.append(d);
|
||||
}
|
||||
renderSimple();
|
||||
paint();
|
||||
window.scrollTo(0, y);
|
||||
}
|
||||
|
||||
/* ---------------- Settings, Simple (P3) ---------------- */
|
||||
// The fields the layout marks `simple`, each in its own card with when it
|
||||
// applies, and the batteries' switches and modes (runtime state, applied at
|
||||
// once) drawn by app.js from /status. The Installer rows stay built but
|
||||
// hidden, so the save bar counts every unsaved change in either view.
|
||||
const chip = (applies) => h("span", { class: "chip " + (applies === "now" ? "now" : "rs") }, ROOT.applies[applies].name);
|
||||
// A note with {kw} is filled from /status (app.js sends ux13:status).
|
||||
function fillNotes() {
|
||||
const st = window.UX13.status();
|
||||
const mp = st && st.month_peak;
|
||||
document.querySelectorAll("#simple [data-kw-note]").forEach((e) => {
|
||||
const ok = mp && mp.peak_w != null;
|
||||
e.hidden = !ok;
|
||||
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
|
||||
});
|
||||
}
|
||||
document.addEventListener("ux13:status", fillNotes);
|
||||
function simpleField(f) {
|
||||
const row = fieldRow(f, null, "simple");
|
||||
row.querySelector(".head").append(chip(f.applies));
|
||||
const n = f.note && W.simple.note[f.note];
|
||||
if (n && n.description.includes("{kw}")) {
|
||||
row.append(h("div", { class: "small muted", "data-kw-note": n.description, hidden: true }));
|
||||
} else if (n) {
|
||||
// A plain note replaces the option's installer help on this view.
|
||||
const help = row.querySelector(".help");
|
||||
if (help) help.textContent = n.description;
|
||||
}
|
||||
return h("div", { class: "card" }, row);
|
||||
}
|
||||
function renderSimple() {
|
||||
const host = $("#simple");
|
||||
host.replaceChildren();
|
||||
if (mode !== "simple") return;
|
||||
const cards = allFields().filter((f) => f.simple).map(simpleField);
|
||||
const batts = h("div", {});
|
||||
cards.splice(Math.min(1, cards.length), 0, h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("div", { class: "lbl" }, W.simple.batteries.name), chip("now")), batts));
|
||||
host.append(...cards, h("p", { class: "small muted", style: "margin:4px 2px" }, W.simple.more.description + " ",
|
||||
h("button", { type: "button", class: "btn link", onclick: () => setMode("installer", true) }, W.simple.installer)));
|
||||
window.UX13.batteries(batts);
|
||||
fillNotes();
|
||||
}
|
||||
// `remember`: only the person's own pick of a view is kept for next time;
|
||||
// a jump to Installer (a link, a setting to fix) is not.
|
||||
function setMode(v, redraw, remember) {
|
||||
mode = v;
|
||||
if (remember) try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ }
|
||||
document.querySelectorAll("#set-view [role=radio]").forEach((b) => {
|
||||
const on = b.dataset.v === v;
|
||||
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
|
||||
});
|
||||
$("#set-desc").textContent = v === "simple" ? W.simple.description : W.description;
|
||||
$("#simple").hidden = v !== "simple";
|
||||
["#set-search", "#groups", "#set-nomatch"].forEach((id) => { $(id).classList.toggle("vhide", v === "simple"); });
|
||||
if (redraw && M && view === "settings") render();
|
||||
}
|
||||
document.querySelectorAll("#set-view [role=radio]").forEach((b, i, all) => {
|
||||
b.addEventListener("click", () => setMode(b.dataset.v, true, true));
|
||||
b.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
const j = all[(i + d + all.length) % all.length];
|
||||
setMode(j.dataset.v, true, true); j.focus();
|
||||
});
|
||||
});
|
||||
setMode(mode, false);
|
||||
window.UX13.settingsView = (v) => setMode(v, true);
|
||||
|
||||
/* ---------------- messages, visibility, search, badges ---------------- */
|
||||
const ICON = { bad: "×", warn: "!", info: "i" };
|
||||
const RANK = { bad: 0, warn: 1, info: 2 };
|
||||
@@ -434,11 +509,13 @@
|
||||
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||
}
|
||||
function paint() {
|
||||
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||
const q = view === "setup" || mode === "simple" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||
// A message counts as shown only beside a row of the view on screen.
|
||||
const onScreen = (r) => view === "setup" || (mode === "simple") === (r.gid === "simple");
|
||||
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; })
|
||||
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible && onScreen(r)) 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 },
|
||||
@@ -562,7 +639,9 @@
|
||||
validate();
|
||||
}
|
||||
function firstBad() {
|
||||
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
|
||||
if (view === "settings" && mode === "simple" && !ROWS.some((x) => x.gid === "simple" && x.tiers && x.tiers.has("bad") && x.visible)
|
||||
&& ROWS.some((x) => x.tiers && x.tiers.has("bad") && x.visible)) setMode("installer", true);
|
||||
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible && (view !== "settings" || (mode === "simple") === (x.gid === "simple")));
|
||||
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" });
|
||||
|
||||
@@ -84,13 +84,22 @@
|
||||
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
|
||||
"setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
|
||||
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
|
||||
"with_modes": {"description": "Each battery follows its mode, after the peak limit and safety stops."},
|
||||
"use": "Use this battery", "on": "On", "off": "Off",
|
||||
"on": "On", "off": "Off",
|
||||
"choice": {
|
||||
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
|
||||
"leftover_solar": {"name": "Leftover solar", "description": "Charges from solar the other batteries cannot take, and covers the house."},
|
||||
"off": {"name": "Off", "description": "The add-on asks this battery for 0 W, unless the peak limit needs it."}
|
||||
}},
|
||||
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
|
||||
},
|
||||
"later": "Cheap hours only, coming later",
|
||||
"today": "Today", "why": "Why",
|
||||
"sell": {"name": "Sell in expensive hours", "description": "Selling comes in a later update."},
|
||||
"modes_off": {"name": "Battery modes are off"},
|
||||
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
|
||||
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and also covers the house."},
|
||||
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
|
||||
"rules": {"description": "The peak limit and the safety stops always come first, and charging from the grid and selling come later."}},
|
||||
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
|
||||
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
|
||||
},
|
||||
"choices_later": {
|
||||
@@ -107,7 +116,13 @@
|
||||
},
|
||||
"settings": {
|
||||
"name": "Settings",
|
||||
"description": "Every setting, grouped by task, and the simple view follows in a later update.",
|
||||
"description": "Every setting, grouped by task.",
|
||||
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
|
||||
"simple": {"description": "The choices a home owner makes.",
|
||||
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}},
|
||||
"batteries": {"name": "Batteries"},
|
||||
"more": {"description": "Looking for the meter, inverters or prices?"},
|
||||
"installer": "Open the installer view"},
|
||||
"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."},
|
||||
@@ -223,7 +238,6 @@
|
||||
"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"},
|
||||
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
|
||||
@@ -290,7 +304,37 @@
|
||||
"cycle": "Monthly cycle under way.",
|
||||
"giving": "Giving {kw} now.",
|
||||
"charging": "Charging {kw} now.",
|
||||
"resting": "Resting now."
|
||||
"resting": "Resting now.",
|
||||
"off_resting": "Off. Resting now.",
|
||||
"off_peak": "Off, but gives {kw} for the peak limit.",
|
||||
"off_going": "Off. Still at {kw}, going to 0 W."
|
||||
},
|
||||
"why": {
|
||||
"no_reading": "No reading, so the add-on cannot tell.",
|
||||
"held": "An alarm stopped control. See Needs attention.",
|
||||
"stopped": "Control is stopped.",
|
||||
"shadow": "Shadow mode: the add-on only shows what it would do.",
|
||||
"cycle": "The monthly cycle keeps its level reading right.",
|
||||
"cycle_mode": "The monthly cycle comes first. Its mode resumes after.",
|
||||
"off": "You switched it off.",
|
||||
"off_peak": "The peak limit comes before Off. It rests again after.",
|
||||
"refuses_give": "The battery refuses to give power. The add-on tries again every 15 minutes.",
|
||||
"refuses_charge": "The battery refuses to charge. The add-on tries again every 15 minutes.",
|
||||
"peak": "The peak limit needs power from the batteries.",
|
||||
"modes_off": "Battery modes are off, so it follows the house.",
|
||||
"solar_first": "Solar first: it gets spare solar before the others.",
|
||||
"leftover": "It gets the solar that Solar first batteries cannot take.",
|
||||
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
|
||||
"covers_house": "It gives power to cover the house.",
|
||||
"other_refuses": "{name} refuses to give power, so this battery takes over."
|
||||
},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"off": "Off, so no monthly cycle today.",
|
||||
"house": "Follows the house.",
|
||||
"house_would": "Would follow the house.",
|
||||
"stopped": "Nothing planned while control is stopped.",
|
||||
"held": "Nothing planned until the alarm clears."
|
||||
},
|
||||
"head": {
|
||||
"held": "Every inverter is at 0 W until the alarm clears.",
|
||||
|
||||
@@ -81,13 +81,22 @@
|
||||
"offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."},
|
||||
"setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"},
|
||||
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
|
||||
"with_modes": {"description": "Elke batterij volgt haar modus, na de piekgrens en de veiligheidsstops."},
|
||||
"use": "Deze batterij gebruiken", "on": "Aan", "off": "Uit",
|
||||
"on": "Aan", "off": "Uit",
|
||||
"choice": {
|
||||
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
|
||||
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Laadt met zonne-energie die de andere batterijen niet opnemen, en dekt het huis."},
|
||||
"off": {"name": "Uit", "description": "De add-on vraagt deze batterij 0 W, behalve als de piekgrens haar nodig heeft."}
|
||||
}},
|
||||
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
|
||||
},
|
||||
"later": "Alleen goedkope uren, volgt later",
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"sell": {"name": "Verkopen in dure uren", "description": "Verkopen komt in een latere update."},
|
||||
"modes_off": {"name": "Batterijmodi staan uit"},
|
||||
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
|
||||
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en dekt ook het huis."},
|
||||
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
|
||||
"rules": {"description": "De piekgrens en de veiligheidsstops gaan altijd voor, en laden van het net en verkopen volgen later."}},
|
||||
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
|
||||
"settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"}
|
||||
},
|
||||
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
|
||||
@@ -102,7 +111,13 @@
|
||||
},
|
||||
"settings": {
|
||||
"name": "Instellingen",
|
||||
"description": "Elke instelling, per taak, en de eenvoudige weergave volgt in een latere update.",
|
||||
"description": "Elke instelling, per taak.",
|
||||
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
|
||||
"simple": {"description": "De keuzes die een eigenaar maakt.",
|
||||
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}},
|
||||
"batteries": {"name": "Batterijen"},
|
||||
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
|
||||
"installer": "De installateursweergave openen"},
|
||||
"classic": "Klassieke instellingen",
|
||||
"search": {"name": "Instelling zoeken", "description": "Zoek een instelling, bijvoorbeeld piek of meter."},
|
||||
"no_match": {"description": "Geen instelling past bij uw zoekopdracht."},
|
||||
@@ -197,7 +212,6 @@
|
||||
"needed": {"description": "Doe eerst de waterkokertest, of sla hem over."}
|
||||
},
|
||||
"check": {"name": "Wat de add-on nakeek", "running": {"description": "Bezig met de startcontroles van de add-on."}, "pass": {"description": "De startcontroles van de add-on zijn in orde."}, "failed": {"description": "De controle liep niet, dus probeer opnieuw."}, "again": "Opnieuw nakijken", "blocked": {"description": "Los de rood gemarkeerde punten op en kijk dan opnieuw na."}},
|
||||
"shadow": {"name": "Starten in schaduwmodus", "description": "Aanbevolen, want dan blijft elke omvormer op 0 W tot u de schaduwmodus uitzet onder Instellingen."},
|
||||
"dialog": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, en de schaduwmodus houdt ze daarna op 0 W.", "yes": "Bewaren en herstarten"},
|
||||
"dialog_live": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Bewaren en herstarten"},
|
||||
"dialog_setup": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en de schaduwmodus houdt elke omvormer op 0 W.", "yes": "Bewaren en herstarten"},
|
||||
@@ -232,7 +246,37 @@
|
||||
"cycle": "Maandelijkse cyclus bezig.",
|
||||
"giving": "Levert nu {kw}.",
|
||||
"charging": "Laadt nu {kw}.",
|
||||
"resting": "Rust nu."
|
||||
"resting": "Rust nu.",
|
||||
"off_resting": "Uit. Rust nu.",
|
||||
"off_peak": "Uit, maar levert {kw} voor de piekgrens.",
|
||||
"off_going": "Uit. Nog {kw}, gaat naar 0 W."
|
||||
},
|
||||
"why": {
|
||||
"no_reading": "Geen meting, dus de add-on weet het niet.",
|
||||
"held": "Een alarm stopte de regeling. Zie Vraagt aandacht.",
|
||||
"stopped": "De regeling is gestopt.",
|
||||
"shadow": "Schaduwmodus: de add-on toont alleen wat hij zou doen.",
|
||||
"cycle": "De maandelijkse cyclus houdt de meting van het laadniveau juist.",
|
||||
"cycle_mode": "De maandelijkse cyclus gaat voor. Daarna volgt ze weer haar modus.",
|
||||
"off": "U hebt haar uitgezet.",
|
||||
"off_peak": "De piekgrens gaat voor op Uit. Daarna rust ze weer.",
|
||||
"refuses_give": "De batterij weigert te leveren. De add-on probeert het elke 15 minuten opnieuw.",
|
||||
"refuses_charge": "De batterij weigert te laden. De add-on probeert het elke 15 minuten opnieuw.",
|
||||
"peak": "De piekgrens vraagt vermogen van de batterijen.",
|
||||
"modes_off": "Batterijmodi staan uit, dus ze volgt het huis.",
|
||||
"solar_first": "Eerst zonne-energie: ze krijgt het overschot vóór de andere.",
|
||||
"leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.",
|
||||
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
|
||||
"covers_house": "Ze levert stroom om het huis te dekken.",
|
||||
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over."
|
||||
},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"off": "Uit, dus vandaag geen maandelijkse cyclus.",
|
||||
"house": "Volgt het huis.",
|
||||
"house_would": "Zou het huis volgen.",
|
||||
"stopped": "Niets gepland zolang de regeling gestopt is.",
|
||||
"held": "Niets gepland tot het alarm weg is."
|
||||
},
|
||||
"head": {
|
||||
"held": "Elke omvormer staat op 0 W tot het alarm weg is.",
|
||||
|
||||
@@ -46,6 +46,7 @@ class Field:
|
||||
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)
|
||||
note: str = "" # P3: a line under it on Settings, Simple
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
@@ -180,12 +181,13 @@ GROUPS = (
|
||||
Group("safety", (
|
||||
Part("peak", (
|
||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||
simple=True, wizard=5),
|
||||
simple=True, wizard=5, note="month_peak"),
|
||||
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
||||
Field("peak_soc_floor", LOCKED, "%"),
|
||||
)),
|
||||
Part("start", (
|
||||
Field("shadow_mode", SEGMENTED, simple=True, wizard=6),
|
||||
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
|
||||
note="shadow"),
|
||||
Field("auto_start", TOGGLE),
|
||||
Field("estop_fitted", TOGGLE),
|
||||
Field("stale_input_s", SLIDER, "s"),
|
||||
|
||||
@@ -99,7 +99,7 @@ def _field(fld, schema_ui, translations, lang, T):
|
||||
"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,
|
||||
"wizard": fld.wizard, "simple": fld.simple, "note": fld.note,
|
||||
"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()
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""UX-13 P3: the Batteries screen and Settings, Simple. `python3 test_batteries_screen.py`
|
||||
|
||||
1. Each battery's Now, Today and Why lines, in English and Dutch, for the six
|
||||
states the brief names: modes off, modes on, a battery Off, an Off battery
|
||||
giving way to the peak limit, a refusal hold, and the monthly cycle.
|
||||
2. The OPT-11 P1 test's F2 contradictions stay gone: a battery that moves
|
||||
power never reads "0 W" beside it, the Off help names no watts, and no
|
||||
battery line says the peak limit "holds back" a battery it asks for power.
|
||||
3. The plan leaves an Off battery out, except for the peak limit.
|
||||
4. `battery_why()` is pure and picks the most urgent reason first.
|
||||
5. The screen's `/action` round trips over the real web app: the switch and
|
||||
the mode list post `pack_use` and `pack_mode`, `/status` shows the result
|
||||
in both languages, and a refused value comes back as a plain 400.
|
||||
6. The page: the cards, the Simple view and its fields come from the layout
|
||||
table and ui_text, and the scripts post only the existing actions.
|
||||
7. The screen changes no control path: homeview.py reads the controller and
|
||||
calls nothing that writes, and the page adds no /action value.
|
||||
The headless-browser check is in the PR, not in CI (the CI image has no
|
||||
browser). Mutating a module this file grades? Read MUTATION.md first.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import aiohttp # noqa: E402
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
import ux13_demo as D # noqa: E402
|
||||
from app import homeview, packmode, uilayout as L, uipage, web # noqa: E402
|
||||
from app.maintenance import IDLE, DRAIN # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def words(lang):
|
||||
with io.open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
|
||||
encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
EN, NL = words("en"), words("nl")
|
||||
B_NAME = D.OPTS["inverters"][1]["name"]
|
||||
LANGS = {"en": EN, "nl": NL}
|
||||
|
||||
|
||||
# The fixture's prices are for the day it is built, so the plan depends on
|
||||
# the hour. Pin the clock (review of #150: unpinned, this file failed every
|
||||
# morning): the fixture is built at 00:15 on 2026-09-28, Brussels time. Part 1
|
||||
# looks at 23:30, when the day's trades and cycle start are past, and part 3
|
||||
# at 00:30, with all of them ahead.
|
||||
_BUILT = datetime(2026, 9, 27, 22, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _Clock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return _BUILT.astimezone(tz) if tz else _BUILT.replace(tzinfo=None)
|
||||
|
||||
|
||||
D.datetime = _Clock
|
||||
_DAY0 = homeview.local_day(_BUILT)[0]
|
||||
EVENING = _DAY0 + timedelta(hours=23, minutes=30)
|
||||
MORNING = _DAY0 + timedelta(minutes=30)
|
||||
|
||||
|
||||
def build(state, lang="en", c=None, now=None):
|
||||
c = c or D.controller(state)
|
||||
s = c.status_payload()
|
||||
return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang,
|
||||
now=now or EVENING)
|
||||
|
||||
|
||||
# The six states and what each battery's Why line must be.
|
||||
WANT = {
|
||||
"running": ("modes_off", "modes_off"),
|
||||
"modes": ("solar_first", "leftover"),
|
||||
"off": ("solar_first", "off"),
|
||||
"off_peak": ("peak", "off_peak"),
|
||||
"refusal": ("refuses_give", "other_refuses"),
|
||||
"maint": ("cycle_mode", "leftover"),
|
||||
}
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. Now, Today and Why per battery, English and Dutch")
|
||||
ROWS = {(st, lang): build(st, lang) for st in WANT for lang in LANGS}
|
||||
for st, want in WANT.items():
|
||||
got = tuple(b["why_key"] for b in ROWS[(st, "en")]["batteries"])
|
||||
en = ROWS[(st, "en")]["batteries"]
|
||||
nl = ROWS[(st, "nl")]["batteries"]
|
||||
print(f" {st}: " + " / ".join(f"{b['now']} | {b['why']} | "
|
||||
f"{b['today_line'] or b['today']}"
|
||||
for b in en))
|
||||
check(f"{st}: the Why lines are {want}, in each language's own words",
|
||||
got == want
|
||||
and all(b["why"] == homeview.uitext.say(
|
||||
W["home_words"]["why"][b["why_key"]],
|
||||
name=D.OPTS["inverters"][0]["name"])
|
||||
for W, rows in ((EN, en), (NL, nl)) for b in rows))
|
||||
check(f"{st}: every battery has a Now, a Why and a Today, and the Dutch "
|
||||
f"lines differ from the English",
|
||||
all(b["now"] and b["why"] and (b["today"] or b["today_line"])
|
||||
for b in en + nl)
|
||||
and all(e["why"] != n["why"] and e["now"] != n["now"]
|
||||
for e, n in zip(en, nl)))
|
||||
|
||||
_modes = ROWS[("modes", "en")]
|
||||
check("modes on: each row carries its switch and mode, and Today says the "
|
||||
"mode's plan is not made yet (OPT-11 P3/P4), without inventing one",
|
||||
_modes["modes_on"] is True
|
||||
and [(b["use"], b["mode_key"]) for b in _modes["batteries"]]
|
||||
== [(True, "solar_first"), (True, "leftover_solar")]
|
||||
and all(b["today_line"] == EN["home_words"]["today"]["not_planned"]
|
||||
for b in _modes["batteries"]))
|
||||
_off = ROWS[("running", "en")]
|
||||
check("modes off: no switch, no mode, and Today and Why say it follows the "
|
||||
"house", _off["modes_on"] is False
|
||||
and all("mode_key" not in b and "use" not in b
|
||||
and b["today_line"] == EN["home_words"]["today"]["house"]
|
||||
for b in _off["batteries"]))
|
||||
_b = ROWS[("off", "en")]["batteries"][1]
|
||||
check("a battery Off: 'Off. Resting now.', 'You switched it off.', and no "
|
||||
"monthly cycle today", (_b["mode_key"], _b["now"], _b["why"],
|
||||
_b["today_line"])
|
||||
== ("off", EN["home_words"]["now"]["off_resting"],
|
||||
EN["home_words"]["why"]["off"], EN["home_words"]["today"]["off"]))
|
||||
_b = ROWS[("off_peak", "en")]["batteries"][1]
|
||||
_bn = ROWS[("off_peak", "nl")]["batteries"][1]
|
||||
check("Off giving way to the peak limit says so plainly, with the figure: "
|
||||
"'Off, but gives 0.5 kW for the peak limit.'",
|
||||
_b["now"] == "Off, but gives 0.5 kW for the peak limit."
|
||||
and _bn["now"] == "Uit, maar levert 0,5 kW voor de piekgrens."
|
||||
and _b["why"] == EN["home_words"]["why"]["off_peak"])
|
||||
_a, _b = ROWS[("refusal", "en")]["batteries"]
|
||||
check("a refusal hold: the refusing battery says it refuses and when it "
|
||||
"tries again, the other says it takes over",
|
||||
_a["why"] == EN["home_words"]["why"]["refuses_give"]
|
||||
and _b["why"] == "Battery A refuses to give power, so this battery "
|
||||
"takes over.")
|
||||
_a = ROWS[("maint", "en")]["batteries"][0]
|
||||
check("maintenance: the cycle comes first, the mode resumes after, and Today "
|
||||
"shows the cycle block", _a["maintenance"] == DRAIN
|
||||
and _a["why"] == EN["home_words"]["why"]["cycle_mode"]
|
||||
and [r["kind"] for r in _a["today"]] == ["cycle"])
|
||||
_sh = build("shadow")["batteries"]
|
||||
_al = build("alarm")["batteries"]
|
||||
check("shadow mode and an alarm: Why says which, and Today plans nothing "
|
||||
"the add-on does not do",
|
||||
all(b["why_key"] == "shadow" and b["today_line"]
|
||||
== EN["home_words"]["today"]["house_would"] for b in _sh)
|
||||
and all(b["why_key"] == "held" and b["today_line"]
|
||||
== EN["home_words"]["today"]["held"] for b in _al))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the OPT-11 P1 test's F2: no line contradicts the power shown")
|
||||
ZERO = re.compile(r"\b0 W\b")
|
||||
GOING = (EN["home_words"]["zero_going"], NL["home_words"]["zero_going"],
|
||||
"going to 0 W", "gaat naar 0 W")
|
||||
_bad = []
|
||||
for (st, lang), payload in sorted(ROWS.items()):
|
||||
for b in payload["batteries"]:
|
||||
moving = b["power_w"] is not None and abs(b["power_w"]) > homeview.IDLE_W
|
||||
if moving and b.get("maintenance", IDLE) == IDLE:
|
||||
kw = homeview._dec(f"{abs(b['power_w']) / 1000:.1f}")
|
||||
kw = kw.replace(".", ",") if lang == "nl" else kw
|
||||
if kw not in b["now"]:
|
||||
_bad.append(f"{st}/{lang}/{b['name']}: no figure in {b['now']!r}")
|
||||
for line in (b["now"], b["why"]):
|
||||
if ZERO.search(line) and not any(g in line for g in GOING):
|
||||
_bad.append(f"{st}/{lang}/{b['name']}: 0 W beside power: "
|
||||
f"{line!r}")
|
||||
if "held back" in b["why"] or "tegengehouden" in b["why"]:
|
||||
_bad.append(f"{st}/{lang}/{b['name']}: {b['why']!r}")
|
||||
print(f" {_bad}")
|
||||
check("a battery that moves power shows the figure, and no line beside it "
|
||||
"says 0 W", not _bad)
|
||||
check("the Off help names no watts, in either language, and says the peak "
|
||||
"limit may use the battery",
|
||||
all(not ZERO.search(W["home"]["batteries"]["choice"]["off"]
|
||||
["description"]) for W in LANGS.values())
|
||||
and "peak limit" in EN["home"]["batteries"]["choice"]["off"]
|
||||
["description"])
|
||||
_going = homeview.battery_now(62, 900, phase=IDLE, shadow=False, running=True,
|
||||
off=True, peak=False)
|
||||
check("an Off battery still moving without the peak limit reads 'going to "
|
||||
"0 W' with its figure, never a bare 0 W",
|
||||
_going == "Off. Still at 0.9 kW, going to 0 W.")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the plan leaves an Off battery out, except for the peak limit")
|
||||
_c = D.controller("off")
|
||||
_c.maints[1].cfg = _c.maints[0].cfg # both due, so both would be drawn
|
||||
_p = build("off", c=_c)
|
||||
_kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i}
|
||||
for i in (0, 1)}
|
||||
print(f" {_kinds}")
|
||||
check("Off: no 'follows the house', no trade, no monthly cycle for B",
|
||||
not _kinds[1] and "house" in _kinds[0])
|
||||
_p = build("off", now=MORNING)
|
||||
_m = build("modes", now=MORNING)
|
||||
_kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i}
|
||||
for i in (0, 1)}
|
||||
print(f" morning: {_kinds}, lines {_p['plan']['lines']}")
|
||||
check("in the morning, with trades and a cycle ahead: A has them, Off B has "
|
||||
"no block and waits for no cycle (with B on, it does)",
|
||||
{"house", "grid", "give", "cycle"} <= _kinds[0] and not _kinds[1]
|
||||
and not any(B_NAME in x for x in _p["plan"]["lines"])
|
||||
and {"grid", "give"} <= {r["kind"] for r in _m["plan"]["runs"]
|
||||
if r["battery"] == 1}
|
||||
and any(B_NAME in x for x in _m["plan"]["lines"]))
|
||||
_p = build("off_peak")
|
||||
check("...but the peak block stays on it while the peak limit needs it",
|
||||
{r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 1}
|
||||
== {"peak"})
|
||||
# Review: an Off battery that A covers alone rests, so it gets no peak block,
|
||||
# and a card never says "every battery gives" beside it.
|
||||
_c = D.controller("off_peak")
|
||||
_c.inverters[1].batt = 0.0
|
||||
_p = build("off_peak", c=_c)
|
||||
_a, _b = _p["batteries"]
|
||||
check("peak limit covered by A alone: Off B rests, has no peak block, and "
|
||||
"A's Why does not claim every battery gives",
|
||||
_b["why_key"] == "off" and _b["now"] == EN["home_words"]["now"]
|
||||
["off_resting"] and not _b["today"]
|
||||
and {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 0}
|
||||
>= {"peak"} and _a["why_key"] == "peak"
|
||||
and "every" not in _a["why"] and "elke" not in
|
||||
build("off_peak", "nl", c=_c)["batteries"][0]["why"])
|
||||
# Review: a blind or floor-limited guard is engaged with a 0 W floor. It
|
||||
# forbids charging only, so no battery gives power for it.
|
||||
_bad = []
|
||||
for _kw in (dict(blind="meter gap"), dict(floor_limited=True)):
|
||||
_c = D.controller("off_peak")
|
||||
_c.peak_guard = homeview.peakguard.PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=3500.0, ceiling_from="cap",
|
||||
projected_w=None, **_kw)
|
||||
_c.inverters[1].batt = 300.0 # B still going to 0 W
|
||||
_p = build("off_peak", c=_c)
|
||||
_a, _b = _p["batteries"]
|
||||
if (_a["why_key"] == "peak" or _b["why_key"] == "off_peak"
|
||||
or "peak limit" in _b["now"]
|
||||
or any(r["kind"] == "peak" for r in _p["plan"]["runs"])):
|
||||
_bad.append((_kw, _a["why_key"], _b["why_key"], _b["now"]))
|
||||
print(f" {_bad}")
|
||||
check("a blind or floor-limited peak guard (floor 0 W) draws no peak block "
|
||||
"and no line says the peak limit takes power", not _bad)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. battery_why(): pure, most urgent first")
|
||||
BASE = dict(modes_on=True, mode=packmode.SOLAR_FIRST, off=False, reading=True,
|
||||
phase=IDLE, shadow=False, running=True, held=False, refused=0,
|
||||
peak=False, giving=False, sf_ahead=False)
|
||||
|
||||
|
||||
def why(**kw):
|
||||
return homeview.battery_why(**dict(BASE, **kw))
|
||||
|
||||
|
||||
_cases = [
|
||||
(dict(reading=False, held=True), "no_reading"),
|
||||
(dict(held=True, shadow=True), "held"),
|
||||
(dict(running=False, off=True), "stopped"),
|
||||
(dict(shadow=True, phase=DRAIN), "shadow"),
|
||||
(dict(phase=DRAIN, off=True), "cycle_mode"),
|
||||
(dict(phase=DRAIN, modes_on=False), "cycle"),
|
||||
(dict(off=True, peak=True, giving=True), "off_peak"),
|
||||
(dict(off=True, peak=True, giving=False), "off"), # charging or resting
|
||||
(dict(off=True, giving=True), "off"),
|
||||
(dict(refused=1, peak=True), "refuses_give"),
|
||||
(dict(refused=-1), "refuses_charge"),
|
||||
(dict(peak=True, giving=True, other_refuses="B"), "peak"),
|
||||
(dict(giving=True, other_refuses="B", modes_on=False), "other_refuses"),
|
||||
(dict(modes_on=False, giving=True), "modes_off"),
|
||||
(dict(giving=True, mode=packmode.LEFTOVER_SOLAR), "covers_house"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, sf_ahead=True), "leftover"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR), "leftover_alone"),
|
||||
({}, "solar_first"),
|
||||
]
|
||||
_wrong = [(kw, want, why(**kw)) for kw, want in _cases if why(**kw) != want]
|
||||
print(f" {_wrong}")
|
||||
check(f"{len(_cases)} cases, each the expected reason", not _wrong)
|
||||
check("every reason it can return has words in both languages",
|
||||
{w for _k, w in _cases} == set(EN["home_words"]["why"])
|
||||
== set(NL["home_words"]["why"]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. /action round trips from the screen, over the real web app")
|
||||
|
||||
|
||||
async def _serve(state, calls):
|
||||
c = D.controller(state)
|
||||
stored = dict(D.defaults(D.CONFIG), **D.OPTS)
|
||||
runner = aioweb.AppRunner(web.build_app(c, D.Supervisor(stored)))
|
||||
await runner.setup()
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
|
||||
out = []
|
||||
try:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
root = f"http://127.0.0.1:{port}/"
|
||||
for body, lang in calls:
|
||||
if body is not None:
|
||||
async with s.post(root + "action", json=body) as r:
|
||||
out.append((r.status,
|
||||
await r.json(content_type=None)))
|
||||
async with s.get(root + "status?lang=" + lang) as r:
|
||||
out.append((r.status, await r.json(content_type=None)))
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
A, B = (i["name"] for i in D.OPTS["inverters"])
|
||||
# Exactly what app.js posts: {action, unit, value}.
|
||||
_r = asyncio.run(_serve("modes", [
|
||||
({"action": "pack_use", "unit": B, "value": False}, "en"),
|
||||
({"action": "pack_mode", "unit": A, "value": packmode.LEFTOVER_SOLAR},
|
||||
"nl"),
|
||||
({"action": "pack_use", "unit": B, "value": True}, "en"),
|
||||
({"action": "pack_mode", "unit": A, "value": "cheap_hours"}, "en"),
|
||||
]))
|
||||
(_s1, _j1), (_, _st1), (_s2, _j2), (_, _st2), (_s3, _), (_, _st3), \
|
||||
(_s4, _j4), (_, _st4) = _r
|
||||
check("switch B off: 200, and /status shows B Off with 'You switched it off.'",
|
||||
_s1 == 200 and _j1["result"]["use"] is False
|
||||
and _st1["batteries"][1]["mode_key"] == "off"
|
||||
and _st1["batteries"][1]["why"] == EN["home_words"]["why"]["off"])
|
||||
check("set A to Leftover solar: 200, and /status?lang=nl shows it in Dutch",
|
||||
_s2 == 200 and _st2["batteries"][0]["mode_choice"]
|
||||
== packmode.LEFTOVER_SOLAR
|
||||
and _st2["batteries"][0]["mode"]
|
||||
== NL["home_words"]["modes"]["leftover_solar"])
|
||||
check("switch B back on: its stored mode returns",
|
||||
_s3 == 200 and _st3["batteries"][1]["mode_key"]
|
||||
== packmode.LEFTOVER_SOLAR)
|
||||
check("a mode the list only shows greyed out is refused with a plain 400, and "
|
||||
"nothing changes", _s4 == 400 and _j4["ok"] is False and _j4["error"]
|
||||
and _st4["batteries"][0]["mode_choice"] == packmode.LEFTOVER_SOLAR)
|
||||
_r = asyncio.run(_serve("running", [
|
||||
({"action": "pack_use", "unit": B, "value": False}, "en")]))
|
||||
check("with battery modes off the switch is refused with the plain reason, "
|
||||
"and the batteries stay on", _r[0][0] == 400 and _r[0][1]["error"]
|
||||
and "mode_key" not in _r[1][1]["batteries"][1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the page")
|
||||
PAGE = web.page()
|
||||
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
SJS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
|
||||
encoding="utf-8").read()
|
||||
check("the Batteries screen has its cards, the modes-off card and 'How the "
|
||||
"modes work'", all(f'id="{i}"' in PAGE for i in
|
||||
("bcards", "batt-off", "batt-how", "batt-banner")))
|
||||
check("Settings has a Simple / Installer choice as a radio group, and Simple "
|
||||
"comes first", re.search(r'id="set-view"[^>]*>\s*<button[^>]*'
|
||||
r'data-v="simple"', PAGE) is not None
|
||||
and PAGE.count('role="radio"') >= 2 and 'id="simple"' in PAGE)
|
||||
_acts = set(re.findall(r'packAct\("(\w+)"', JS))
|
||||
check(f"the cards post only the existing actions ({sorted(_acts)}), as "
|
||||
f"{{action, unit, value}}", _acts == set(web.PACK_ACTIONS)
|
||||
and "action: what, unit: b.name, value: value" in JS)
|
||||
check("the switch is a real switch and the list names cheap hours as coming "
|
||||
"later, not choosable", 'role: "switch"' in JS and '"aria-checked"' in JS
|
||||
and 'h("option", { value: "", disabled: true }, t("batteries.later"))'
|
||||
in JS)
|
||||
check("the selling switch is greyed out until OPT-11 P3 (no pack_sell is "
|
||||
"posted)", "pack_sell" not in JS and "disabled: true })" in JS)
|
||||
_T = uipage.ui_text("en")
|
||||
_lay = uipage.layout(D.schema_ui(D.CONFIG), {"en": {}})
|
||||
_simple = [f for g in _lay["groups"] for p in g["parts"] for f in p["fields"]
|
||||
if f.get("simple")]
|
||||
check("Settings, Simple draws the fields the layout marks, and each note "
|
||||
"has words in both languages",
|
||||
sorted(f["key"] for f in _simple)
|
||||
== sorted(f.key for g in L.GROUPS for p in g.parts for f in p.fields
|
||||
if f.simple)
|
||||
and all(f["note"] in EN["settings"]["simple"]["note"]
|
||||
and f["note"] in NL["settings"]["simple"]["note"]
|
||||
for f in _simple if f["note"]))
|
||||
check("settings.js picks them by the `simple` flag and names no option key",
|
||||
"f.simple" in SJS and not any(f'"{f.key}"' in SJS for g in L.GROUPS
|
||||
for p in g.parts for f in p.fields))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. no control path changes: the screen reads, it does not write")
|
||||
SRC = io.open(os.path.join(HERE, "app", "homeview.py"), encoding="utf-8").read()
|
||||
WRITES = {"set_number", "store_set", "set", "set_time", "handle_action",
|
||||
"log_event", "abort", "tick", "publish", "call", "write",
|
||||
"_pack_action", "_maint_stop_off"}
|
||||
_calls = sorted({n.func.attr for n in ast.walk(ast.parse(SRC))
|
||||
if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr in WRITES})
|
||||
check(f"homeview.py calls nothing that writes state or commands an inverter "
|
||||
f"({_calls})", not _calls)
|
||||
_imports = sorted({(n.module or "") for n in ast.walk(ast.parse(SRC))
|
||||
if isinstance(n, ast.ImportFrom)})
|
||||
check(f"...and imports no module that sends power requests ({_imports})",
|
||||
not {"main", "inverter", "hass", "control", "allocator",
|
||||
"arbiter"} & {m.lstrip(".") for m in _imports})
|
||||
_c = D.controller("off_peak")
|
||||
_before = (json.dumps(_c.store.data, sort_keys=True, default=str),
|
||||
[(i.last_write, i.target) for i in _c.inverters])
|
||||
for lang in LANGS:
|
||||
build("off_peak", lang, c=_c)
|
||||
check("building the screen twice leaves the store and every power request "
|
||||
"as it was", _before
|
||||
== (json.dumps(_c.store.data, sort_keys=True, default=str),
|
||||
[(i.last_write, i.target) for i in _c.inverters]))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -845,7 +845,8 @@ DATA = {
|
||||
"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",
|
||||
"main.py:err", "main.py:', '.join(sorted(stranded))",
|
||||
"main.py:sorted(on_roster)", "main.py:inv.name",
|
||||
"main.py:sorted(on_roster)", "main.py:inv.name", "main.py:topic_s",
|
||||
"main.py:more",
|
||||
"main.py:inv.batt_fresh.bound.release_s", "main.py:inv.batt_fresh.bound.move_w",
|
||||
"main.py:inv.batt_fresh.bound.unchanged_s", "main.py:inv.batt_fresh.held",
|
||||
"main.py:value", "main.py:float(value)",
|
||||
@@ -1058,6 +1059,8 @@ TEXT = {
|
||||
"main.py:rep.explain()": _migration_texts,
|
||||
"store.py:rep.explain()": _migration_texts,
|
||||
"main.py:plan.words": _tariff_texts,
|
||||
# OPT-11 P2: why an MQTT command was ignored.
|
||||
"main.py:why": lambda: list(messages.PACK_REFUSED.values()),
|
||||
# Families: every builder of the text, as a template.
|
||||
"main.py:refusal": lambda: (_static(("main.py", "return", "write_refusal"))
|
||||
+ _static(("allocator.py", "return", "authorises"))),
|
||||
|
||||
@@ -42,6 +42,7 @@ part is a diff against it, reported as `N topics, M missing, C changed, A added`
|
||||
re-executed from mutated source, each one named with the property it
|
||||
breaks. A property nothing breaks is a property nothing is watching.
|
||||
Part 9 What these checks CANNOT see, enumerated and sized.
|
||||
Part 10 OPT-11 P2: each battery's select and switch, commands, removal.
|
||||
|
||||
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
|
||||
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
|
||||
@@ -821,8 +822,8 @@ MUTATIONS = [
|
||||
("per-unit entities announced at a roster of ONE",
|
||||
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
|
||||
("the unit is dropped from the unique_id",
|
||||
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
|
||||
'unique_id = f"{BASE}_unit_{key}"')]),
|
||||
[('return f"{BASE}_unit_{slug}_{key}"',
|
||||
'return f"{BASE}_unit_{key}"')]),
|
||||
("the unit is dropped from the state topic",
|
||||
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
|
||||
("the slug de-collision is removed (rule 9(c) does not cover this)",
|
||||
@@ -944,6 +945,536 @@ for line in [
|
||||
]:
|
||||
print(" " + line)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n10. OPT-11 P2: each battery's switch and mode as HA entities")
|
||||
# ---------------------------------------------------------------------------
|
||||
import logging # noqa: E402
|
||||
import os # noqa: E402
|
||||
import tempfile # noqa: E402
|
||||
|
||||
from app import messages, packmode # noqa: E402
|
||||
from app.store import Store, unit_key # noqa: E402
|
||||
|
||||
|
||||
def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
|
||||
module=mqtt):
|
||||
"""What `_on_connect()` sends: the announcement, then any subscriptions."""
|
||||
pub = module.MqttPublisher.__new__(module.MqttPublisher)
|
||||
pub.omit, pub.units, pub.include = set(omit), [str(u) for u in units], \
|
||||
[str(k) for k in include]
|
||||
pub.modes, pub.retract = tuple(modes), list(retract)
|
||||
pub.enabled, pub.client = True, FakeClient()
|
||||
subs = []
|
||||
pub.client.subscribe = lambda topic, *a: subs.append(topic)
|
||||
pub._on_connect()
|
||||
return pub.client.msgs, subs
|
||||
|
||||
|
||||
# --- 10a. modes off: byte-identical to ed739d4 --------------------------------
|
||||
# The digest below is GENERATED from release/1.0 at ed739d4 (the base of this
|
||||
# PR): the same matrix run through that commit's `_announce()`. Reproduce with
|
||||
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
|
||||
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
|
||||
["inverter-b", "Inverter B", "x", "y"], ["café"]]
|
||||
ED739D4_OFF = "501a7d93782a260b"
|
||||
_h, _n, _subs_off = hashlib.sha256(), 0, []
|
||||
for _r in ROSTERS:
|
||||
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
|
||||
for _inc in [(), ("pv",)]:
|
||||
_m, _s = announce_p2(_r, _om, _inc)
|
||||
_h.update(repr(_m).encode())
|
||||
_subs_off += _s
|
||||
_n += 1
|
||||
print(f" modes off, {_n} configurations: digest {_h.hexdigest()[:16]} "
|
||||
f"(ed739d4: {ED739D4_OFF})")
|
||||
check("modes off: every configuration announces byte-for-byte what ed739d4 "
|
||||
"announces, in the same order, and subscribes to nothing",
|
||||
_h.hexdigest()[:16] == ED739D4_OFF and not _subs_off)
|
||||
|
||||
|
||||
# --- 10b. modes on: the ordered payload diff ----------------------------------
|
||||
MODES = packmode.MODES
|
||||
ON1, SUB1 = announce_p2(["inverter-a"], modes=MODES)
|
||||
m, c, a = report("roster of one, modes on", ON1)
|
||||
_opt = '["Solar first", "Leftover solar"]'
|
||||
MODE_N1 = [
|
||||
("homeassistant/select/goodwe_ctl_unit_inverter_a_mode/config",
|
||||
'{"name": "Inverter A Mode", "object_id": "goodwe_inverter_a_mode", '
|
||||
'"unique_id": "goodwe_ctl_unit_inverter_a_mode", '
|
||||
'"state_topic": "goodwe_ctl/unit/inverter_a/mode", '
|
||||
'"command_topic": "goodwe_ctl/unit/inverter_a/mode/set", '
|
||||
'"options": ' + _opt + ', "retain": false, '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:battery-sync"}', True),
|
||||
("homeassistant/switch/goodwe_ctl_unit_inverter_a_use/config",
|
||||
'{"name": "Inverter A Use", "object_id": "goodwe_inverter_a_use", '
|
||||
'"unique_id": "goodwe_ctl_unit_inverter_a_use", '
|
||||
'"state_topic": "goodwe_ctl/unit/inverter_a/use", '
|
||||
'"command_topic": "goodwe_ctl/unit/inverter_a/use/set", '
|
||||
'"payload_on": "ON", "payload_off": "OFF", "retain": false, '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:power"}', True),
|
||||
]
|
||||
check("modes on at a roster of one: every existing message byte-identical and "
|
||||
"in place, then exactly the select and the switch, then availability",
|
||||
ON1 == SITE_NOW[:-1] + MODE_N1 + SITE_NOW[-1:])
|
||||
check("...and it subscribes to the two command topics, and nothing else",
|
||||
SUB1 == ["goodwe_ctl/unit/+/mode/set", "goodwe_ctl/unit/+/use/set"])
|
||||
|
||||
ON2, _ = announce_p2(NAMES2, modes=MODES)
|
||||
m, c, a = report("roster of two, modes on", ON2, TWO)
|
||||
check("modes on at a roster of two: nothing missing or changed, four added, "
|
||||
"and the modes-off list is an unchanged prefix",
|
||||
(m, c) == ([], []) and len(a) == 4 and ON2[:len(TWO) - 1] == TWO[:-1]
|
||||
and ON2[-1] == TWO[-1])
|
||||
_mc = cfgs(ON2)[-4:]
|
||||
check("...A's two stay on the site device with its name in front; B's two are "
|
||||
"on B's own device, as TEL-07's rule has it",
|
||||
[c_["name"] for c_ in _mc] == ["Inverter A Mode", "Inverter A Use",
|
||||
"Mode", "Use"]
|
||||
and _mc[0]["device"] == mqtt.DEVICE == _mc[1]["device"]
|
||||
and _mc[2]["device"] == _mc[3]["device"]
|
||||
== mqtt.unit_device("inverter-b", "inverter_b"))
|
||||
check("...unique_ids are stable per battery and distinct from every other id",
|
||||
[c_["unique_id"] for c_ in _mc]
|
||||
== ["goodwe_ctl_unit_inverter_a_mode", "goodwe_ctl_unit_inverter_a_use",
|
||||
"goodwe_ctl_unit_inverter_b_mode", "goodwe_ctl_unit_inverter_b_use"]
|
||||
and len({c_["unique_id"] for c_ in cfgs(ON2)}) == len(cfgs(ON2)))
|
||||
_ids = [compose(c_["device"]["name"], c_["name"]).split(".", 1)[1]
|
||||
for c_ in _mc]
|
||||
print(f" composed ids: {_ids}")
|
||||
check("...and HA composes the ids the design names: "
|
||||
"goodwe_inverter_<x>_mode and goodwe_inverter_<x>_use",
|
||||
_ids == ["goodwe_inverter_a_mode", "goodwe_inverter_a_use",
|
||||
"goodwe_inverter_b_mode", "goodwe_inverter_b_use"])
|
||||
check("every mode has a plain label, in MODES order, and a later mode only "
|
||||
"appends an option: no existing option or id changes",
|
||||
all(k in mqtt.MODE_LABELS for k in MODES)
|
||||
and len(set(mqtt.MODE_LABELS.values())) == len(mqtt.MODE_LABELS)
|
||||
and json.loads(MODE_N1[0][1])["options"]
|
||||
== [mqtt.MODE_LABELS[k] for k in MODES])
|
||||
# Review of #152: the HA options are what automations send, so they are kept
|
||||
# apart from the page's words on purpose. They start equal; if the page's
|
||||
# English changes, this goes red and the author decides: keep the HA option
|
||||
# (and change this pin) or rename it (a breaking change, in DECISIONS).
|
||||
_en = json.loads((HERE / "app" / "ui_text" / "en.json").read_text())
|
||||
check("each HA mode option is the page's English name for that mode, in "
|
||||
"both places the page names it",
|
||||
all(mqtt.MODE_LABELS[k] == _en["home_words"]["modes"][k]
|
||||
== _en["home"]["batteries"]["choice"][k]["name"] for k in MODES))
|
||||
_later = dict(mqtt.MODE_LABELS, cheap_hours="Cheap hours only")
|
||||
_orig = mqtt.MODE_LABELS
|
||||
mqtt.MODE_LABELS = _later
|
||||
try:
|
||||
_p3 = mqtt.mode_payloads(NAMES2, MODES + ("cheap_hours",))
|
||||
finally:
|
||||
mqtt.MODE_LABELS = _orig
|
||||
_p2 = mqtt.mode_payloads(NAMES2, MODES)
|
||||
check("...(simulated P3: adding `cheap_hours` changes only the options list, "
|
||||
"by one entry at its end)",
|
||||
[t for t, _ in _p3] == [t for t, _ in _p2]
|
||||
and all({k: v for k, v in x.items() if k != "options"}
|
||||
== {k: v for k, v in y.items() if k != "options"}
|
||||
for (_, x), (_, y) in zip(_p3, _p2))
|
||||
and _p3[0][1]["options"] == _p2[0][1]["options"] + ["Cheap hours only"])
|
||||
|
||||
|
||||
# --- 10c. modes turned off: removal ------------------------------------------
|
||||
_cur2 = [t for t, _ in mqtt.mode_payloads(NAMES2, MODES)]
|
||||
_ret = mqtt.mode_retractions(_cur2, [])
|
||||
OFF2, _subs = announce_p2(NAMES2, retract=_ret)
|
||||
check("modes turned off: the modes-off list, then an EMPTY retained config "
|
||||
"for each of the four, then availability, and no subscription",
|
||||
OFF2 == TWO[:-1] + [(t, "", True) for t in _cur2] + TWO[-1:]
|
||||
and not _subs)
|
||||
check("...the store keeps what was announced, so a roster that shrinks removes "
|
||||
"only the battery that left",
|
||||
mqtt.mode_retractions(_cur2, [t for t, _ in
|
||||
mqtt.mode_payloads(["inverter-a"], MODES)])
|
||||
== _cur2[2:])
|
||||
check("...and nothing from the store is removed that is not one of our mode "
|
||||
"configs: a non-list, a number, a site sensor, another add-on's select",
|
||||
mqtt.mode_retractions("homeassistant/select/x/config", []) == []
|
||||
and mqtt.mode_retractions(
|
||||
[3, None, "homeassistant/sensor/goodwe_ctl_grid/config",
|
||||
"homeassistant/select/other_mode/config",
|
||||
"homeassistant/select/goodwe_ctl_unit_a_mode/config/x",
|
||||
"homeassistant/select/goodwe_ctl_unit_a_mode/config"], [])
|
||||
== ["homeassistant/select/goodwe_ctl_unit_a_mode/config"])
|
||||
|
||||
|
||||
_on1 = [t for t, _ in mqtt.mode_payloads(["inverter-a"], MODES)]
|
||||
_k, _r = mqtt.mode_configs(["inverter-a"], MODES, _cur2)
|
||||
check("start-up (`mode_configs`): a battery that left is removed, and stays "
|
||||
"in the store, so a start with no broker does not forget it",
|
||||
_r == _cur2[2:] and _k == _on1 + _cur2[2:])
|
||||
_k, _r = mqtt.mode_configs(NAMES2, (), _cur2 + _cur2[:1])
|
||||
check("...modes turned off: all four are removed, once each, and kept",
|
||||
_r == _cur2 and _k == _cur2)
|
||||
check("...never turned on: nothing to store and nothing to remove, so the "
|
||||
"store is not written",
|
||||
mqtt.mode_configs(NAMES2, (), None) == ([], []))
|
||||
_k, _r = mqtt.mode_configs(NAMES2, MODES, _cur2)
|
||||
check("...turned on again: nothing removed, and the list does not grow",
|
||||
_r == [] and _k == _cur2)
|
||||
|
||||
|
||||
from app.main import mode_announcement # noqa: E402
|
||||
_st = Store(os.path.join(tempfile.mkdtemp(), "ann.json"))
|
||||
_OPTS1M = dict(OPTS2, battery_modes_enabled=True,
|
||||
inverters=OPTS2["inverters"][:1])
|
||||
_seen = [mode_announcement(dict(OPTS2), _st),
|
||||
dict(_st.data)]
|
||||
_seen += [mode_announcement(dict(OPTS2, battery_modes_enabled=True), _st),
|
||||
list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])]
|
||||
_seen += [mode_announcement(_OPTS1M, _st),
|
||||
list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])]
|
||||
_seen += [mode_announcement(dict(OPTS2, battery_modes_enabled=False), _st),
|
||||
list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])]
|
||||
check("main's start-up, through a real Store: never on announces and stores "
|
||||
"nothing; on stores the four; a roster of one removes B's two and keeps "
|
||||
"them; off removes all four and the store keeps them",
|
||||
_seen[0] == ((), []) and mqtt.MODE_CONFIGS_KEY not in _seen[1]
|
||||
and _seen[2] == (MODES, []) and _seen[3] == _cur2
|
||||
and _seen[4] == (MODES, _cur2[2:]) and sorted(_seen[5]) == sorted(_cur2)
|
||||
and _seen[6] == ((), _seen[5])
|
||||
and sorted(_seen[7]) == sorted(_cur2))
|
||||
_st = Store(os.path.join(tempfile.mkdtemp(), "ren.json"))
|
||||
mode_announcement(dict(OPTS2, battery_modes_enabled=True), _st)
|
||||
_ren = dict(OPTS2, battery_modes_enabled=True, inverters=[
|
||||
OPTS2["inverters"][0], dict(OPTS2["inverters"][1], name="inverter-c")])
|
||||
_r = mode_announcement(_ren, _st)[1]
|
||||
_kept = list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])
|
||||
check("...B renamed C while on: B's two are removed and stay stored beside "
|
||||
"A's and C's, so a start with no broker does not forget them",
|
||||
_r == _cur2[2:] and _kept[-2:] == _cur2[2:] and len(_kept) == 6
|
||||
and mode_announcement(_ren, _st)[1] == _cur2[2:])
|
||||
check("battery modes are on only for exactly `true`: not \"true\", 1 or "
|
||||
"\"false\"",
|
||||
[packmode.enabled({"battery_modes_enabled": v})
|
||||
for v in (True, "true", 1, "false", None)]
|
||||
== [True, False, False, False, False])
|
||||
|
||||
|
||||
# --- 10d. state topics, from the store ----------------------------------------
|
||||
_tmp = tempfile.mkdtemp()
|
||||
OPTS2M = dict(OPTS2, battery_modes_enabled=True)
|
||||
|
||||
|
||||
def ctl_for(opts, path):
|
||||
pub = _Pub()
|
||||
pub.sent = []
|
||||
pub.publish = lambda values, _p=pub: (_p.sent.append(dict(values)),
|
||||
setattr(_p, "last", dict(values)))
|
||||
return Controller(dict(opts), None, Store(path), pub,
|
||||
stop=asyncio.Event()), pub
|
||||
|
||||
|
||||
_path = os.path.join(_tmp, "state.json")
|
||||
ctlm, pubm = ctl_for(OPTS2M, _path)
|
||||
ctlm.publish()
|
||||
_fed = {f"{mqtt.BASE}/{k}" for k in pubm.last
|
||||
if k.endswith("/mode") or k.endswith("/use")}
|
||||
check("every announced mode entity is fed, and nothing else is: the state "
|
||||
"topics published are exactly the ones announced",
|
||||
_fed == {c_["state_topic"] for c_ in _mc})
|
||||
check("...defaults with nothing in the store: both On, both Solar first",
|
||||
[pubm.last[k] for k in ("unit/inverter_a/mode", "unit/inverter_a/use",
|
||||
"unit/inverter_b/mode", "unit/inverter_b/use")]
|
||||
== ["Solar first", "ON", "Solar first", "ON"])
|
||||
_c_off, _p_off = ctl_for(dict(OPTS2, battery_modes_enabled=False),
|
||||
os.path.join(_tmp, "off2.json"))
|
||||
_c_off.publish()
|
||||
_c_abs, _p_abs = ctl_for(OPTS2, os.path.join(_tmp, "off3.json"))
|
||||
_c_abs.publish()
|
||||
check("modes off (false or absent): the controller publishes the same keys as "
|
||||
"before, with no mode or switch topic",
|
||||
set(_p_off.last) == set(_p_abs.last) == set(vals2)
|
||||
and not [k for k in _p_off.last if k.endswith(("/mode", "/use"))])
|
||||
|
||||
|
||||
# --- 10e. commands: the /action path, validated -------------------------------
|
||||
class _Logs(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.DEBUG)
|
||||
self.rows = []
|
||||
|
||||
def emit(self, record):
|
||||
self.rows.append((record.levelno, record.getMessage()))
|
||||
|
||||
|
||||
_logs = _Logs()
|
||||
logging.getLogger("goodwe").addHandler(_logs)
|
||||
logging.getLogger("goodwe").setLevel(logging.DEBUG)
|
||||
_calls = []
|
||||
_real_handle = ctlm.handle_action
|
||||
|
||||
|
||||
async def _spy(what, **kw):
|
||||
_calls.append((what, kw))
|
||||
return await _real_handle(what, **kw)
|
||||
|
||||
|
||||
ctlm.handle_action = _spy
|
||||
|
||||
|
||||
def cmd(topic, payload, retained=False, ctl=None):
|
||||
ctl = ctl or ctlm
|
||||
asyncio.run(ctl.mqtt_command(topic, payload, retained))
|
||||
|
||||
|
||||
def stored(ctl=None):
|
||||
d = (ctl or ctlm).store.data
|
||||
return {k: d.get(k) for k in (unit_key("inverter-a", "pack_use"),
|
||||
unit_key("inverter-a", "pack_mode"),
|
||||
unit_key("inverter-b", "pack_use"),
|
||||
unit_key("inverter-b", "pack_mode"))}
|
||||
|
||||
|
||||
T_A = "goodwe_ctl/unit/inverter_a"
|
||||
T_B = "goodwe_ctl/unit/inverter_b"
|
||||
pubm.sent.clear()
|
||||
cmd(f"{T_B}/use/set", b"OFF")
|
||||
cmd(f"{T_A}/mode/set", b"Leftover solar")
|
||||
check("good commands go through handle_action() with the /action arguments",
|
||||
_calls == [("pack_use", {"unit": "inverter-b", "value": False}),
|
||||
("pack_mode", {"unit": "inverter-a",
|
||||
"value": "leftover_solar"})])
|
||||
check("...and reach the store under P1's keys, and nowhere else",
|
||||
stored() == {unit_key("inverter-a", "pack_use"): None,
|
||||
unit_key("inverter-a", "pack_mode"): "leftover_solar",
|
||||
unit_key("inverter-b", "pack_use"): False,
|
||||
unit_key("inverter-b", "pack_mode"): None})
|
||||
check("...and the state topics say so at once, from the store",
|
||||
pubm.sent and pubm.sent[-1] == {
|
||||
"unit/inverter_a/mode": "Leftover solar", "unit/inverter_a/use": "ON",
|
||||
"unit/inverter_b/mode": "Solar first", "unit/inverter_b/use": "OFF"})
|
||||
check("...and the action log records each change",
|
||||
any("inverter-b switched off" in e for e in ctlm.events)
|
||||
and any("inverter-a mode set to leftover solar" in e
|
||||
for e in ctlm.events))
|
||||
|
||||
_before = stored()
|
||||
_n_calls = len(_calls)
|
||||
BAD = [
|
||||
(f"{T_A}/use/set", b"on"), (f"{T_A}/use/set", b"ON\n"),
|
||||
(f"{T_A}/use/set", b"true"), (f"{T_A}/use/set", b"1"),
|
||||
(f"{T_A}/use/set", b""),
|
||||
(f"{T_A}/mode/set", b"solar_first"), # a key, not the option
|
||||
(f"{T_A}/mode/set", b"Cheap hours only"), # not offered before P3
|
||||
(f"{T_A}/mode/set", b"solar first"),
|
||||
(f"{T_A}/mode/set", "Sοlar first".encode()), # Greek omicron
|
||||
(f"{T_A}/mode/set", b"Solar first\x00"),
|
||||
(f"{T_A}/mode/set", b'{"action": "pack_use", "value": false}'),
|
||||
(f"{T_A}/mode/set", b"Solar first; rm -rf /"),
|
||||
(f"{T_A}/mode/set", b"' OR 1=1 --"),
|
||||
(f"{T_A}/use/set", b"\xff\xfe"), # not UTF-8
|
||||
(f"{T_A}/mode/set", b"Solar first" * 10), # too long
|
||||
("goodwe_ctl/unit/inverter_z/use/set", b"OFF"), # no such battery
|
||||
("goodwe_ctl/unit/../use/set", b"OFF"),
|
||||
("goodwe_ctl/unit/inverter_a/soc/set", b"OFF"),
|
||||
("goodwe_ctl/unit/inverter_a/use/set/x", b"OFF"),
|
||||
("goodwe_ctl/unit/Inverter_A/use/set", b"OFF"),
|
||||
(f"{T_A}/use/set", None),
|
||||
]
|
||||
for _t, _p in BAD:
|
||||
cmd(_t, _p)
|
||||
check(f"all {len(BAD)} bad commands are refused: the store is unchanged",
|
||||
stored() == _before)
|
||||
_passed = [c_ for c_ in _calls[_n_calls:]]
|
||||
check("...those that reach handle_action() carry a value /action also refuses "
|
||||
"(None) or no known battery; nothing is coerced into a valid choice",
|
||||
all(kw["value"] is None or kw["unit"] is None for _, kw in _passed))
|
||||
|
||||
# Log once per topic and reason.
|
||||
_logs.rows.clear()
|
||||
for _ in range(5):
|
||||
cmd(f"{T_B}/use/set", b"maybe")
|
||||
_warn = [r for r in _logs.rows if r[0] >= logging.WARNING]
|
||||
check("a refused command repeated 5 times is logged once at WARNING, in plain "
|
||||
"words, and at DEBUG the other times",
|
||||
len(_warn) == 1 and messages.pack_refused("value") in _warn[0][1]
|
||||
and len([r for r in _logs.rows if r[0] == logging.DEBUG]) == 5)
|
||||
_logs.rows.clear()
|
||||
_flood, _ = ctl_for(OPTS2M, os.path.join(_tmp, "flood.json"))
|
||||
for _i in range(100):
|
||||
cmd(f"goodwe_ctl/unit/x{_i}/use/set", b"OFF", ctl=_flood)
|
||||
_w = [r[1] for r in _logs.rows if r[0] >= logging.WARNING]
|
||||
_per = [w for w in _w if "still ignored" not in w]
|
||||
_more = [w for w in _w if "still ignored" in w]
|
||||
print(f" flood: {len(_per)} per-topic WARNINGs, then {_more}")
|
||||
check("...and a flood of 100 different bad topics logs 32 WARNINGs, then the "
|
||||
"rest only as a count, at 1 and 10 more (review of #152)",
|
||||
len(_per) == 32 and len(_more) == 2
|
||||
and "1 more so far" in _more[0] and "10 more so far" in _more[1])
|
||||
_logs.rows.clear()
|
||||
for _ in range(100):
|
||||
cmd(f"{T_B}/use/set", b"maybe", ctl=_flood)
|
||||
_more = [r[1] for r in _logs.rows
|
||||
if r[0] >= logging.WARNING and "still ignored" in r[1]]
|
||||
check("...so a repeated attack on ONE topic still shows it goes on: the count "
|
||||
"reaches 100 in 3 lines, not 100",
|
||||
len(_more) == 1 and "100 more so far" in _more[0])
|
||||
_logs.rows.clear()
|
||||
_inj, _ = ctl_for(OPTS2M, os.path.join(_tmp, "inj.json"))
|
||||
cmd("goodwe_ctl/unit/a\nERROR forged line\x1b[31m/use/set", b"OFF", ctl=_inj)
|
||||
_w = [r[1] for r in _logs.rows if r[0] >= logging.WARNING]
|
||||
check("...and a topic with a newline or an escape code cannot forge a log "
|
||||
"line: it is logged printable only",
|
||||
len(_w) == 1 and "\n" not in _w[0] and "\x1b" not in _w[0]
|
||||
and "a?ERROR forged line?[31m" in _w[0])
|
||||
|
||||
# Modes off: refused on the same path, even if a message arrives.
|
||||
cmd(f"{T_A}/use/set", b"OFF", ctl=_c_off)
|
||||
check("with modes off a command is refused by the same check /action makes",
|
||||
stored(_c_off)[unit_key("inverter-a", "pack_use")] is None)
|
||||
|
||||
|
||||
# --- 10f. a retained command after a restart ----------------------------------
|
||||
_logs.rows.clear()
|
||||
_before = stored()
|
||||
_n_calls = len(_calls)
|
||||
cmd(f"{T_A}/use/set", b"OFF", retained=True)
|
||||
cmd(f"{T_B}/use/set", b"ON", retained=True)
|
||||
check("a RETAINED command (the broker replays it on subscribe, e.g. after a "
|
||||
"restart) is never applied, and never reaches handle_action()",
|
||||
stored() == _before and len(_calls) == _n_calls)
|
||||
check("...and it is logged once, in plain words",
|
||||
any(messages.pack_refused("retained") in r[1]
|
||||
for r in _logs.rows if r[0] >= logging.WARNING))
|
||||
ctl_r, pub_r = ctl_for(OPTS2M, _path) # a restart: same state file
|
||||
ctl_r.publish()
|
||||
check("after a restart the state topics come from the store: A Leftover solar "
|
||||
"and on, B off",
|
||||
[pub_r.last[k] for k in ("unit/inverter_a/mode", "unit/inverter_a/use",
|
||||
"unit/inverter_b/use")]
|
||||
== ["Leftover solar", "ON", "OFF"])
|
||||
logging.getLogger("goodwe").removeHandler(_logs)
|
||||
|
||||
|
||||
# --- 10g. the paho side ----------------------------------------------------------
|
||||
class _Msg:
|
||||
def __init__(self, topic, payload, retain):
|
||||
self.topic, self.payload, self.retain = topic, payload, retain
|
||||
|
||||
|
||||
_got = []
|
||||
|
||||
|
||||
async def _handler(*args):
|
||||
_got.append(args)
|
||||
|
||||
|
||||
async def _drive():
|
||||
pub = mqtt.MqttPublisher.__new__(mqtt.MqttPublisher)
|
||||
pub._on_message(None, None, _Msg("t", b"x", 0)) # not bound: dropped
|
||||
pub.bind(asyncio.get_running_loop(), _handler)
|
||||
pub._on_message(None, None, _Msg(f"{T_A}/use/set", b"OFF", 1))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
asyncio.run(_drive())
|
||||
check("paho's thread hands (topic, bytes, retained) to the controller's loop, "
|
||||
"and drops a message that arrives before the controller exists",
|
||||
_got == [(f"{T_A}/use/set", b"OFF", True)])
|
||||
|
||||
_kws = {k.arg: ast.unparse(k.value) for c_ in _call for k in c_.keywords}
|
||||
_main_src = (HERE / "app" / "main.py").read_text()
|
||||
check("(source read) main() passes modes and retractions to the publisher, "
|
||||
"and binds its commands to the controller",
|
||||
_kws.get("modes") == "_modes" and _kws.get("retract") == "_retract"
|
||||
and "pub.bind(asyncio.get_running_loop(), controller.mqtt_command)"
|
||||
in _main_src
|
||||
and "_modes, _retract = mode_announcement(opts, store)" in _main_src)
|
||||
|
||||
|
||||
# --- 10h. the guards, broken one at a time --------------------------------------
|
||||
def p2_properties(mod):
|
||||
out = {}
|
||||
try:
|
||||
off, s_off = announce_p2(NAMES2, module=mod)
|
||||
on1, s_on = announce_p2(["inverter-a"], modes=MODES, module=mod)
|
||||
on2, _ = announce_p2(NAMES2, modes=MODES, module=mod)
|
||||
out["off identical"] = off == TWO and not s_off
|
||||
out["on payloads"] = on1 == ON1 and on2 == ON2
|
||||
out["subscribes when on"] = s_on == SUB1
|
||||
cur = [t for t, _ in mod.mode_payloads(NAMES2, MODES)]
|
||||
out["retract filters store"] = mod.mode_retractions(
|
||||
[3, "homeassistant/sensor/goodwe_ctl_grid/config",
|
||||
"homeassistant/select/other/config"] + cur, cur) == []
|
||||
out["retract keeps current"] = mod.mode_retractions(cur, cur) == []
|
||||
out["retract published"] = announce_p2(
|
||||
NAMES2, retract=cur[:1], module=mod)[0] == \
|
||||
TWO[:-1] + [(cur[0], "", True)] + TWO[-1:]
|
||||
pc = mod.parse_command
|
||||
out["retained dropped"] = pc(f"{T_A}/use/set", b"OFF", True,
|
||||
MODES)[3] == "retained"
|
||||
out["size capped"] = pc(f"{T_A}/mode/set", b"Solar first" * 10,
|
||||
False, MODES)[3] == "size"
|
||||
out["utf-8 only"] = pc(f"{T_A}/use/set", b"\xff", False,
|
||||
MODES)[3] == "encoding"
|
||||
out["switch exact"] = (
|
||||
pc(f"{T_A}/use/set", b"on", False, MODES)[2] is None
|
||||
and pc(f"{T_A}/use/set", b"OFF", False, MODES)[2] is False)
|
||||
out["select labels only"] = (
|
||||
pc(f"{T_A}/mode/set", b"solar_first", False, MODES)[2] is None
|
||||
and pc(f"{T_A}/mode/set", b"Leftover solar", False,
|
||||
MODES)[2] == "leftover_solar")
|
||||
out["select offered only"] = pc(
|
||||
f"{T_A}/mode/set", b"Solar first", False, ("leftover_solar",))[2] \
|
||||
is None
|
||||
out["topic exact"] = all(
|
||||
pc(t, b"OFF", False, MODES)[3] == "topic"
|
||||
for t in ("goodwe_ctl/unit/../use/set", "goodwe_ctl/unit/a/b/use/set",
|
||||
"goodwe_ctl/unit/a/use/set/x", "x/goodwe_ctl/unit/a/use/set"))
|
||||
except Exception as err: # noqa: BLE001
|
||||
out["did not raise"] = False
|
||||
out["__error__"] = repr(err)[:70]
|
||||
return out
|
||||
|
||||
|
||||
P2_SHIPPED = p2_properties(mqtt)
|
||||
check("the shipped module has every P2 property (so a red below is the "
|
||||
"mutation)", all(v is True for v in P2_SHIPPED.values()))
|
||||
P2_MUTATIONS = [
|
||||
("mode entities announced with modes off",
|
||||
[(" if not modes:\n return []\n", " modes = modes or ('solar_first', 'leftover_solar')\n")]),
|
||||
("subscribed with modes off", [(" if self.modes:\n", " if True:\n")]),
|
||||
("retained commands applied", [(" if retained:\n", " if False:\n")]),
|
||||
("no payload size cap", [("or len(payload) > MAX_COMMAND_BYTES", "or False")]),
|
||||
("bad UTF-8 replaced, not refused",
|
||||
[('.decode("utf-8")', '.decode("utf-8", "replace")')]),
|
||||
("switch payload case-folded",
|
||||
[("{USE_ON: True, USE_OFF: False}.get(text)",
|
||||
"{USE_ON: True, USE_OFF: False}.get(text.upper())")]),
|
||||
("select accepts mode keys too",
|
||||
[("if MODE_LABELS.get(k) == text", "if text in (k, MODE_LABELS.get(k))")]),
|
||||
("select accepts any labelled mode, offered or not",
|
||||
[("(k for k in modes if MODE_LABELS.get(k) == text)",
|
||||
"(k for k in MODE_LABELS if MODE_LABELS.get(k) == text)")]),
|
||||
("command topic loosened", [('/([a-z0-9_]+)/(mode|use)/set")', '/(.+)/(mode|use)/set")')]),
|
||||
("command topic matched as a prefix",
|
||||
[("_COMMAND_RE.fullmatch(topic)", "_COMMAND_RE.search(topic)")]),
|
||||
("store list not filtered",
|
||||
[("and _MODE_CONFIG_RE.fullmatch(t)\n", "and True\n")]),
|
||||
("current configs also removed", [("and t not in keep))", "))")]),
|
||||
("commands published retained by HA", [('cfg["retain"] = False', 'cfg["retain"] = True')]),
|
||||
("retractions not published",
|
||||
[(' self.client.publish(topic, "", retain=True)\n', " pass\n")]),
|
||||
]
|
||||
_p2_surv = []
|
||||
for label, pairs in P2_MUTATIONS:
|
||||
got = properties(load(pairs))
|
||||
got.update(p2_properties(load(pairs)))
|
||||
broke = sorted(k for k, v in {**SHIPPED, **P2_SHIPPED}.items()
|
||||
if v is True and got.get(k) is not True)
|
||||
print(f" {'RED ' if broke else 'GREEN'} {label}"
|
||||
+ (f" (breaks: {', '.join(broke)})" if broke else ""))
|
||||
if not broke:
|
||||
_p2_surv.append(label)
|
||||
check(f"all {len(P2_MUTATIONS)} P2 mutations of app/mqtt.py are red",
|
||||
not _p2_surv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""UX-13 P3: the headless-browser check of the Batteries screen and Settings,
|
||||
Simple. Run by `CHECK=1 tools/ux13_screenshots.sh`, like the screenshots.
|
||||
|
||||
For every battery-mode fixture (tools/ux13_demo.py) it opens both screens at
|
||||
phone and desktop width, light and dark, in English and Dutch, and fails on a
|
||||
page error, a console error or a sideways scroll. Then it clicks the switch
|
||||
and the mode list on both screens and checks what was posted and what the
|
||||
cards show after.
|
||||
|
||||
python3 ux13_browser_check.py # DEMO_<STATE> = the fixture's URL
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
STATES = ("running", "modes", "off", "off_peak", "refusal", "maint")
|
||||
SIZES = {"phone": {"width": 390, "height": 844},
|
||||
"desktop": {"width": 1000, "height": 900}}
|
||||
FAILS = []
|
||||
|
||||
|
||||
def url(state):
|
||||
return os.environ[f"DEMO_{state.upper()}"]
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
def open_page(browser, state, route, *, size, scheme, lang):
|
||||
ctx = browser.new_context(locale=lang, viewport=SIZES[size],
|
||||
color_scheme=scheme, reduced_motion="reduce")
|
||||
page = ctx.new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(f"pageerror {e}"))
|
||||
page.on("console", lambda m: errors.append(f"console {m.text}")
|
||||
if m.type == "error" else None)
|
||||
page.goto(url(state) + route)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1200)
|
||||
return ctx, page, errors
|
||||
|
||||
|
||||
def sweep(browser):
|
||||
n = 0
|
||||
for state in STATES:
|
||||
for route in ("#batteries", "#settings"):
|
||||
for size in SIZES:
|
||||
for scheme in ("light", "dark"):
|
||||
for lang in ("en-GB", "nl-BE"):
|
||||
ctx, page, errors = open_page(
|
||||
browser, state, route, size=size, scheme=scheme,
|
||||
lang=lang)
|
||||
wide = page.evaluate(
|
||||
"document.documentElement.scrollWidth > "
|
||||
"document.documentElement.clientWidth")
|
||||
if route == "#batteries":
|
||||
cards = page.locator("#bcards .bcard").count()
|
||||
texts = page.locator("#bcards dd").all_inner_texts()
|
||||
ok = cards == 2 and all(t.strip() for t in texts)
|
||||
else:
|
||||
ok = (page.locator("#simple .card").count() >= 3
|
||||
and page.locator("#groups").is_hidden())
|
||||
where = f"{state} {route} {size} {scheme} {lang}"
|
||||
if errors or wide or not ok:
|
||||
FAILS.append(f"{where}: errors={errors} "
|
||||
f"wide={wide} drawn={ok}")
|
||||
print(f" FAIL {where}: {errors} wide={wide} "
|
||||
f"drawn={ok}")
|
||||
n += 1
|
||||
ctx.close()
|
||||
check(f"{n} views: 0 page errors, 0 console errors, no sideways scroll, "
|
||||
f"every card drawn", not [f for f in FAILS if " #" in f])
|
||||
|
||||
|
||||
def posts(page):
|
||||
sent = []
|
||||
page.on("request", lambda r: sent.append(json.loads(r.post_data))
|
||||
if r.url.endswith("/action") and r.method == "POST" else None)
|
||||
return sent
|
||||
|
||||
|
||||
def actions(browser):
|
||||
# The Batteries screen: switch B off, then set A to Leftover solar.
|
||||
ctx, page, errors = open_page(browser, "modes", "#batteries",
|
||||
size="desktop", scheme="light", lang="en-GB")
|
||||
sent = posts(page)
|
||||
page.locator("#bsw-c1").click()
|
||||
page.wait_for_timeout(1500)
|
||||
card_b = page.locator("#bcards .bcard").nth(1)
|
||||
check("Batteries: B's switch posts pack_use false, and B's card turns Off "
|
||||
"with its mode list greyed out",
|
||||
sent[-1:] == [{"action": "pack_use", "unit": "Battery B",
|
||||
"value": False}]
|
||||
and "off" in (card_b.get_attribute("class") or "")
|
||||
and page.locator("#bsel-c1").is_disabled()
|
||||
and "You switched it off." in card_b.inner_text())
|
||||
page.locator("#bsel-c0").focus()
|
||||
page.locator("#bsel-c0").select_option("leftover_solar")
|
||||
page.wait_for_timeout(1500)
|
||||
check("...A's list posts pack_mode leftover_solar and the card keeps it",
|
||||
sent[-1:] == [{"action": "pack_mode", "unit": "Battery A",
|
||||
"value": "leftover_solar"}]
|
||||
and page.locator("#bsel-c0").input_value() == "leftover_solar"
|
||||
and "Leftover solar" in page.locator("#bcards .bcard").nth(0)
|
||||
.inner_text())
|
||||
check("...the cheap-hours choice and the selling switch are greyed out",
|
||||
page.locator("#bsel-c0 option[disabled]").count() == 1
|
||||
and page.locator("#bcards .bcard .f .switch[disabled]").count() == 2)
|
||||
check("...and the focus stays on the control that was used",
|
||||
page.evaluate("document.activeElement.id") == "bsel-c0")
|
||||
check("...with 0 page errors", not errors)
|
||||
ctx.close()
|
||||
# Settings, Simple: the same switch, and the Installer view is one tap.
|
||||
ctx, page, errors = open_page(browser, "modes", "#settings",
|
||||
size="phone", scheme="dark", lang="nl-BE")
|
||||
sent = posts(page)
|
||||
page.locator("#bsw-s1").click()
|
||||
page.wait_for_timeout(1500)
|
||||
check("Simple (NL, phone, dark): B's switch posts pack_use true and reads "
|
||||
"Aan", sent[-1:] == [{"action": "pack_use", "unit": "Battery B",
|
||||
"value": True}]
|
||||
and page.locator("#bsw-s1").get_attribute("aria-checked") == "true")
|
||||
page.locator("#set-view [data-v=installer]").click()
|
||||
page.wait_for_timeout(500)
|
||||
check("...Installer shows the groups and hides Simple",
|
||||
page.locator("#groups").is_visible()
|
||||
and page.locator("#simple").is_hidden())
|
||||
check("...with 0 page errors", not errors)
|
||||
ctx.close()
|
||||
|
||||
|
||||
def switching(browser):
|
||||
# Review: an unsaved change survives the view switch both ways, the
|
||||
# Installer's locked values still need "Change", and only the person's
|
||||
# own pick of a view is remembered.
|
||||
ctx, page, errors = open_page(browser, "modes", "#settings",
|
||||
size="desktop", scheme="light", lang="en-GB")
|
||||
chips = [c.locator(".chip").first.inner_text()
|
||||
for c in page.locator("#simple > .card").all()]
|
||||
check(f"Simple's badges: the peak limit and shadow mode need a restart, "
|
||||
f"the batteries apply at once ({chips})",
|
||||
chips == ["Applies after a restart", "Applies right away",
|
||||
"Applies after a restart"])
|
||||
num = page.locator("#simple .card input.num").first
|
||||
num.fill("4.2")
|
||||
num.dispatch_event("change")
|
||||
page.wait_for_timeout(600)
|
||||
n1 = page.locator("#sb-text").inner_text()
|
||||
page.locator("#set-view [data-v=installer]").click()
|
||||
page.wait_for_timeout(600)
|
||||
val = page.locator("#groups [data-key=peak_cap_w] input.num").input_value()
|
||||
n2 = page.locator("#sb-text").inner_text()
|
||||
locked = (page.locator("#groups [data-key=peak_soc_floor] input").count(),
|
||||
page.locator("#groups [data-key=peak_soc_floor] button.link")
|
||||
.count())
|
||||
page.locator("#set-view [data-v=simple]").click()
|
||||
page.wait_for_timeout(600)
|
||||
back = page.locator("#simple .card input.num").first.input_value()
|
||||
n3 = page.locator("#sb-text").inner_text()
|
||||
check("an unsaved peak limit survives Simple -> Installer -> Simple, and "
|
||||
"the save bar counts it once", n1.startswith("1 ") and n2 == n1
|
||||
and n3 == n1 and val == "4.2" and back == "4.2"
|
||||
and page.locator("#savebar").is_visible())
|
||||
check("...the Installer's locked value has no input until 'Change'",
|
||||
locked == (0, 1))
|
||||
ctx.close()
|
||||
ctx, page, errors = open_page(browser, "running", "#batteries",
|
||||
size="desktop", scheme="light", lang="en-GB")
|
||||
page.locator("#batt-off a").click()
|
||||
page.wait_for_timeout(800)
|
||||
page.reload()
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1200)
|
||||
check("a jump to Installer from a link is not remembered: Settings "
|
||||
"opens on Simple again", page.locator("#simple").is_visible()
|
||||
and page.locator("#groups").is_hidden() and not errors)
|
||||
ctx.close()
|
||||
|
||||
|
||||
def modes_off(browser):
|
||||
ctx, page, errors = open_page(browser, "running", "#batteries",
|
||||
size="phone", scheme="light", lang="en-GB")
|
||||
page.locator("#batt-off a").click()
|
||||
page.wait_for_timeout(1500)
|
||||
check("modes off: the card's button opens Settings on the Installer view",
|
||||
page.url.endswith("#settings") and page.locator("#groups")
|
||||
.is_visible() and not errors)
|
||||
ctx.close()
|
||||
|
||||
|
||||
def month_peak(browser):
|
||||
# The fixture has no month peak yet, so give /status one on the way in.
|
||||
ctx = browser.new_context(locale="en-GB", viewport=SIZES["desktop"])
|
||||
page = ctx.new_page()
|
||||
|
||||
def with_peak(route):
|
||||
r = route.fetch()
|
||||
body = r.json()
|
||||
body["month_peak"]["peak_w"] = 2860.0
|
||||
route.fulfill(response=r, json=body)
|
||||
page.route("**/status*", with_peak)
|
||||
page.goto(url("modes") + "#settings")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1500)
|
||||
check("Simple: the peak card shows this month's peak from /status",
|
||||
"This month's peak so far: 2.9 kW." in page.locator("#simple")
|
||||
.inner_text())
|
||||
ctx.close()
|
||||
|
||||
|
||||
def main():
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
sweep(b)
|
||||
actions(b)
|
||||
month_peak(b)
|
||||
switching(b)
|
||||
modes_off(b)
|
||||
b.close()
|
||||
if FAILS:
|
||||
print(f"{len(FAILS)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,7 +2,9 @@
|
||||
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
|
||||
|
||||
STATE is running, shadow, alarm, new (P4) or setup (UX-15's setup-only
|
||||
mode, P5). The controller is the shipped `Controller`
|
||||
mode, P5). P3 adds the battery-mode states in `P3`: modes (on, B on Leftover
|
||||
solar), off (B switched off), off_peak (B off but giving for the peak limit),
|
||||
refusal (A refuses to give power) and maint (A in its monthly cycle). 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
|
||||
@@ -34,8 +36,9 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
from app import homeview, web # noqa: E402
|
||||
from app import homeview, packmode, web # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN # noqa: E402
|
||||
from app.setuponly import SetupOnly # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
|
||||
@@ -231,8 +234,14 @@ OPTS = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"max_w": 2500, "capacity_kwh": 5.4}]}
|
||||
|
||||
|
||||
# UX-13 P3: the battery-mode states, each with battery modes on.
|
||||
P3 = ("modes", "off", "off_peak", "refusal", "maint")
|
||||
|
||||
|
||||
def controller(state):
|
||||
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
|
||||
if state in P3:
|
||||
opts["battery_modes_enabled"] = True
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -255,6 +264,29 @@ def controller(state):
|
||||
c.pv_w = 3900.0
|
||||
if state == "alarm":
|
||||
c.inverters[0].sign_wd.tripped = True
|
||||
if state in P3:
|
||||
a, b = c.inverters
|
||||
b.store_set(packmode.MODE_KEY, packmode.LEFTOVER_SOLAR)
|
||||
if state in ("off", "off_peak"):
|
||||
b.store_set(packmode.USE_KEY, False)
|
||||
b.batt = 0.0
|
||||
if state == "off_peak":
|
||||
# A cloudy evening: the house takes 6 kW and the peak limit needs
|
||||
# both batteries, so B gives 0.5 kW although it is switched off.
|
||||
c.pv_w, c.grid = 0.0, 3490.0
|
||||
a.batt, b.batt = 2000.0, 510.0
|
||||
c.peak_guard = PeakDecision(engaged=True, floor_w=2500.0,
|
||||
ceiling_w=3500.0, ceiling_from="cap",
|
||||
projected_w=3600.0)
|
||||
if state == "refusal":
|
||||
# A is at its cut-out level and refuses: asked 0 W, B takes over.
|
||||
c.pv_w, c.grid = 0.0, 5.0
|
||||
a.soc, a.batt, a.refused = 9.0, -4.0, 1
|
||||
b.batt = 1210.0
|
||||
if state == "maint":
|
||||
c.maints[0].store.set("phase", DRAIN) # MAINT-05: A's own key
|
||||
c.pv_w, c.grid = 0.0, 0.0
|
||||
a.batt, b.batt = 2000.0, 0.0
|
||||
return c
|
||||
|
||||
|
||||
@@ -290,6 +322,7 @@ async def main(state, port, host="127.0.0.1"):
|
||||
p1_net_entity="sensor.grid", price_provider="generic_dayahead",
|
||||
peak_forecast_entity="sensor.peak_forecast",
|
||||
shadow_mode=c.o.get("shadow_mode", "off"),
|
||||
battery_modes_enabled=state in P3,
|
||||
inverters=[dict(i, bms_soc_floor=10)
|
||||
for i in OPTS["inverters"]])
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
|
||||
@@ -6,8 +6,8 @@ error, so a broken screen is never pictured.
|
||||
|
||||
python3 ux13_screenshots.py OUT_DIR # DEMO_<STATE> = the fixture's URL
|
||||
|
||||
The battery-mode screens (P3) are not built, so there is no shot of them:
|
||||
DOCS.md says so instead of showing a mock-up.
|
||||
P3 adds the Batteries screen and Settings, Simple (the default Settings
|
||||
view), so the Installer shots click Installer first.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -48,7 +48,12 @@ def shot(browser, name, state, route, *, lang="en-GB", size=DESKTOP,
|
||||
print(f" {name}.png")
|
||||
|
||||
|
||||
def installer(page):
|
||||
page.locator("#set-view [data-v=installer]").click()
|
||||
|
||||
|
||||
def open_tariff(page):
|
||||
installer(page)
|
||||
page.locator("details.grp[data-g=tariff] > summary").click()
|
||||
page.locator("details.grp[data-g=meter] > summary").click() # close it
|
||||
# The fixture site is on a dynamic contract: its fallback gets windows.
|
||||
@@ -80,8 +85,17 @@ def main():
|
||||
shot(b, "home-shadow", "shadow", "#home")
|
||||
shot(b, "home-phone", "running", "#home", size=PHONE)
|
||||
shot(b, "home-setup-only", "setup", "#home", full=False)
|
||||
shot(b, "settings-installer", "running", "#settings")
|
||||
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE")
|
||||
shot(b, "settings-installer", "running", "#settings", act=installer)
|
||||
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE",
|
||||
act=installer)
|
||||
shot(b, "batteries", "modes", "#batteries")
|
||||
shot(b, "batteries-nl", "modes", "#batteries", lang="nl-BE")
|
||||
shot(b, "batteries-off-peak", "off_peak", "#batteries")
|
||||
shot(b, "batteries-phone", "refusal", "#batteries", size=PHONE,
|
||||
full=False)
|
||||
shot(b, "batteries-modes-off", "running", "#batteries", full=False)
|
||||
shot(b, "settings-simple", "modes", "#settings")
|
||||
shot(b, "settings-simple-nl", "modes", "#settings", lang="nl-BE")
|
||||
tariff = "details.grp[data-g=tariff]"
|
||||
shot(b, "settings-time-windows", "running", "#settings",
|
||||
act=windows, only=tariff)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# UX-13 P5: regenerate the DOCS screenshots in docs/img/ux13/ from the real page.
|
||||
#
|
||||
# sh goodwe_controller/tools/ux13_screenshots.sh [OUT_DIR]
|
||||
# CHECK=1 sh goodwe_controller/tools/ux13_screenshots.sh # P3 browser check
|
||||
#
|
||||
# Serves tools/ux13_demo.py's fixtures (running, shadow, new, setup) in the CI
|
||||
# test image, then runs tools/ux13_screenshots.py in the Playwright image on a
|
||||
@@ -14,7 +15,9 @@ HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||
OUT=${1:-$HERE/../docs/img/ux13}
|
||||
PW=mcr.microsoft.com/playwright/python:v1.55.0-noble
|
||||
NET=ux13-shots-$$
|
||||
STATES="running shadow new setup"
|
||||
STATES="running shadow new setup modes off off_peak refusal maint"
|
||||
RUN=/t/ux13_screenshots.py
|
||||
[ "${CHECK:-}" = 1 ] && RUN=/t/ux13_browser_check.py
|
||||
mkdir -p "$OUT"
|
||||
OUT=$(cd "$OUT" && pwd)
|
||||
|
||||
@@ -39,5 +42,5 @@ docker run --rm --network "$NET" --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
$ENVS -v "$HERE/tools:/t:ro" -v "$OUT:/out" "$PW" \
|
||||
sh -euc 'pip install -q --disable-pip-version-check --target /tmp/pw \
|
||||
playwright==1.55.0
|
||||
PYTHONPATH=/tmp/pw python3 /t/ux13_screenshots.py /out'
|
||||
PYTHONPATH=/tmp/pw python3 '"$RUN"' /out'
|
||||
echo "screenshots in $OUT"
|
||||
|
||||