diff --git a/STYLE.md b/STYLE.md index e3b3c8f..1014aae 100644 --- a/STYLE.md +++ b/STYLE.md @@ -7,6 +7,7 @@ This applies to everything a home owner or installer reads: notifications, phone - **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost. - **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body. - **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md. +- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words from `app/homeview.py`, checked by `test_messages.py` part 8. - **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals. - **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3). - **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON. diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py new file mode 100644 index 0000000..c4e56b7 --- /dev/null +++ b/goodwe_controller/app/homeview.py @@ -0,0 +1,932 @@ +"""UX-13 P1: the Home screen's part of `/status`, built from what the add-on +already knows and already plans. Reads only; commands nothing. + +`build(controller)` returns `now`, `batteries`, `plan`, `attention`, +`month_peak`, `control` and `state`. The old fields (`banner`, `rows`, +`checks`, ...) stay beside them for the installer details and for anything +that reads the JSON. See DECISIONS/UX-13-one-layout-table.md and the design +note, section 3.3. + +The plan shows only what the add-on does today: trading on price (the +arbitrage verdict, the same call the control cycle makes), the monthly battery +cycle, the peak limit and "follows the house". Battery modes (OPT-11) are not +built, so nothing here invents a mode plan. Every sentence is in the words +section at the bottom and is held to STYLE.md by test_messages.py. +""" + +from datetime import datetime, timedelta, timezone + +from . import arbitrage, messages, peakguard +from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD +from .maintenance import IDLE as M_IDLE +from .price import Absence, Uncertainty, brussels +from .static_tariff import static_plan +from .tariff import BILLING_TZ +from .uimodel import REGION_PRESETS, options_to_ui + +# A battery at or under this many watts is resting, not charging or giving. +IDLE_W = 50.0 +# Display only: the lowest third of today's price range is "cheap", the top +# third "expensive". A price range under this is flat, and nothing is marked. +FLAT_EUR_KWH = 0.01 + +# Severity words the page shows; the same four as STYLE.md section 3. +ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO + + +# --------------------------------------------------------------------------- # +# Time: today's local day, on the billing calendar +# --------------------------------------------------------------------------- # +def local_day(now: datetime): + """`(midnight_utc, next_midnight_utc)` of the local day holding `now`.""" + here = now.astimezone(BILLING_TZ) + start = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) + nxt = start.date() + timedelta(days=1) + end = datetime(nxt.year, nxt.month, nxt.day, tzinfo=BILLING_TZ) + return start.astimezone(timezone.utc), end.astimezone(timezone.utc) + + +def _hours(t: datetime, day0: datetime) -> float: + return (t - day0).total_seconds() / 3600.0 + + +def hhmm(t: datetime) -> str: + return f"{brussels(t):%H:%M}" + + +def _kw(w) -> str: + return f"{abs(float(w)) / 1000:.1f} kW" + + +def _eur(v) -> str: + return f"€{float(v):.2f}" + + +def _kwh(v) -> str: + return f"{float(v):.1f} kWh" + + +# --------------------------------------------------------------------------- # +# Now +# --------------------------------------------------------------------------- # +def flow(w, *, shadow=False): + """`charging` / `giving` / `resting` / None for one battery power.""" + if w is None: + return None + if shadow: + return "resting" + if w > IDLE_W: + return "giving" + if w < -IDLE_W: + return "charging" + return "resting" + + +def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow): + """The four tiles. `house_w` needs all three readings, so without a solar + reading it is None rather than a number that leaves the sun out.""" + house = None + if has_solar and None not in (grid_w, pv_w, batt_w): + house = float(grid_w) + float(pv_w) + float(batt_w) + return { + "solar_w": None if pv_w is None else float(pv_w), + "house_w": house, + "grid_w": None if grid_w is None else float(grid_w), + "batt_w": None if batt_w is None else float(batt_w), + "has_solar": bool(has_solar), + "lines": { + "solar": tile_solar(pv_w, has_solar), + "house": tile_house(house, has_solar), + "grid": tile_grid(grid_w), + "batt": tile_batt(batt_w, shadow), + }, + } + + +# --------------------------------------------------------------------------- # +# Prices for today's chart +# --------------------------------------------------------------------------- # +def _points(plan): + if plan is None: + return () + return plan.read.match(series=lambda r: tuple(r.points), + no_data=lambda _r: ()) + + +def _spans(points): + """`[(point, end)]`: each point runs until the next one starts, and the + last one for the series' own spacing (15 minutes or an hour).""" + pts = sorted(points, key=lambda p: p.start) + gaps = [b.start - a.start for a, b in zip(pts, pts[1:])] + step = min([g for g in gaps if g > timedelta(0)] or [timedelta(hours=1)]) + step = min(step, timedelta(hours=1)) + out = [] + for i, p in enumerate(pts): + nxt = pts[i + 1].start if i + 1 < len(pts) else None + end = nxt if nxt is not None and nxt - p.start <= timedelta(hours=1) \ + else p.start + step + out.append((p, end)) + return out + + +def _day_prices(c): + cache = getattr(c, "home_prices", None) + if cache is None: + cache = c.home_prices = DayPrices() + return cache + + +class DayPrices: + """Today's prices as seen so far. The refresh keeps only the hours ahead, + so the hours already past are remembered here for the chart. Display only: + nothing reads this back into a decision.""" + + def __init__(self): + self.day = None + self.points = {} + + def see(self, points, day0, day1): + if self.day != day0: + self.day, self.points = day0, {} + for p in points: + if day0 <= p.start < day1: + self.points[p.start] = p + return sorted(self.points.values(), key=lambda p: p.start) + + +def hour_bars(points, day0, day1): + """One bar per local hour: the mean of its prices, or None when unknown. + `kind` is cheap / dear / mid by thirds of today's range (display only).""" + n = int(round(_hours(day1, day0))) + bars = [] + for k in range(n): + a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1) + inside = [p for p in points if a <= p.start < b] + price = (sum(p.eur_per_kwh for p in inside) / len(inside) + if inside else None) + bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a), + "end_label": hhmm(b), + "eur_kwh": None if price is None else round(price, 4), + "estimate": bool(inside) and any(p.provisional + for p in inside), + "kind": None}) + known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None] + if known and max(known) - min(known) >= FLAT_EUR_KWH: + lo, hi = min(known), max(known) + third = (hi - lo) / 3.0 + for b in bars: + p = b["eur_kwh"] + if p is None: + continue + b["kind"] = ("cheap" if p <= lo + third else + "dear" if p >= hi - third else "mid") + elif known: + for b in bars: + if b["eur_kwh"] is not None: + b["kind"] = "mid" + return bars + + +# --------------------------------------------------------------------------- # +# The plan, per battery +# --------------------------------------------------------------------------- # +def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None, + eur=None, price=None): + a = max(0.0, _hours(start, day0)) + z = min(_hours(day1, day0), _hours(end, day0)) + return {"battery": i, "kind": kind, "start": start.isoformat(), + "end": end.isoformat(), "from": hhmm(start), + "to": "24:00" if end == day1 else hhmm(end), + "start_h": round(a, 3), "end_h": round(z, 3), + "what": what, "why": why, "amount": amount, + "kwh": None if kwh is None else round(kwh, 2), + "eur": None if eur is None else round(eur, 2), + "eur_kwh": None if price is None else round(price, 4)} + + +def trade_verdict(c, plan): + """The arbitrage verdict the control cycle would reach on this plan, or a + reason there is none. The SAME call and inputs as `Controller.cycle()`: + `plan.arbitrage()` with the peak guard's state, the cycle cost and the + no-weather uncertainty. Returns `(verdict_or_None, why_key)`.""" + if not arbitrage.enabled(c.o): + return None, "off" + guard = arbitrage.guard_state(c.peak_guard) + if guard is None: + return None, "not_covered" + if plan is None: + return None, "no_prices" + verdict = plan.arbitrage(peak_guard_active=guard, + min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), + uncertainty=frozenset({Uncertainty.NO_WEATHER})) + return verdict.match( + allowed=lambda v: (v, "allowed"), + refused=lambda _v: (None, "peak" if guard else "not_worth")) + + +def _point_end(plan, point): + for p, end in _spans(_points(plan)): + if p.start == point.start: + return end + return point.start + timedelta(hours=1) + + +def trade_runs(c, plan, verdict, day0, day1, now, shadow): + """A charge block and a give block on each battery, where the verdict put + them. The site trades as one; each battery's share is capped by its own + rating and by the room its level leaves (arbitrage's own floor/ceiling).""" + floor, ceiling = arbitrage.soc_bounds(c.o) + runs, later = [], [] + for kind, pt in (("grid", verdict.cheapest), ("give", verdict.dearest)): + end = _point_end(plan, pt) + if end <= now: + continue + if not (day0 <= pt.start < day1): + later.append((kind, pt)) + continue + hrs = (end - pt.start).total_seconds() / 3600.0 + for i, inv in enumerate(c.inverters): + kwh = inv.max_w / 1000.0 * hrs + if inv.capacity_kwh and inv.soc is not None: + room = (ceiling - inv.soc if kind == "grid" + else inv.soc - floor) + kwh = min(kwh, max(0.0, room) / 100.0 * inv.capacity_kwh) + eur = kwh * pt.eur_per_kwh + if kind == "grid": + what, why, amount = (run_grid_what(kwh, shadow), + run_grid_why(pt.eur_per_kwh), + run_grid_amount(eur)) + else: + what, why, amount = (run_give_what(kwh, shadow), + run_give_why(pt.eur_per_kwh), + run_give_amount(eur)) + runs.append(_run(i, kind, pt.start, end, day0, day1, what, why, + amount, kwh=kwh, eur=eur, price=pt.eur_per_kwh)) + return runs, later + + +def maintenance_runs(c, day0, day1, now, shadow): + """The monthly battery cycle: under way now, or starting later today. + The cycle starts in the hour `maintenance_start_hour` names, read as the + control cycle reads it (UTC hour of `now`), shown in local time.""" + runs, lines = [], [] + waiting = [] + for i, (inv, m) in enumerate(zip(c.inverters, c.maints)): + cfg = m.cfg + if not cfg.enabled: + continue + cap = inv.capacity_kwh + if m.phase != M_IDLE: + hours = _cycle_hours(m.phase, inv.soc, cap, cfg) + end = now + timedelta(hours=hours or 1.0) + runs.append(_run(i, "cycle", now, end, day0, day1, + run_cycle_what(m.phase, shadow), + run_cycle_why(shadow), + run_cycle_amount(hours))) + continue + if not m.due(now) and (m.next_due(now) is None + or m.next_due(now) >= day1): + continue + start = now.replace(hour=cfg.start_hour, minute=0, second=0, + microsecond=0) + if start + timedelta(hours=1) <= now: + start += timedelta(days=1) + due_at = m.next_due(now) + if due_at is not None and due_at > start: + continue + if not (day0 <= start < day1): + continue + waiting.append((m.waited_days(now), i, start)) + # One battery cycles at a time (maintenance.arbitrate); the one waiting + # longest goes first, and none starts while another is under way. + busy = any(m.phase != M_IDLE for m in c.maints) + waiting.sort(key=lambda t: (-t[0], t[1])) + for n, (_w, i, start) in enumerate(waiting): + inv, m = c.inverters[i], c.maints[i] + hours = _cycle_hours(M_DRAIN, inv.soc, inv.capacity_kwh, m.cfg) + if n == 0 and not busy: + end = start + timedelta(hours=hours or 1.0) + runs.append(_run(i, "cycle", start, end, day0, day1, + run_cycle_what(M_IDLE, shadow), + run_cycle_why(shadow), + run_cycle_amount(hours))) + else: + lines.append(line_cycle_waits(inv.name)) + return runs, lines + + +def _cycle_hours(phase, soc, cap_kwh, cfg): + """Hours left in a cycle, or None when the capacity or level is unknown.""" + if phase == M_HOLD: + return cfg.hold_min / 60.0 + if not cap_kwh or soc is None: + return None + hours = cfg.hold_min / 60.0 + fill_from = soc if phase == M_CHARGE else cfg.soc_floor + hours += (max(0.0, cfg.soc_target - fill_from) / 100.0 * cap_kwh + / max(cfg.charge_w / 1000.0, 0.1)) + if phase not in (M_CHARGE,): + hours += (max(0.0, soc - cfg.soc_floor) / 100.0 * cap_kwh + / max(cfg.discharge_w / 1000.0, 0.1)) + return hours + + +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.""" + pg = c.peak_guard + if pg is None or not pg.engaged: + return [] + start = now - timedelta(minutes=now.minute % 15, seconds=now.second, + microseconds=now.microsecond) + end = start + timedelta(minutes=15) + 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))] + + +def house_runs(n, day0, day1, now, shadow): + """"Follows the house" for the rest of the day, drawn under the others.""" + 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)] + + +def region_of(opts): + """The levies preset and its year, as the page model has it (UX-13 P0).""" + try: + ui = options_to_ui(opts) + except Exception: # noqa: BLE001 + return {"id": None, "year": None} + rid = ui.get("ui.region") + return {"id": rid, "year": ui.get("region_year") + if rid in REGION_PRESETS else None} + + +def build_plan(c, now, shadow, running, held=False): + day0, day1 = local_day(now) + fresh = c.fresh_tariff_plan(now) + plan = fresh + if plan is None or plan.origin == "static": + # A static profile is pure arithmetic: price the whole day from it. + try: + static = static_plan(c.o, day0, day1, now=now) + if static.has_prices: + plan = static + except Exception: # noqa: BLE001 + pass + seen = _day_prices(c).see(_points(plan), day0, day1) + bars = hour_bars(seen, day0, day1) + + limit_w = c.peak.cap_w # the limit the controller parsed at start + runs, lines = [], [] + # 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) + if verdict is not None: + trade, later = trade_runs(c, fresh, verdict, day0, day1, now, + shadow) + runs += trade + for kind, pt in later: + lines.append(line_trade_tomorrow(kind, hhmm(pt.start), + pt.eur_per_kwh, shadow)) + else: + lines.append(line_no_trade(trade_why)) + cyc, waits = maintenance_runs(c, day0, day1, now, shadow) + runs += cyc + lines += waits + runs += peak_run(c, day0, day1, now, shadow, limit_w) + if c.p1_enabled: + lines.append(line_peak_limit(limit_w, shadow)) + lines = [x for x in lines if x] + + kinds = {r["kind"] for r in runs} + if held: + verdict_key = "held" + elif not running: + verdict_key = "stopped" + elif "cycle" in kinds: + verdict_key = "cycle" + elif "grid" in kinds or "give" in kinds: + verdict_key = "trade" + else: + verdict_key = "house" + + known = [b for b in bars if b["eur_kwh"] is not None] + price_now = None + try: + if plan is not None: + price_now = plan.price_at(now) + except Absence: + price_now = None + feed = fresh is not None and fresh.from_feed + tomorrow = any(not p.provisional and p.start >= day1 + for p in _points(fresh)) + region = region_of(c.o) + return { + "day": brussels(now).date().isoformat(), + "now_h": round(_hours(now, day0), 3), + "hours": int(round(_hours(day1, day0))), + "verdict": VERDICT[verdict_key], + "verdict_key": verdict_key, + "head": plan_head(verdict_key, shadow), + "prices": bars, + "runs": runs, + "lines": lines, + "why": c.why_words, + "price_line": line_prices( + price_now, + min(known, key=lambda b: b["eur_kwh"]) if known else None, + max(known, key=lambda b: b["eur_kwh"]) if known else None), + "tomorrow_line": line_tomorrow(tomorrow) if feed else "", + "region": region, + "region_line": (line_region(region["id"], region["year"]) + if feed else ""), + "has_prices": bool(known), + } + + +# --------------------------------------------------------------------------- # +# Batteries, attention, month peak, control +# --------------------------------------------------------------------------- # +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) + 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 + rows.append({ + "name": inv.name, + "level_pct": None if inv.soc is None else float(inv.soc), + "power_w": None if inv.batt is None else float(inv.batt), + "capacity_kwh": cap, + "stored_kwh": (None if cap is None or inv.soc is None + else round(cap * float(inv.soc) / 100.0, 2)), + "state": flow(inv.batt, shadow=shadow), + "mode": MODE_HOUSE, + "maintenance": phase, + "now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow, + running=running, held=held, + would_w=shares.get(inv.name)), + # Per-battery reasons arrive with battery modes (OPT-11). + "why": None, + }) + return rows + + +# Row id -> the title on Home. The row's own sentence is the body. +ATTN_TITLES = { + "controller": "Control is not running", + "meter": "Meter reading", + "meter_frozen": messages.T_METER_FROZEN, + "freeze_check": "Stuck meter check", + "month_peak": "This month's peak", + "peak_guard": "Peak limit", + "peak_forecast": "Peak limit forecast", + "sensor": "Battery readings", + "control": "Inverter control", + "power_too_high": messages.T_OUTPUT_CLAMP, + "not_keeping_up": messages.T_LOOP_BOUND, + "write": "Power request not accepted", + "direction": "Power went the wrong way", + "site_direction": "Power went the wrong way", + "circulating": messages.T_CIRCULATING, + "delivery": messages.S_NOT_DELIVERING, + "estop": "Emergency stop", + "pv": "Solar reading", + None: "Inverter connection", +} +# Row id -> the DOCS.md heading under Notifications, where one exists. +ATTN_DOCS = { + "meter_frozen": messages.T_METER_FROZEN, + "power_too_high": messages.T_OUTPUT_CLAMP, + "not_keeping_up": messages.T_LOOP_BOUND, + "circulating": messages.T_CIRCULATING, + "delivery": messages.S_NOT_DELIVERING, +} + + +def attention(checks): + """Every row that is not plain green: red rows as alarms, amber rows as + warnings, and green-but-amber rows (a state still settling) as info. + Most urgent first; within a level, the page's own row order.""" + rank = {ALARM: 0, WARNING: 1, INFO: 2} + out = [] + for row in checks: + ok, warn = bool(row.get("ok")), bool(row.get("warn")) + if ok and not warn: + continue + sev = INFO if ok else (WARNING if warn else ALARM) + rid = row.get("id") + out.append({"severity": sev, "id": rid, + "title": ATTN_TITLES.get(rid, ATTN_TITLES[None]), + "body": row.get("text", ""), + "docs": ATTN_DOCS.get(rid)}) + out.sort(key=lambda a: rank[a["severity"]]) + return out + + +def month_peak(c, now): + """This month's peak against the limit, and the 15-minute period so far.""" + limit_w = c.peak.cap_w # the limit the controller parsed at start + local = brussels(now) + out = {"month": f"{local:%B}", "limit_w": limit_w, "peak_w": None, + "pct": None, "period_w": None, "state": "not_covered", + "line": ""} + if not c.p1_enabled: + out["line"] = line_peak_not_covered() + return out + reading = c.peak.peak(now) + if reading.is_unreadable: + out["state"] = "unreadable" + elif reading.is_observed: + out["state"] = "observed" + out["peak_w"] = float(reading.peak_w) + out["pct"] = round(100.0 * float(reading.peak_w) / limit_w, 1) \ + if limit_w > 0 else None + else: + out["state"] = "none" + pg = c.peak_guard + ceiling = limit_w + if pg is not None: + out["period_w"] = pg.projected_w + ceiling = pg.ceiling_w or limit_w + out["line"] = line_period(out["period_w"], ceiling, c.shadow) + return out + + +def state_of(c, level, shadow): + """One word for the pill and the banner's colour: running, shadow, + stopped, alarm or warning.""" + if shadow: + return "shadow" + if level == "bad": + return "alarm" + if not c.auto: + return "stopped" + if level == "warn": + return "warning" + return "running" + + +def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict: + """The structured half of `/status`. Pure reads of the controller.""" + now = now or datetime.now(timezone.utc) + shadow = bool(c.shadow) + kind = state_of(c, level, shadow) + # A red banner means every inverter is at 0 W (the status cascade), so + # nothing is planned until it clears. Shadow mode still plans: it is + # what the add-on would do. + held = level == "bad" and not shadow + running = (bool(c.auto) or shadow) and not held + ctl = next((r for r in checks if r.get("id") == "controller"), None) + return { + "state": {"kind": kind, "pill": pill(kind), + "ran": ctl.get("text", "") if ctl else "", + "sub": banner_sub(kind)}, + "now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w, + has_solar=bool(c.pv_roster), shadow=shadow), + "batteries": battery_rows(c, shadow, running, held), + "plan": build_plan(c, now, shadow, running, held=held), + "attention": ([{"severity": INFO, "id": "shadow", + "title": ATTN_SHADOW, "body": attn_shadow_body(), + "docs": None}] if shadow else []) + attention(checks), + "month_peak": month_peak(c, now), + "control": {"running": bool(c.auto), "shadow": shadow, + "text": control_text(bool(c.auto), shadow), + "maintenance": c.maint.phase, + "docs_url": _docs_url()}, + } + + +def _docs_url(): + slug = messages.addon_slug() + return None if slug is None else messages.docs_url(slug, + messages._site["ha"]) + + +# =========================================================================== # +# The words. STYLE.md: plain, short, present tense. A plan line is at most +# two sentences of at most 12 words each (UX-13 design, section 4); a title at +# most 5 words; everything else one sentence of at most 25 words. In shadow +# mode a text says what the add-on WOULD do. test_messages.py renders every +# function below through `text_samples()` and checks it. +# =========================================================================== # +MODE_HOUSE = "Follows the house" +ATTN_SHADOW = "Shadow mode is on" + + +def attn_shadow_body() -> str: + return "The add-on decides everything, but every inverter stays at 0 W." + +VERDICT = { + "held": "Held at 0 W", + "stopped": "Stopped", + "cycle": "Monthly cycle today", + "trade": "Trading on price", + "house": "Follows the house", +} + + +def _w(shadow, live, would): + return would if shadow else live + + +def pill(kind) -> str: + return {"running": "Running", "shadow": "Shadow mode", + "stopped": "Stopped", "alarm": "Needs you now", + "warning": "Running, with a warning"}[kind] + + +def banner_sub(kind) -> str: + return { + "running": "The batteries follow the plan below.", + "shadow": "The plans below say what the add-on would do.", + "stopped": "Start control below when you are ready.", + "alarm": "See Needs attention below for what to do.", + "warning": "See Needs attention below.", + }[kind] + + +def tile_solar(pv_w, has_solar) -> str: + if not has_solar: + return "Not set up" + if pv_w is None: + return "No reading" + return "Producing" if pv_w > IDLE_W else "Not producing" + + +def tile_house(house_w, has_solar) -> str: + if not has_solar: + return "Needs a solar reading" + return "No reading" if house_w is None else "Using" + + +def tile_grid(grid_w) -> str: + if grid_w is None: + return "No reading" + if grid_w > IDLE_W: + return "Taking from the grid" + if grid_w < -IDLE_W: + return "Feeding in" + return "Balanced" + + +def tile_batt(batt_w, shadow) -> str: + if shadow: + return "Held at 0 W" + return {"giving": "Giving power", "charging": "Charging", + "resting": "Resting", None: "No reading"}[flow(batt_w)] + + +def battery_now(soc, batt_w, *, phase, shadow, running, held=False, + would_w=None) -> str: + if soc is None or batt_w is None: + return "No reading from this battery." + if shadow: + if would_w is None or abs(would_w) <= IDLE_W: + return "Held at 0 W. Would rest now." + verb = "give" if would_w > 0 else "charge" + return f"Held at 0 W. Would {verb} {_kw(would_w)} now." + if held: + return "Held at 0 W until the alarm clears." + if not running: + return "Held at 0 W while control is stopped." + if phase != M_IDLE: + return {M_DRAIN: "Monthly cycle: emptying.", + M_CHARGE: "Monthly cycle: refilling.", + M_HOLD: "Monthly cycle: holding full."}.get( + phase, "Monthly cycle under way.") + st = flow(batt_w) + if st == "giving": + return f"Giving {_kw(batt_w)} now." + if st == "charging": + return f"Charging {_kw(batt_w)} now." + return "Resting now." + + +def plan_head(key, shadow) -> str: + if key == "held": + return "Every inverter is at 0 W until the alarm clears." + if key == "stopped": + return "Control is stopped, so nothing is planned." + if not shadow: + return { + "cycle": "A battery runs its monthly cycle today.", + "trade": "Charges in the cheapest hour, gives power in the dearest.", + "house": "Charges from spare solar and covers the house.", + }[key] + return "Shadow mode: this is what the add-on would do. " + { + "cycle": "A battery would run its monthly cycle today.", + "trade": "Would charge when cheapest and give power when dearest.", + "house": "Would charge from spare solar and cover the house.", + }[key] + + +def run_grid_what(kwh, shadow) -> str: + return _w(shadow, "Charges", "Would charge") + \ + f" up to {_kwh(kwh)} from the grid." + + +def run_grid_why(price) -> str: + return f"Cheapest price in the list, {_eur(price)} per kWh." + + +def run_grid_amount(eur) -> str: + return f"Costs up to {_eur(eur)}." + + +def run_give_what(kwh, shadow) -> str: + return _w(shadow, "Gives", "Would give") + \ + f" up to {_kwh(kwh)}, to the house first." + + +def run_give_why(price) -> str: + return f"Dearest price in the list, {_eur(price)} per kWh." + + +def run_give_amount(eur) -> str: + return f"Worth up to {_eur(eur)} at that price." + + +def run_cycle_what(phase, shadow) -> str: + if phase == M_IDLE: + return _w(shadow, "Starts", "Would start") + \ + " the monthly battery cycle." + return _w(shadow, "Runs", "Would run") + " the monthly battery cycle now." + + +def run_cycle_why(shadow=False) -> str: + return _w(shadow, "Empties, refills and holds full", + "Would empty, refill and hold full") + \ + ", so the level reads right." + + +def run_cycle_amount(hours) -> str: + if hours is None: + return "How long depends on the battery." + return f"Takes about {max(1, round(hours))} hours." + + +def run_peak_what(limit_w, shadow) -> str: + return _w(shadow, "Gives", "Would give") + \ + f" power to stay under {_kw(limit_w)}." + + +def run_peak_why() -> str: + return "The peak limit comes before everything else." + + +def run_house_what(shadow) -> str: + return _w(shadow, "Follows", "Would follow") + " the house." + + +def run_house_why(shadow=False) -> str: + return _w(shadow, "Charges from spare solar, gives power", + "Would charge from spare solar, give power") + \ + " when the house needs it." + + +def run_house_amount() -> str: + return "Amounts depend on the sun and the house." + + +def line_no_trade(key) -> str: + return { + "off": "", + "not_covered": "No trading on price: nothing measures the 15-minute " + "peak.", + "no_prices": "No trading on price yet: no prices have arrived.", + "peak": "No trading on price now: the peak limit comes first.", + "not_worth": "No trading on price today. The price gap is too small.", + }[key] + + +def line_trade_tomorrow(kind, at, price, shadow) -> str: + verb = {"grid": _w(shadow, "Charges", "Would charge"), + "give": _w(shadow, "Gives power", "Would give power")}[kind] + return f"Tomorrow: {verb.lower()} at {at}, {_eur(price)} per kWh." + + +def line_cycle_waits(name) -> str: + return f"{name} waits its turn for the monthly cycle." + + +def line_peak_limit(limit_w, shadow) -> str: + return _w(shadow, "The batteries step in", "The batteries would step in") \ + + f" above {_kw(limit_w)}." + + +def line_prices(now_eur, lo, hi) -> str: + parts = [] + if now_eur is not None: + parts.append(f"Price now {_eur(now_eur)} per kWh, all in.") + if lo is not None and hi is not None: + parts.append(f"Today: cheapest {_eur(lo['eur_kwh'])} at " + f"{lo['label']}, dearest {_eur(hi['eur_kwh'])} at " + f"{hi['label']}.") + return " ".join(parts) or "No prices for today." + + +def line_tomorrow(known) -> str: + return ("Tomorrow's prices are in." if known else + "Tomorrow's prices usually arrive after 13:00.") + + +def line_region(rid, year) -> str: + if rid in REGION_PRESETS and year: + return f"Levies and VAT: {REGION_WORDS.get(rid, rid)}, {year} figures." + return "Levies and VAT: your own figure." + + +REGION_WORDS = {"flanders": "Flanders, Fluvius Antwerpen"} + + +def line_peak_not_covered() -> str: + return "No peak limit: this meter does not measure 15-minute periods." + + +def line_period(period_w, limit_w, shadow) -> str: + step = _w(shadow, "The batteries step in", "The batteries would step in") + if period_w is None: + return f"{step} above {_kw(limit_w)}." + return (f"This 15-minute period is heading for {_kw(period_w)}. " + f"{step} above {_kw(limit_w)}.") + + +def control_text(running, shadow) -> str: + if shadow: + return "Shadow mode: every inverter stays at 0 W." + if running: + return "Control is running." + return "Control is stopped. Every inverter is at 0 W." + + +def text_samples() -> list: + """`(kind, text)` for every sentence above, rendered with real values, so + test_messages.py can hold each to its STYLE limit. Kinds: title, plan + (2 sentences of 12 words), line (1 sentence of 25 words).""" + out = [("title", t) for t in VERDICT.values()] + out += [("title", t) for t in ATTN_TITLES.values()] + out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW), + ("line", attn_shadow_body())] + for k in ("running", "shadow", "stopped", "alarm", "warning"): + out += [("title", pill(k)), ("line", banner_sub(k))] + for sh in (False, True): + out += [("title", tile_solar(v, s)) for v, s in + ((None, False), (None, True), (3900, True), (0, True))] + out += [("title", tile_house(v, s)) for v, s in + ((None, False), (None, True), (1100, True))] + out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)] + out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)] + for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD): + for w in (2500, -2500, 0): + out.append(("plan", battery_now(62, w, phase=phase, + shadow=sh, running=True, + would_w=-2400))) + out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh, + running=False))) + out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False, + running=False, held=True))) + out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh, + running=True))) + for k in VERDICT: + out.append(("plan", plan_head(k, sh))) + out += [("plan", run_grid_what(12.34, sh)), + ("plan", run_give_what(9.6, sh)), + ("plan", run_cycle_what(M_IDLE, sh)), + ("plan", run_cycle_what(M_DRAIN, sh)), + ("plan", run_peak_what(3500, sh)), + ("plan", run_house_what(sh)), + ("plan", line_trade_tomorrow("grid", "02:00", 0.1234, sh)), + ("plan", line_trade_tomorrow("give", "19:00", 0.3612, sh)), + ("plan", line_peak_limit(3500, sh)), + ("plan", line_period(1240, 3500, sh)), + ("plan", line_period(None, 3500, sh)), + ("plan", control_text(True, sh)), + ("plan", control_text(False, sh))] + out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)), + ("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)), + ("plan", run_cycle_why()), ("plan", run_cycle_why(True)), + ("plan", run_cycle_amount(None)), + ("plan", run_cycle_amount(5.4)), ("plan", run_peak_why()), + ("plan", run_house_why()), ("plan", run_house_why(True)), + ("plan", run_house_amount()), + ("plan", line_cycle_waits("Battery B")), + ("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"}, + {"eur_kwh": 0.36, "label": "19:00"})), + ("plan", line_prices(None, None, None)), + ("plan", line_tomorrow(True)), ("plan", line_tomorrow(False)), + ("plan", line_region("flanders", 2026)), + ("plan", line_region("own", None)), + ("plan", line_peak_not_covered())] + out += [("plan", line_no_trade(k)) for k in + ("not_covered", "no_prices", "peak", "not_worth")] + return out diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 03a008f..b35d5c8 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -54,6 +54,7 @@ from .clamp import ( clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note, ) from . import freshness +from . import homeview from . import messages from .freeze import ( FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w, @@ -1047,6 +1048,9 @@ class Controller: # because `self.peak_guard` is a different cycle's decision by the time # the status page renders. self.arb_headroom_w = None + # UX-13 P1: today's prices as the Home chart has seen them. Display + # only; nothing reads it back into a decision. + self.home_prices = homeview.DayPrices() # TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at` # is the exact, derived statement of "the latest cycle did not get as # far as the arbitrage step", which is the fact the *Arbitrage* row @@ -5508,7 +5512,7 @@ class Controller: decided_at=self.arb_at, cycle_at=self.cycle_at, now=datetime.now(timezone.utc), why=self.why_words)), ] - return { + out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, # The action log itself, newest first. The page does not show it @@ -5517,6 +5521,21 @@ class Controller: # peak guard's projection) are written nowhere else. "events": list(self.events), } + # UX-13 P1: the Home screen's structured half. Read-only, and it may + # never cost the page its rows: a fault here is logged and the old + # fields are served alone (the page then shows the installer details). + try: + out.update(homeview.build(self, level=level, checks=checks)) + except Exception: # noqa: BLE001 + # Once per process at ERROR: the page polls every 3 s. + if not getattr(self, "_home_failed", False): + self._home_failed = True + _LOG.exception("Home view could not be built, so the page " + "shows the plain status rows only") + else: + _LOG.debug("Home view failed again", exc_info=True) + out["home_error"] = True + return out async def handle_action(self, what: str): now = datetime.now(timezone.utc) diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css new file mode 100644 index 0000000..826b35d --- /dev/null +++ b/goodwe_controller/app/static/app.css @@ -0,0 +1,137 @@ +/* UX-13 page styles. Colours follow the approved mockup (UX-13-mockup.html, + version 2) and prefers-color-scheme. No web fonts, no external files. */ +:root{ + --bg:#f3f4f6;--card:#ffffff;--ink:#1b1d21;--muted:#5b626c;--faint:#8a919b;--line:#e2e5e9; + --accent:#1c64d9;--accent-soft:#e6efff;--on-accent:#ffffff; + --ok:#17773a;--ok-bg:#e6f4eb;--warn:#955300;--warn-bg:#fcefd9;--bad:#c02626;--bad-bg:#fce8e8;--info:#5b626c;--info-bg:#eceef1; + --solar:#c98a00;--house:#6c47c2;--grid:#56657a;--batt:#17773a; + --cheap:#8fcca4;--dear:#f0ad72;--mid:#cfd5dc;--track:#dde1e6;--shadow:0 1px 2px rgba(0,0,0,.05); +} +@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){ + --bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038; + --accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220; + --ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d; + --solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98; + --cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none; +}} +:root[data-theme="dark"]{ + --bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038; + --accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220; + --ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d; + --solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98; + --cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none; +} +*{box-sizing:border-box} +[hidden]{display:none!important} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden} +button,input,select{font:inherit;color:inherit} +a{color:var(--accent)} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.top{display:flex;align-items:center;gap:12px;padding:12px 16px 8px;max-width:820px;margin:0 auto} +.logo{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);display:grid;place-items:center;color:var(--accent);flex:none} +.app{font-weight:700;font-size:17px;line-height:1.2} +.pill{display:flex;flex-wrap:wrap;align-items:center;gap:0 6px;font-size:13px;color:var(--muted)} +.dot{width:8px;height:8px;border-radius:50%;background:var(--ok);flex:none} +.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)} +.tabs{display:flex;gap:4px;max-width:820px;margin:0 auto;padding:0 16px;border-bottom:1px solid var(--line)} +.tab{border:0;background:none;padding:10px 12px;border-bottom:2px solid transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;min-height:44px;position:relative} +.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} +.tab svg{width:20px;height:20px} +.badge{font-size:11px;font-weight:700;background:var(--warn);color:var(--card);border-radius:9px;padding:0 6px;line-height:18px} +.badge.bad{background:var(--bad)} +@media (max-width:640px){ + .tabs{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--card);border-top:1px solid var(--line);border-bottom:0;padding:0 4px env(safe-area-inset-bottom);justify-content:space-around;gap:0} + .tab{flex-direction:column;gap:0;font-size:11px;padding:6px 4px;border-bottom:0;border-top:2px solid transparent;flex:1;min-width:0} + .tab[aria-selected="true"]{border-top-color:var(--accent)} + .tab .badge{position:absolute;top:2px;left:calc(50% + 6px)} + .wrap{padding-bottom:96px!important} +} +.wrap{max-width:820px;margin:0 auto;padding:16px 16px 40px} +h2{font-size:15px;margin:0 0 10px;font-weight:650} +.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:var(--shadow)} +.muted{color:var(--muted)} .small{font-size:13px} .tiny{font-size:12px} +.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.spread{justify-content:space-between} +.grow{flex:1 1 auto;min-width:0} +.banner{border-radius:12px;padding:12px 14px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start;font-weight:600} +.banner .ic{flex:none;margin-top:2px} +.b-ok{background:var(--ok-bg);color:var(--ok)} .b-warn{background:var(--warn-bg);color:var(--warn)} .b-bad{background:var(--bad-bg);color:var(--bad)} +.banner small{display:block;font-weight:400;color:var(--ink);margin-top:2px} +.tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px} +@media (max-width:640px){.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}} +.tile{border:1px solid var(--line);border-radius:10px;padding:10px;min-width:0} +.tile .lab{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:6px} +.tile .val{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.25;margin-top:2px} +.tile .val small{font-size:13px;font-weight:500;color:var(--muted)} +.tile .sub{font-size:12px;color:var(--muted)} +.sw-solar{color:var(--solar)} .sw-house{color:var(--house)} .sw-grid{color:var(--grid)} .sw-batt{color:var(--batt)} +.brow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:10px 0;border-top:1px solid var(--line)} +.brow:first-child{border-top:0} +.lvl{height:10px;background:var(--track);border-radius:6px;overflow:hidden;grid-column:1/-1;margin:4px 0 2px} +.lvl>i{display:block;height:100%;background:var(--batt);border-radius:6px} +.lvl.none>i{background:var(--faint)} +/* the plan chart */ +.chart{position:relative;margin:18px 0 4px} +.bars{display:flex;align-items:flex-end;gap:2px;height:72px} +.bars i{flex:1;border-radius:3px 3px 0 0;background:var(--mid);min-width:0} +.bars i.cheap{background:var(--cheap)} +/* not by colour alone: expensive hours are striped, estimates are faded */ +.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)} +.bars i.est{opacity:.55} +.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important} +.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px} +.lane b{position:absolute;top:2px;bottom:2px;border-radius:3px;min-width:3px} +.lane .house{background:repeating-linear-gradient(90deg,var(--mid) 0 6px,transparent 6px 9px);top:6px;bottom:6px} +.lane .grid{background:var(--accent)} +.lane .give{background:repeating-linear-gradient(135deg,var(--house) 0 4px,color-mix(in srgb,var(--house) 55%,var(--card)) 4px 7px)} +.lane .cycle{background:repeating-linear-gradient(90deg,var(--warn) 0 3px,var(--warn-bg) 3px 6px)} +.lane .peak{background:var(--bad)} +.lanelab{font-size:12px;color:var(--muted);margin-top:6px} +.nowline{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none} +.nowline span{position:absolute;top:-15px;left:-10px;font-size:10px} +.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:4px} +.legend{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted);margin-top:8px} +.legend span{display:inline-flex;align-items:center;gap:5px} +.legend i{width:14px;height:10px;border-radius:2px;display:inline-block} +.legend i.house{background:repeating-linear-gradient(90deg,var(--mid) 0 4px,transparent 4px 6px)} +ul.plain{list-style:none;margin:8px 0 0;padding:0} +ul.plain li{padding:6px 0;border-top:1px solid var(--line)} +ul.plain li:first-child{border-top:0} +.meter{height:12px;background:var(--track);border-radius:6px;overflow:hidden;margin:8px 0 4px} +.meter i{display:block;height:100%;background:var(--accent)} +.meter i.warn{background:var(--warn)} .meter i.bad{background:var(--bad)} +.alert{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--line)} +.alert:first-child{border-top:0} +.sev{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:13px;font-weight:800} +.sev.warning{background:var(--warn-bg);color:var(--warn)} .sev.alarm{background:var(--bad-bg);color:var(--bad)} .sev.info{background:var(--info-bg);color:var(--info)} .sev.ok{background:var(--ok-bg);color:var(--ok)} +.alert .t{font-weight:600} +.btn{border:1px solid var(--line);background:var(--card);padding:9px 14px;border-radius:10px;cursor:pointer;min-height:44px} +.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600} +.btn.danger{color:var(--bad);border-color:var(--bad)} +.btn:disabled{opacity:.5;cursor:not-allowed} +a.btn{display:inline-flex;align-items:center;text-decoration:none;color:var(--ink)} +details.more{margin-top:12px;border-top:1px solid var(--line)} +details.more>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center} +table.rows{width:100%;border-collapse:collapse;font-size:13px} +table.rows td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top} +table.rows td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;padding-left:12px;overflow-wrap:anywhere} +table.rows tr:last-child td{border-bottom:0} +ul.checks{margin:6px 0 0;padding-left:0;list-style:none;font-size:13px} +ul.checks li{margin:3px 0} +.c-ok{color:var(--ok)} .c-warn{color:var(--warn)} .c-bad{color:var(--bad)} +.kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px;font-size:14px;margin-top:10px} +.kv dt{color:var(--muted)} .kv dd{margin:0} +#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;z-index:30;background:var(--ink);color:var(--bg);padding:10px 14px;border-radius:10px;font-size:14px;max-width:calc(100% - 32px);opacity:0;pointer-events:none;transition:opacity .2s} +#toast.show{opacity:1} +@media (max-width:640px){#toast{bottom:84px}} +dialog{border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);max-width:min(420px,calc(100% - 32px));padding:18px} +dialog::backdrop{background:rgba(0,0,0,.45)} +@media (prefers-reduced-motion:reduce){*{transition:none!important}} +.tip{position:fixed;z-index:40;background:var(--ink);color:var(--bg);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:8px;pointer-events:none;max-width:240px;opacity:0;transition:opacity .1s;box-shadow:0 2px 8px rgba(0,0,0,.25);left:0;top:0} +.tip.show{opacity:1} +.tip b{display:block;font-weight:700} +.tip span{display:block} +.chart [data-tip]{cursor:default} +.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px} +.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js new file mode 100644 index 0000000..01f9d0a --- /dev/null +++ b/goodwe_controller/app/static/app.js @@ -0,0 +1,368 @@ +"use strict"; +/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no + external files. Every URL is relative, because ingress serves the add-on + under a generated path prefix. Every word comes from app/ui_text (fixed + labels) or from /status (sentences built by app/homeview.py), so STYLE.md + is checked in Python. Data is always set as text, never as HTML. */ +(function () { + const T = JSON.parse(document.getElementById("ui-text").textContent).home; + const $ = (s) => document.querySelector(s); + const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T); + function h(tag, attrs, ...kids) { + const e = document.createElement(tag); + for (const k in attrs || {}) { + const v = attrs[k]; + if (v === false || v == null) continue; + if (k === "class") e.className = v; + else if (k === "style") e.style.cssText = v; + else if (k.startsWith("on")) e.addEventListener(k.slice(2), v); + else e.setAttribute(k, v === true ? "" : v); + } + for (const c of kids.flat()) { + if (c == null || c === false) continue; + e.append(c.nodeType ? c : document.createTextNode(String(c))); + } + return e; + } + const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1); + const eur = (v) => "€" + Number(v).toFixed(2); + + // Fixed labels from ui_text. + document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; }); + document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); }); + + let S = null; // the last /status + let busy = false; + + /* ---------------- toast and confirm dialog ---------------- */ + function toast(text) { + const e = $("#toast"); e.textContent = text; e.classList.add("show"); + clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600); + } + let onYes = null; + function ask(key, fn) { + $("#dlg-t").textContent = t("dialog." + key + ".name"); + $("#dlg-b").textContent = t("dialog." + key + ".description"); + $("#dlg-yes").textContent = t("dialog." + key + ".yes"); + onYes = fn; + const d = $("#dlg"); + if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn(); + } + $("#dlg-no").addEventListener("click", () => $("#dlg").close()); + $("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); }); + + /* ---------------- data ---------------- */ + async function getStatus() { + const r = await fetch("status", { cache: "no-store" }); + if (!r.ok) throw new Error("status " + r.status); + return r.json(); + } + async function act(what) { + const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: what }) }); + if (!r.ok) throw new Error("action " + r.status); + return r.json(); + } + async function refresh() { + if (busy) return; + busy = true; + try { S = await getStatus(); render(); } + catch (e) { offline(); } + finally { busy = false; } + } + + /* ---------------- shell ---------------- */ + function banner(el, level, title, sub, role) { + el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null }, + h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"), + h("span", {}, title, sub ? h("small", {}, sub) : null))); + } + function offline() { + banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert"); + $("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name"))); + } + function renderPill() { + const st = S.state || null; + const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn") + : (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn"); + const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner; + $("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words)); + } + + function render() { + renderPill(); + const structured = S.now && S.plan && !S.home_error; + banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null); + ["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; }); + if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); } + renderControl(structured); + renderDetails(!structured); + renderBatteries(); + renderSettings(); + } + + /* ---------------- Home: now ---------------- */ + function val(id, w, show) { + const e = $(id); + if (w == null || !show) { e.textContent = "–"; return; } + e.replaceChildren(kw(w) + " ", h("small", {}, "kW")); + } + function levelBar(pct, label) { + const known = pct != null; + return h("div", { class: "lvl" + (known ? "" : " none"), role: "img", + "aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") }, + h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" })); + } + function renderNow() { + const n = S.now; + val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar; + val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house; + val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid; + val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt; + $("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" }, + h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)), + h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"), + levelBar(b.level_pct, b.name), + h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now)))); + } + + /* ---------------- Home: today's plan ---------------- */ + const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"], + ["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"], + ["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"], + ["peak", "background:var(--bad)"], ["house", null]]; + function tipText(lines) { return lines.filter(Boolean).join(" "); } + function chartItem(tag, cls, style, title, lines) { + return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)), + "data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) }); + } + function roving(group) { + const items = [...group.querySelectorAll("[data-tip]")]; + items.forEach((el, i) => { + el.tabIndex = i === 0 ? 0 : -1; + el.addEventListener("keydown", (e) => { + const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key]; + if (d === undefined) return; + e.preventDefault(); + const j = Math.max(0, Math.min(items.length - 1, i + d)); + items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus(); + }); + }); + } + function renderPlan() { + const p = S.plan; + $("#verdict").textContent = p.verdict; + $("#plan-head").textContent = p.head; + const hours = p.hours || 24; + const bars = $("#bars"); + const known = p.prices.filter((b) => b.eur_kwh != null); + const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh))); + bars.setAttribute("aria-label", t("plan.name")); + bars.replaceChildren(...p.prices.map((b) => { + const title = b.label + " " + t("plan.time_to") + " " + b.end_label; + if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]); + const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : ""; + const est = b.estimate ? ", " + t("plan.estimate_tip") : ""; + return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""), + "height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%", + title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]); + })); + roving(bars); + $("#no-prices").hidden = known.length > 0; + const lanes = $("#lanes"); + lanes.replaceChildren(); + S.batteries.forEach((b, i) => { + const lane = h("div", { class: "lane", role: "group", "aria-label": b.name }); + const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0)); + for (const r of runs) { + if (r.end_h <= r.start_h) continue; + lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%", + b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount])); + } + lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane); + roving(lane); + }); + const nl = $("#nowline"); + nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%"; + const used = new Set(p.runs.map((r) => r.kind)); + known.forEach((b) => used.add(b.kind)); + $("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) => + h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k)))); + const list = []; + S.batteries.forEach((b, i) => { + for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) { + list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount]))); + } + const house = p.runs.find((x) => x.battery === i && x.kind === "house"); + if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why]))); + }); + for (const line of p.lines) list.push(h("li", {}, line)); + $("#plan-list").replaceChildren(...list); + $("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" "); + } + + /* ---------------- tooltips: hover, tap and keyboard focus ---------------- */ + (function () { + const tip = $("#tip"); let hot = null; + const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; }; + function show(el, x, y) { + if (hot && hot !== el) hot.classList.remove("hot"); + hot = el; el.classList.add("hot"); + const lines = JSON.parse(el.dataset.tip); + tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l))); + tip.classList.add("show"); + const r = tip.getBoundingClientRect(); + let L = x + 12, T2 = y - r.height - 12; + if (L + r.width > innerWidth - 8) L = x - r.width - 12; + if (L < 8) L = 8; + if (T2 < 8) T2 = y + 16; + tip.style.left = L + "px"; tip.style.top = T2 + "px"; + } + const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]"); + document.addEventListener("pointermove", (e) => { + if (e.pointerType === "touch") return; + const el = target(e); + if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide(); + }); + document.addEventListener("pointerdown", (e) => { + const el = target(e); + if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); } + else if (!el) hide(); + }); + document.addEventListener("focusin", (e) => { + const el = target(e); + if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); } + }); + document.addEventListener("focusout", (e) => { if (target(e)) hide(); }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); }); + addEventListener("scroll", hide, { passive: true }); + })(); + + /* ---------------- Home: peak, attention, control, details ---------------- */ + function renderPeak() { + const m = S.month_peak; + $("#peak-month").textContent = m.month; + const covered = m.state !== "not_covered"; + $("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered; + $("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW" + : m.state === "unreadable" ? t("peak.unreadable") : t("peak.none"); + $("#peak-limit").textContent = kw(m.limit_w) + " kW"; + const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct)); + const bar = $("#peak-meter i"); + bar.style.width = pct + "%"; + bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : ""; + $("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %")); + $("#peak-line").textContent = m.line; + } + function renderAttention() { + const list = S.attention; + const icon = { alarm: "×", warning: "!", info: "i" }; + $("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" }, + h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"), + h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title), + h("div", { class: "small" }, a.body), + a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null)))); + const n = list.filter((a) => a.severity !== "info").length; + $("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing"); + if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing"))); + const badge = $("#attn-badge"); + badge.textContent = n; badge.hidden = !n; + badge.classList.toggle("bad", list.some((a) => a.severity === "alarm")); + } + function renderControl(structured) { + const running = structured ? S.control.running : null; + const btn = $("#btn-ctl"); + btn.textContent = running === false ? t("control.start") : t("control.stop"); + btn.className = "btn" + (running === false ? " primary" : ""); + $("#ctl-text").textContent = structured ? S.control.text : ""; + $("#hint").textContent = S.hint || ""; + const docs = $("#docs-link"); + if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true; + } + function renderDetails(open) { + $("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v)))); + $("#checks").replaceChildren(...(S.checks || []).map((c) => { + const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad"); + const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗"); + return h("li", { class: cls }, mark + " " + c.text); + })); + if (open) $("#c-details").open = true; + } + + /* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */ + function renderBatteries() { + if (!S.batteries) { $("#bcards").replaceChildren(); return; } + $("#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" : "")), + 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", {}, b.stored_kwh.toFixed(1) + " kWh")] : null, + h("dt", {}, t("batteries.now")), h("dd", {}, b.now), + h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode))))); + } + function renderSettings() { + const line = S.plan && S.plan.region_line; + $("#set-region").textContent = line || ""; $("#set-region").hidden = !line; + } + + /* ---------------- control buttons ---------------- */ + // The action is a toggle, so the page reads the live state first and sends + // nothing when control is already where the person asked it to be. + async function setControl(want) { + try { + const now = await getStatus(); + if (!now.control || now.control.running !== want) await act("auto_toggle"); + toast(want ? t("toast.started") : t("toast.stopped")); + } catch (e) { toast(t("toast.failed")); } + refresh(); + } + $("#btn-ctl").addEventListener("click", () => { + const running = S && S.control ? S.control.running : true; + if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true)); + }); + async function simple(what) { + try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); } + refresh(); + } + $("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start"))); + $("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort"))); + + /* ---------------- tabs: hash routes, arrow keys ---------------- */ + const TABS = [...document.querySelectorAll(".tab")]; + function select(tab, focus) { + TABS.forEach((x) => { + const on = x === tab; + x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1; + document.getElementById(x.getAttribute("aria-controls")).hidden = !on; + }); + if (focus) tab.focus(); + if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash); + schedule(); + } + TABS.forEach((x, i) => { + x.addEventListener("click", () => select(x, false)); + x.addEventListener("keydown", (e) => { + const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key]; + if (j === undefined) return; + e.preventDefault(); select(TABS[j % TABS.length], true); + }); + }); + function fromHash() { + const want = (location.hash || "#home").slice(1); + select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false); + } + addEventListener("hashchange", fromHash); + + /* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */ + let timer = null; + function schedule() { + const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" || + $("#t-batt").getAttribute("aria-selected") === "true"); + if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); } + if (!live && timer) { clearInterval(timer); timer = null; } + } + document.addEventListener("visibilitychange", schedule); + fromHash(); + refresh(); +})(); diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html new file mode 100644 index 0000000..cecb7a2 --- /dev/null +++ b/goodwe_controller/app/static/index.html @@ -0,0 +1,135 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>Battery add-on + + + +
+ +
+
+
…
+
+
+ + + +
+ +
+
+ +
+

