UX-13 P3: Simple view and Batteries screen
- Batteries: one card per battery with On/Off, the mode list (cheap hours and selling greyed out until OPT-11 P3), level, and Now / Today / Why lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses the existing pack_use / pack_mode actions and store keys. - An Off battery the peak limit still needs says so with its figure; no line shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned yet" until OPT-11 P3/P4. - Home's plan leaves Off batteries out, except for the peak limit. - Settings opens on Simple: the layout's simple fields with an applies chip and a note, plus the battery switches and modes. Installer is one tap. - English and Dutch words in app/ui_text; test_batteries_screen.py; browser check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and changed screenshots only. No control, allocator, arbiter or command module changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""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 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)
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user