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 @@
+
+
+
+
+
+Battery add-on
+
+
+
+
+
+
+
+
…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
☀
–
+
⌂
–
+
⇅
–
+
▮
–
+
+
+
+
+
+
+
+
+
+
+
+
00:0006:0012:0018:0024:00
+
+
+
+
+
+
+
+
+
+
+
+
:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
-
-
+# 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. `` is escaped inside the two inline
+ scripts, so no text in them can close the tag early."""
+ ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
+ script = _read(os.path.join(STATIC, "app.js"))
+ if " 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("<\\/", ""))["home"]
+ == UI)
+check("four tabs with the four hash routes, in a keyboard tablist",
+ re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
+ "settings", "setup"]
+ and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
+ and "ArrowRight" in JS and "ArrowLeft" in JS)
+_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
+check(f"no external file and no web font: no URL leaves the add-on "
+ f"({_ext})", not _ext and "@import" not in PAGE
+ and " 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])))