+
+
–
+
–
+
–
+
–
+
+
+
+ +
+

+
+
+
+
+ + +
+ +
+

+

+
    +

    +
    + +
    +

    +
    :
    + +
    +
    + +
    +

    +
    +
    + +
    +

    +
    +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + +
    +

    +
      +
      +
      + + + + + + +
      + + +

      +

      +
      + + +
      +
      +
      + + + + + + diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index dd39981..75d3b0c 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -34,5 +34,47 @@ "applies": { "restart": {"name": "Applies after a restart"}, "now": {"name": "Applies right away"} + }, + "home": { + "app": {"name": "Battery add-on"}, + "tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"}, + "now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"}, + "plan": { + "name": "Today's plan", + "description": "Point at a bar or a block for details, or tap it on a phone.", + "cheap": "Cheap hour", + "dear": "Expensive hour", + "estimate": "Estimate", + "grid": "Charges from the grid", + "give": "Gives power", + "cycle": "Monthly battery cycle", + "peak": "Peak limit", + "house": "Follows the house", + "now": "now", + "time_to": "to", + "per_kwh": "per kWh", + "cheap_tip": "a cheap hour", + "dear_tip": "an expensive hour", + "estimate_tip": "estimate from yesterday", + "none_tip": "No price known", + "no_prices": {"description": "No prices for today, so the chart shows only the plan."}, + "list": "The plan in words" + }, + "peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"}, + "attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"}, + "control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"}, + "details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"}, + "dialog": { + "cancel": "Cancel", + "stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"}, + "start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"}, + "maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, + "maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"} + }, + "toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."}, + "offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."}, + "batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"}, + "settings": {"name": "Settings", "description": "The new settings screens arrive in a later update, so use the settings page for now.", "open": "Open the settings page"}, + "setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."} } } diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index aa117a9..648b7ca 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -10,7 +10,9 @@ a cellar, by someone who did not write it. Numbers alone are not enough - it says what is wrong in words. ⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S. -`/` is the status and control surface and is unchanged. `/config` is the +`/` is the status and control surface; since UX-13 P1 its files are in +app/static/ and its Home screen reads the structured half of `/status` +(app/homeview.py). `/config` is the configuration surface: its markup lives in app/config_page.py, its field model in app/configui.py and its Supervisor conversation in app/selfconfig.py. What stays HERE is the routing, so that the question a reviewer has to answer - does @@ -28,96 +30,48 @@ actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET of Home Assistant's entity list (app/haentities.py) and writes nothing. """ +import io +import json import logging +import os from aiohttp import web from . import messages, selfconfig from .haentities import EntityCache +from .homeview import region_of from .validate import config_notes from .config_page import CONFIG_PAGE from .configui import changed_keys, field_model, problems_by_field _LOG = logging.getLogger("goodwe.web") -PAGE = """ - -GoodWe RS485 Controller - -

      GoodWe RS485 Controller

      -

      Configuration →

      - +# UX-13 P1: the page lives in app/static/ as three files - index.html, +# app.css, app.js - plus the page words from app/ui_text/en.json. They are +# joined into ONE response here, once, so the page needs no second request +# and no route of its own: the route list below is unchanged, and every URL +# the page uses stays relative (ingress). No build step, no external file. +STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") +UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", + "en.json") -
      -
      -
      -
      -
      Commissioning
      - -
      +def _read(path): + with io.open(path, encoding="utf-8") as f: + return f.read() -
      - - - -
      -
      - -""" +def page() -> str: + """The assembled index page. ` web.Application: selfcfg = selfconfig.SelfConfig(controller.hass.session, controller.hass.token) entities = EntityCache(controller.hass) + index_page = page() async def index(_request): - return web.Response(text=PAGE, content_type="text/html") + return web.Response(text=index_page, content_type="text/html") async def status(_request): return web.json_response(controller.status_payload()) @@ -203,6 +158,9 @@ def build_app(controller, selfcfg=None) -> web.Application: "running": controller.o, "running_differs": changed_keys(controller.o, options), "lang": lang, + # UX-13 P1: the levies preset and its year, for the P2 Settings + # screens, which show the year beside the region choice. + "region": region_of(options), }) return web.json_response(model) diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py new file mode 100644 index 0000000..395e316 --- /dev/null +++ b/goodwe_controller/test_home.py @@ -0,0 +1,518 @@ +"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py` + +1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`, + `hint`, `events`) AND carries the structured half the Home screen reads: + `state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`. + Both are pinned here, by name and shape. +2. The plan shows only what the add-on does today. Trading on price is the + verdict the control cycle would reach (the same `plan.arbitrage()` call), + drawn where the cheapest and dearest prices are, with kWh and € per block. + The monthly cycle, the peak limit and "follows the house" are the rest. +3. Shadow mode says "would", a stopped site plans nothing, and a red row is an + alarm under "Needs attention". +4. A fault in the Home view never costs the page its old rows. +5. The page: served at `/` from app/static, no external file, relative URLs + only, every label resolves in app/ui_text, and the region year reaches + `config/model` for the P2 Settings screens. + +The STYLE scan of every Home sentence is test_messages.py part 8. +Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). +""" + +import asyncio +import io +import json +import os +import re +import sys +from datetime import datetime, timedelta, timezone + +from aiohttp import ClientSession, web as aioweb + +from app import homeview, web +from app.main import Controller +from app.peakguard import PeakDecision +from app.price import PricePoint, PriceProduct, PriceRead +from app.static_tariff import TariffPlan +from app.uimodel import REGION_PRESETS +from named_absence import field, nth, present + +FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"] + +HERE = os.path.dirname(os.path.abspath(__file__)) +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +# --------------------------------------------------------------------------- # +# Fixtures: a real Controller, with its readings set directly. +# --------------------------------------------------------------------------- # +class _Store: + def __init__(self): + self.data = {} + + def set(self, k, v): + self.data[k] = v + + def get(self, k, d=None): + return self.data.get(k, d) + + def set_time(self, k, v): + self.data[k] = v.isoformat() + + def get_time(self, k): + v = self.data.get(k) + return None if v is None else datetime.fromisoformat(v) + + +class _Pub: + enabled = False + + def publish(self, _v): + pass + + def close(self): + pass + + +class _Hass: + session = None + token = "" + + async def limits(self, _e): + return (-5000.0, 5000.0, 1.0) + + async def number(self, _e, invert=False): + return None + + async def state(self, _e): + return None + + async def set_number(self, _e, _v): + return True + + async def call(self, *_a): + return True + + async def states(self): + return [] + + +ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off", "max_w": 5000, "shadow_mode": "off", + "maintenance_enabled": False} +TWO = dict(ONE, inverters=[ + {"name": "Battery A", "setpoint_entity": "number.a", "soc_entity": + "sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500, + "capacity_kwh": 9.6}, + {"name": "Battery B", "setpoint_entity": "number.b", "soc_entity": + "sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500, + "capacity_kwh": 5.4}]) + +# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day. +NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc) +DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc) +MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14, + .16, .19, .23, .28, .33, .36, .34, .29, .26, .24] + + +def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0, + auto=True): + c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event()) + c.auto = auto + for i, inv in enumerate(c.inverters): + inv.soc, inv.batt = soc[i], batt[i] + inv.dev_min, inv.dev_max = -5000.0, 5000.0 + c.grid = grid + return c + + +def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None): + pts = [] + for d in range(days): + for k, p in enumerate(prices): + start = day0 + timedelta(days=d, hours=k) + pts.append(PricePoint(start, p, provisional_from is not None + and start >= provisional_from)) + return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD, + "generic_dayahead", NOW), None, + "provider") + + +def quiet_guard(projected_w=1240.0): + return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0, + ceiling_from="cap", projected_w=projected_w) + + +def status(c, now=NOW): + s = c.status_payload() + # The status route builds at wall-clock time; rebuild the structured half + # at the fixture's instant so the day and the hours are the mockup's. + s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now)) + return s + + +# --------------------------------------------------------------------------- # +print("1. /status: the old fields stay, the structured half is added") +# --------------------------------------------------------------------------- # +c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0) +s1 = c1.status_payload() +OLD = {"banner", "level", "rows", "checks", "hint", "events"} +NEW = {"state", "now", "batteries", "plan", "attention", "month_peak", + "control"} +check(f"every old field is still there, with its old type ({sorted(OLD)})", + OLD <= set(s1) and isinstance(s1["rows"], list) + and all(len(r) == 2 for r in s1["rows"]) + and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"]) + and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad") + and isinstance(s1["events"], list)) +check(f"...and the structured half is added beside them ({sorted(NEW)})", + NEW <= set(s1) and "home_error" not in s1) +check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1)) + == json.loads(json.dumps(s1))) +check("`state` names the kind, the pill word, the control row and a sub-line", + set(s1["state"]) == {"kind", "pill", "ran", "sub"} + and s1["state"]["kind"] == "running") +check("`now` carries the four tiles in W and a plain line for each", + {"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"} + <= set(s1["now"]) + and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"} + and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0) +check("...and with no solar reading the house figure is NOT invented: it is " + "None and the tile says why", + s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False + and s1["now"]["lines"]["house"] == homeview.tile_house(None, False)) +_b = s1["batteries"][0] +check("`batteries[]` has level, power, state, mode, the now line and why", + {"name", "level_pct", "power_w", "state", "mode", "now", "why", + "capacity_kwh", "stored_kwh", "maintenance"} <= set(_b) + and _b["level_pct"] == 62.0 and _b["state"] == "giving" + and _b["now"] == "Giving 1.0 kW now.") +_p = s1["plan"] +check("`plan` carries the verdict, the prices, the runs, the lines and " + "the region", + {"verdict", "head", "prices", "runs", "lines", "now_h", "hours", + "price_line", "tomorrow_line", "region", "region_line"} <= set(_p) + and len(_p["prices"]) in (23, 24, 25)) +check("`attention[]` has severity, title, body and docs; `month_peak` and " + "`control` are there", + all({"severity", "title", "body", "docs"} <= set(a) + for a in s1["attention"]) + and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"} + <= set(s1["month_peak"]) + and {"running", "shadow", "text", "maintenance", "docs_url"} + <= set(s1["control"])) + +# --------------------------------------------------------------------------- # +print("\n2. the plan is what the add-on actually does") +# --------------------------------------------------------------------------- # +s_house = status(make(TWO)) +check("with trading off and no cycle due, each battery follows the house " + "from now to the end of the day, and nothing else is drawn", + [r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"] + and s_house["plan"]["verdict_key"] == "house" + and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01 + for r in s_house["plan"]["runs"]) + and s_house["plan"]["runs"][0]["to"] == "24:00") + +ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05) +c_arb = make(ARB) +c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW +c_arb.peak_guard = quiet_guard() +s_arb = status(c_arb) +_runs = s_arb["plan"]["runs"] +_grid = [r for r in _runs if r["kind"] == "grid"] +_give = [r for r in _runs if r["kind"] == "give"] +check("trading on price: a charge block at the cheapest hour (13:00) and a " + "give block at the dearest (19:00), on each battery", + [(r["battery"], r["from"], r["to"]) for r in _grid] + == [(0, "13:00", "14:00"), (1, "13:00", "14:00")] + and [(r["battery"], r["from"], r["to"]) for r in _give] + == [(0, "19:00", "20:00"), (1, "19:00", "20:00")] + and s_arb["plan"]["verdict_key"] == "trade") +check("...placed where the control cycle's own verdict puts them: the same " + "plan.arbitrage() call on the same plan", + c_arb.fresh_tariff_plan(NOW).arbitrage( + peak_guard_active=False, min_spread_eur_kwh=0.05, + uncertainty=frozenset({__import__("app.price", fromlist=["x"]) + .Uncertainty.NO_WEATHER})) + .match(allowed=lambda v: (v.cheapest.start, v.dearest.start), + refused=lambda v: None) + == (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19))) +# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of +# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5. +check("each block carries kWh and €: charging is capped by the battery's own " + "rating and the room its level leaves, € = kWh x that hour's price", + [r["kwh"] for r in _grid] == [2.5, 2.5] + and [r["eur"] for r in _grid] == [0.35, 0.35] + and [r["eur_kwh"] for r in _grid] == [0.14, 0.14] + and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"] + and "€0.35" in _grid[0]["amount"]) +# Giving: A holds 62 - 20 = 42 % of 9.6 = 4.03 -> 2.5. B holds 35 - 20 = 15 % +# of 5.4 = 0.81 kWh, which is what it can give above arbitrage's floor. +check("...and giving is capped by what each battery holds above arbitrage's " + "floor (Battery B: 15 % of 5.4 kWh)", + [r["kwh"] for r in _give] == [2.5, 0.81] + and [r["eur"] for r in _give] == [0.9, 0.29]) +check("the price bars are today's 24 hours, from the real series, with the " + "cheap and expensive thirds marked", + [b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK + and s_arb["plan"]["prices"][13]["kind"] == "cheap" + and s_arb["plan"]["prices"][19]["kind"] == "dear" + and s_arb["plan"]["prices"][0]["kind"] == "mid" + and s_arb["plan"]["prices"][13]["label"] == "13:00") +check("the price line names the price now, today's cheapest and dearest, and " + "that tomorrow's prices are in", + s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. " + "Today: cheapest €0.14 at 13:00, " + "dearest €0.36 at 19:00.") + and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.") + +c_late = make(ARB) +c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW +c_late.peak_guard = quiet_guard() +s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local +check("a block whose hour is over is not drawn: at 20:40 no trade is left " + "today", + not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")]) + +c_peak = make(ARB) +c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW +c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0, + ceiling_w=3500.0, ceiling_from="cap", + projected_w=3900.0) +s_peak = status(c_peak) +check("while the peak limit holds, the verdict refuses as the cycle does: no " + "trade block, a plain line, and a peak block for this 15-minute period", + not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")] + and homeview.line_no_trade("peak") in s_peak["plan"]["lines"] + and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"] + if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")]) + +c_blind = make(ARB) +c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW +s_blind = status(c_blind) # meter_source off: the guard never runs +check("with nothing measuring the 15-minute peak, trading is NOT drawn, as " + "the cycle refuses it (guard state None)", + not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")] + and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"]) + +c_flat = make(ARB) +c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW +c_flat.peak_guard = quiet_guard() +s_flat = status(c_flat) +check("a flat day: no trade, no cheap or expensive hour marked", + not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")] + and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"} + and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"]) + +c_old = make(ARB) +c_old.tariff_plan = provider_plan() +c_old.tariff_at = NOW - timedelta(hours=3) # past its max age +c_old.peak_guard = quiet_guard() +s_old = status(c_old) +check("a stale plan is not traded on by the cycle, so it is not drawn", + not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")]) + +MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12) +c_m = make(MAINT) +s_m = status(c_m) +_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"] +check("a monthly cycle due today is drawn on ONE battery, at the start hour " + "the cycle uses (12 UTC is 14:00 here), and the other one waits", + len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00" + and s_m["plan"]["verdict_key"] == "cycle" + and any("waits its turn" in x for x in s_m["plan"]["lines"])) +check("...with its duration from the battery's capacity and the cycle's rates " + "and hold time", + present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ") + and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2) +c_m.maints[0].store.set("phase", "drain") +s_md = status(c_m) +check("a cycle under way is drawn from now, and the battery's now line says " + "so", + [r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"] + == ["11:40"] + and s_md["batteries"][0]["now"] == "Monthly cycle: emptying." + and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"])) + +# --------------------------------------------------------------------------- # +print("\n3. shadow mode, a stopped site, an alarm") +# --------------------------------------------------------------------------- # +c_sh = make(dict(ARB, shadow_mode="on")) +c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW +c_sh.peak_guard = quiet_guard() +s_sh = status(c_sh) +_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]] +check("shadow: the state is shadow, the battery tile is held at 0 W, and " + "every block and the head say 'would'", + s_sh["state"]["kind"] == "shadow" + and s_sh["now"]["lines"]["batt"] == "Held at 0 W" + and all("ould" in x for x in _texts) + and s_sh["plan"]["head"].startswith("Shadow mode:")) +check("...and 'Shadow mode is on' is the first item under Needs attention", + s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW + and s_sh["attention"][0]["severity"] == "info") +check("...and each battery's now line says it is held at 0 W", + all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"])) + +s_stop = status(make(TWO, auto=False)) +check("stopped: nothing is planned and the head says so", + s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped" + and s_stop["plan"]["verdict_key"] == "stopped" + and s_stop["control"]["running"] is False + and all(b["now"] == "Held at 0 W while control is stopped." + for b in s_stop["batteries"])) + +c_al = make(ONE, soc=(62.0,), batt=(0.0,)) +c_al.inverters[0].sign_wd.tripped = True +s_al = c_al.status_payload() +_red = [a for a in s_al["attention"] if a["severity"] == "alarm"] +check("an alarm: level bad, state alarm, and the red row is the first item " + "under Needs attention", + s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm" + and _red and s_al["attention"][0] is _red[0] + and _red[0]["title"] == homeview.ATTN_TITLES["direction"]) +check("...and nothing is planned while every inverter is held at 0 W", + s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held") +_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]] +check("...and every red, amber and settling row reaches Needs attention with " + "its own sentence as the body, and no green row does", + [a["body"] for a in homeview.attention(s_al["checks"])] + == [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]] + + [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]] + + [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]]) + +# --------------------------------------------------------------------------- # +print("\n4. a fault in the Home view never costs the page its rows") +# --------------------------------------------------------------------------- # +_real = homeview.build +try: + homeview.build = lambda *a, **k: 1 / 0 + s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload() +finally: + homeview.build = _real +check("the old fields are served and `home_error` says the rest is missing", + OLD <= set(s_err) and s_err.get("home_error") is True + and not (NEW & set(s_err))) + +# --------------------------------------------------------------------------- # +print("\n5. the page") +# --------------------------------------------------------------------------- # +PAGE = web.page() +JS = io.open(os.path.join(HERE, "app", "static", "app.js"), + encoding="utf-8").read() +UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"), + encoding="utf-8"))["home"] + + +def resolve(path): + node = UI + for k in path.split("."): + if not isinstance(node, dict) or k not in node: + return None + node = node[k] + return node + + +check("the page is assembled: no placeholder is left and the words parse", + "/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE + and "/*UI_TEXT*/" not in PAGE + and json.loads(PAGE.split('id="ui-text">', 1)[1] + .split("", 1)[0].replace("<\\/", " 30 and not _missing) +check("data is set as text, never as HTML, so no status sentence can inject " + "markup", "innerHTML" not in JS and "insertAdjacentHTML" not in JS) +check("every function of the old page is still on it: start/stop, the " + "maintenance start and abort, the status rows, the checks and the " + "event line", + "auto_toggle" in JS and '"maint_start"' in JS and '"maint_abort"' in JS + and 'id="rows"' in PAGE and 'id="checks"' in PAGE + and 'id="hint"' in PAGE and 'href="config"' in PAGE) +check("stopping, starting and both maintenance actions ask first", + all(f'ask("{k}"' in JS for k in ("stop", "start", "maint_start", + "maint_abort"))) +check("tooltips open on hover, on a tap and on keyboard focus", + "pointermove" in JS and "pointerdown" in JS and "focusin" in JS) +check("the chart is not colour alone: expensive hours and blocks are " + "patterned, and every item has a text label", + "repeating-linear-gradient" in PAGE and '"aria-label": tipText' in JS) +check("the phone layout puts the tabs at the bottom under 640 px, and dark " + "mode follows the system", + "@media (max-width:640px)" in PAGE and "position:fixed;bottom:0" in PAGE + and "prefers-color-scheme:dark" in PAGE) + + +async def _serve(): + class _Sup: + async def info(self): + return {"options": {"price_taxes_eur_kwh": FLANDERS, + "price_taxes_profile": ""}, + "schema": [], "translations": {}} + c = make(ONE, soc=(50.0,), batt=(0.0,)) + runner = aioweb.AppRunner(web.build_app(c, _Sup())) + await runner.setup() + site = aioweb.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + out = {} + try: + async with ClientSession() as s: + async with s.get(f"http://127.0.0.1:{port}/") as r: + out["page"] = (r.status, r.content_type, await r.text()) + async with s.get(f"http://127.0.0.1:{port}/status") as r: + out["status"] = await r.json() + async with s.get(f"http://127.0.0.1:{port}/config/model") as r: + out["model"] = await r.json() + finally: + await runner.cleanup() + return out + + +E = asyncio.run(_serve()) +check("GET / serves the assembled page as HTML", E["page"][0] == 200 + and E["page"][1] == "text/html" and E["page"][2] == PAGE) +check("GET status over HTTP carries both halves", + OLD <= set(E["status"]) and NEW <= set(E["status"])) +check("the levies preset and its year reach config/model for the P2 " + "Settings screens (P0 review follow-up)", + E["model"].get("region") == {"id": "flanders", "year": 2026}) +c_reg = make(dict(ARB, price_taxes_eur_kwh=FLANDERS)) +c_reg.tariff_plan, c_reg.tariff_at = provider_plan(), NOW +s_reg = status(c_reg) +check("...and Home names the preset with its year wherever prices come from " + "a feed, and says 'your own figure' otherwise", + s_reg["plan"]["region"] == {"id": "flanders", "year": 2026} + and s_reg["plan"]["region_line"] + == "Levies and VAT: Flanders, Fluvius Antwerpen, 2026 figures." + and s_arb["plan"]["region_line"] == "Levies and VAT: your own figure." + and s_house["plan"]["region_line"] == "") + +print() +if fails: + print(f"{len(fails)} FAILED") + sys.exit(1) +print("all passed") diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index e82176c..33d3e72 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -1817,6 +1817,69 @@ check("the action log counts 'engagements' with a real plural and the " and messages.count(25, "engagement") == "25 engagements" and messages.count(1, "engagement") == "1 engagement") +# ============================================================================== # +print("\n8. the Home screen's sentences (UX-13 P1)") +# app/homeview.py builds every sentence the Home screen shows. Each is rendered +# with real values by `text_samples()` and held to STYLE.md: a title at most 5 +# words, a plan line at most 2 sentences of at most 12 words each (the design +# note's new row type), any other line 1 sentence of at most 25 words. +from app import homeview # noqa: E402 + +PLAN_SENTENCE_WORDS, PLAN_SENTENCES = 12, 2 + + +def home_problems(kind, text): + if kind == "title": + return title_problems(text) + out = text_problems(text, keys=False, entities=False) + if ";" in text: + out.append("semicolon") + if kind == "line": + if sentences(text) > 1: + out.append(f"over 1 sentence ({sentences(text)})") + if len(words(text)) > 25: + out.append(f"over 25 words ({len(words(text))})") + return out + if sentences(text) > PLAN_SENTENCES: + out.append(f"plan line over {PLAN_SENTENCES} sentences") + long = [x for x in re.split(r"(?<=[.!?])\s+", text) + if len(words(x)) > PLAN_SENTENCE_WORDS] + if long: + out.append(f"plan sentence over {PLAN_SENTENCE_WORDS} words " + f"({len(words(long[0]))}): {long[0]!r}") + return out + + +_home = homeview.text_samples() +_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] if p] +for k, t, p in _home_bad: + print(f" {k}: {t!r}: {p}") +check(f"every Home sentence obeys its STYLE limit ({len(_home)} rendered, " + f"{len(_home_bad)} over)", len(_home) > 80 and not _home_bad) +_shadowed = [t for k, t in homeview.text_samples() if "ould" in t] +check("in shadow mode the Home plan says what the add-on WOULD do: every " + "shadow variant of a plan text carries 'would'", + all("ould" in f(True) for f in ( + lambda sh: homeview.run_grid_what(2.5, sh), + lambda sh: homeview.run_give_what(2.5, sh), + lambda sh: homeview.run_cycle_what("idle", sh), + lambda sh: homeview.run_cycle_why(sh), + lambda sh: homeview.run_peak_what(3500, sh), + lambda sh: homeview.run_house_what(sh), + lambda sh: homeview.run_house_why(sh), + lambda sh: homeview.plan_head("house", sh), + lambda sh: homeview.plan_head("trade", sh), + lambda sh: homeview.line_peak_limit(3500, sh))) + and not any("ould" in f(False) for f in ( + lambda sh: homeview.run_grid_what(2.5, sh), + lambda sh: homeview.run_house_what(sh), + lambda sh: homeview.plan_head("house", sh))) + and len(_shadowed) > 10) +check("mutation: a 13-word plan sentence and a 6-word title are caught", + home_problems("plan", "One two three four five six seven eight nine " + "ten eleven twelve thirteen.") + and home_problems("title", "One two three four five six")) + print() if fails: print(f"{len(fails)} FAILED") diff --git a/goodwe_controller/test_pv.py b/goodwe_controller/test_pv.py index 1768e4b..ad71ba7 100644 --- a/goodwe_controller/test_pv.py +++ b/goodwe_controller/test_pv.py @@ -700,17 +700,20 @@ check(f"PRECONDITION: the scan found the whole package, not a subset - " # UX-13 adds the settings surface: labels (messages.py), a settings warning # (validate.py) and the page's layout and derivation (uilayout.py, uimodel.py). # None of them decides anything, so the decision modules stay excluded. +# UX-13 P1 adds homeview.py, the Home screen's read-only view: its House tile +# is grid + solar + battery, and it shows nothing without a solar reading. _DECIDERS = ("control.py", "arbiter.py", "allocator.py", "peakguard.py", "arbitrage.py", "maintenance.py", "clamp.py", "freeze.py", "watchdog.py", "delivery.py", "circulation.py") check("⭐ ONLY THE READER, THE PUBLISHER AND THE SETTINGS SURFACE SEE THE " "MATERIAL: `pv.py` (the reader), `main.py` (the read and the two operator " - "surfaces), `mqtt.py` (the publication), and `messages.py`, " - "`validate.py`, `uilayout.py` and `uimodel.py` (settings). " + "surfaces), `homeview.py` (the Home screen's view), `mqtt.py` (the " + "publication), and `messages.py`, `validate.py`, `uilayout.py` and " + "`uimodel.py` (settings). " f"{', '.join(_DECIDERS)} do not mention it in any spelling, comments " "included", - _touch == ["main.py", "messages.py", "mqtt.py", "pv.py", "uilayout.py", - "uimodel.py", "validate.py"] + _touch == ["homeview.py", "main.py", "messages.py", "mqtt.py", "pv.py", + "uilayout.py", "uimodel.py", "validate.py"] and not set(_DECIDERS) & set(_touch)) # habit 4 in the instrument: a scanner that cannot go red is not a scanner. check("...and the scan CAN go red - a module body with the material in it is " diff --git a/goodwe_controller/tools/ux13_demo.py b/goodwe_controller/tools/ux13_demo.py new file mode 100644 index 0000000..5b9067b --- /dev/null +++ b/goodwe_controller/tools/ux13_demo.py @@ -0,0 +1,131 @@ +"""UX-13 P1: serve the real page and routes over a fixture controller, for a +headless browser check. `python3 tools/ux13_demo.py STATE PORT` + +STATE is running, shadow or alarm. The controller is the shipped `Controller` +with its readings set by hand (two batteries, solar, today's prices in the +mockup's shape, trading on). Nothing here talks to Home Assistant or to an +inverter: `/action` changes only this fixture. Used for the P1 render check +(no JS errors, tooltips, phone width, dark mode) described in the PR. +""" + +import asyncio +import os +import sys +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from aiohttp import web as aioweb # noqa: E402 + +from app import homeview, web # noqa: E402 +from app.main import Controller # noqa: E402 +from app.peakguard import PeakDecision # noqa: E402 +from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402 +from app.static_tariff import TariffPlan # noqa: E402 + +PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, + .14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24] + + +class Store(dict): + def set(self, k, v): + self[k] = v + + def set_time(self, k, v): + self[k] = v.isoformat() + + def get_time(self, k): + v = self.get(k) + return None if v is None else datetime.fromisoformat(v) + + @property + def data(self): + return self + + +class Pub: + enabled = False + + def publish(self, _v): + pass + + def close(self): + pass + + +class Hass: + session = None + token = "" + + async def limits(self, _e): + return (-5000.0, 5000.0, 1.0) + + async def number(self, _e, invert=False): + return None + + async def state(self, _e): + return None + + async def set_number(self, _e, _v): + return True + + async def call(self, *_a): + return True + + async def states(self): + return [] + + +class Supervisor: + async def info(self): + return {"options": {}, "schema": [], "translations": {}} + + +OPTS = {"meter_entity": "sensor.grid", "meter_source": "off", + "pv_entity": "sensor.pv", "maintenance_enabled": True, + "maintenance_start_hour": 18, "arbitrage_enabled": True, + "cycle_cost_threshold_eur": 0.05, + "inverters": [ + {"name": "Battery A", "setpoint_entity": "number.a", + "soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a", + "max_w": 2500, "capacity_kwh": 9.6}, + {"name": "Battery B", "setpoint_entity": "number.b", + "soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b", + "max_w": 2500, "capacity_kwh": 5.4}]} + + +def controller(state): + opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off") + c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event()) + c.auto = True + now = datetime.now(timezone.utc) + day0, _day1 = homeview.local_day(now) + pts = tuple(PricePoint(day0 + timedelta(days=d, hours=k), p, False) + for d in range(2) for k, p in enumerate(PRICES)) + c.tariff_plan = TariffPlan(PriceRead.series( + pts, PriceProduct.DAY_AHEAD, "generic_dayahead", now), None, + "provider") + c.tariff_at = now + c.peak_guard = PeakDecision(engaged=False, floor_w=None, + ceiling_w=3500.0, ceiling_from="cap", + projected_w=1240.0) + for inv, soc, batt in zip(c.inverters, (62.0, 35.0), (-2500.0, -300.0)): + inv.soc, inv.batt = soc, (0.0 if state == "shadow" else batt) + inv.dev_min, inv.dev_max = -5000.0, 5000.0 + c.grid = -10.0 if state != "shadow" else -2810.0 + c.pv_w = 3900.0 + if state == "alarm": + c.inverters[0].sign_wd.tripped = True + return c + + +async def main(state, port): + runner = aioweb.AppRunner(web.build_app(controller(state), Supervisor())) + await runner.setup() + await aioweb.TCPSite(runner, "127.0.0.1", port).start() + print(f"serving {state} on http://127.0.0.1:{port}/", flush=True) + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1], int(sys.argv[2])))