Merge release/1.0 (a36dca6) into SAFETY-73-per-unit-refusal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-28 01:41:54 +02:00
co-authored by Claude Opus 5.5
12 changed files with 2512 additions and 81 deletions
+1
View File
@@ -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 "<title>" 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.
+985
View File
@@ -0,0 +1,985 @@
"""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, disagree = None, False
if has_solar and None not in (grid_w, pv_w, batt_w):
house = float(grid_w) + float(pv_w) + float(batt_w)
# The three are read at slightly different moments, so the sum can dip
# under zero. Inside the idle band that is 0 W; beyond it the readings
# disagree and no house figure is shown.
if house < -IDLE_W:
house, disagree = None, True
elif house < 0.0:
house = 0.0
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, disagree),
"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).
The page calls these "low price" and "high price", not cheap hours: the
add-on trades in the one cheapest and one dearest hour, not in a third."""
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, refusal_key(plan, guard)))
def refusal_key(plan, guard):
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
the peak guard, no prices, under two published points, then the gap."""
if guard:
return "peak"
if not plan.has_prices:
return "no_prices"
if sum(1 for p in _points(plan) if not p.provisional) < 2:
return "not_firm"
return "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)
# Under this many kWh a block is not drawn: the cycle would not move it.
MIN_KWH = 0.05
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).
Only what `arbitrage.decide()` would still do is drawn: no block on a
battery with no room, no sale at a negative price, and no sale while the
emptiest battery is at the floor (the cycle's `soc` is the emptiest).
With a level unknown the cycle does not trade, so nothing is drawn."""
floor, ceiling = arbitrage.soc_bounds(c.o)
if any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [float(inv.soc) for inv in c.inverters]
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
key=lambda kp: kp[1].start)
runs, later = [], []
for kind, pt in legs:
end = _point_end(plan, pt)
if end <= now:
continue
if not (day0 <= pt.start < day1):
later.append((kind, pt))
continue
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
room = (ceiling - level[i] if kind == "grid"
else level[i] - floor)
kwh = min(kwh, max(0.0, room) / 100.0 * cap)
# A later give may use what this charge put in: an upper
# bound, like every amount here ("up to").
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
if kwh < MIN_KWH:
continue
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, None
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, no_trade = trade_runs(c, fresh, verdict, day0, day1,
now, shadow)
runs += trade
if no_trade:
lines.append(line_no_trade(no_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:
# The guard's ceiling: the cap, or this month's peak once above it.
pg = c.peak_guard
lines.append(line_peak_limit(
(pg.ceiling_w if pg is not None else None) or 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, disagree=False) -> str:
if not has_solar:
return "Needs a solar reading"
if disagree:
return "Readings do not add up"
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_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown.",
}[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.append(("title", tile_house(None, True, True)))
out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)]
out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)]
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_firm", "not_worth",
"no_level")]
return out
+20 -1
View File
@@ -55,6 +55,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,
@@ -1053,6 +1054,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
@@ -5692,7 +5696,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
@@ -5701,6 +5705,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)
+137
View File
@@ -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}
+374
View File
@@ -0,0 +1,374 @@
"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.
// One request at a time: a second confirm while the first is in flight
// would read the same state and toggle it back.
let toggling = false;
async function setControl(want) {
if (toggling) return;
toggling = true;
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")); }
finally { toggling = false; }
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();
})();
+135
View File
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Battery add-on</title>
<style>/*APP_CSS*/</style>
</head>
<body>
<header class="top">
<div class="logo" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2M8 10l-2 3h4l-2 3"/></svg></div>
<div class="grow">
<div class="app" data-t="app.name"></div>
<div class="pill" id="pill" aria-live="polite"><span class="dot warn"></span><span>…</span></div>
</div>
</header>
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
</nav>
<main class="wrap">
<section class="screen" id="s-home" role="tabpanel" aria-labelledby="t-home">
<div id="home-banner"></div>
<div class="card" id="c-now">
<div class="row spread"><h2 data-t="now.name"></h2><span class="tiny muted" data-t="now.description"></span></div>
<div class="tiles">
<div class="tile"><div class="lab"><span class="sw-solar" aria-hidden="true">&#9728;</span><span data-t="now.solar"></span></div><div class="val" id="v-solar">–</div><div class="sub" id="sub-solar"></div></div>
<div class="tile"><div class="lab"><span class="sw-house" aria-hidden="true">&#8962;</span><span data-t="now.house"></span></div><div class="val" id="v-house">–</div><div class="sub" id="sub-house"></div></div>
<div class="tile"><div class="lab"><span class="sw-grid" aria-hidden="true">&#8645;</span><span data-t="now.grid"></span></div><div class="val" id="v-grid">–</div><div class="sub" id="sub-grid"></div></div>
<div class="tile"><div class="lab"><span class="sw-batt" aria-hidden="true">&#9646;</span><span data-t="now.batteries"></span></div><div class="val" id="v-batt">–</div><div class="sub" id="sub-batt"></div></div>
</div>
<div id="home-batts" style="margin-top:6px"></div>
</div>
<div class="card" id="c-plan">
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
<div class="small" id="plan-head"></div>
<div class="chart" id="chart">
<div class="bars" id="bars" role="group"></div>
<div id="lanes"></div>
<div class="nowline" id="nowline" aria-hidden="true"><span data-t="plan.now"></span></div>
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
</div>
<p class="tiny muted" id="no-prices" hidden data-t="plan.no_prices.description"></p>
<div class="legend" id="legend"></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
<h3 class="sr" data-t="plan.list"></h3>
<ul class="plain small" id="plan-list"></ul>
<p class="tiny muted" style="margin:8px 0 0" id="price-line"></p>
</div>
<div class="card" id="c-peak">
<div class="row spread"><h2 data-t="peak.name"></h2><span class="tiny muted" id="peak-month"></span></div>
<div class="row spread small" id="peak-figures"><span><span data-t="peak.this_month"></span>: <strong id="peak-now"></strong></span><span class="muted"><span data-t="peak.limit"></span> <span id="peak-limit"></span></span></div>
<div class="meter" id="peak-meter" role="img"><i style="width:0%"></i></div>
<div class="tiny muted" id="peak-line"></div>
</div>
<div class="card" id="c-attn">
<div class="row spread"><h2 data-t="attention.name"></h2><span class="tiny muted" id="attn-count"></span></div>
<div id="alerts"></div>
</div>
<div class="card" id="c-ctl">
<h2 data-t="control.name"></h2>
<div class="small" id="ctl-text"></div>
<div class="row" style="margin-top:10px">
<button class="btn" id="btn-ctl" type="button"></button>
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
</div>
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
<details class="more">
<summary data-t="control.more"></summary>
<div class="row" style="padding-bottom:6px">
<button class="btn" id="btn-maint" type="button" data-t="control.maint_start"></button>
<button class="btn danger" id="btn-abort" type="button" data-t="control.maint_abort"></button>
</div>
</details>
</div>
<details class="card more" id="c-details">
<summary data-t="details.name"></summary>
<table class="rows" id="rows"></table>
<h2 style="margin-top:12px" data-t="details.checks"></h2>
<ul class="checks" id="checks"></ul>
</details>
</section>
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="config" data-t="batteries.settings"></a>
</div>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="card">
<h2 data-t="settings.name"></h2>
<p class="small muted" data-t="settings.description"></p>
<p class="small" id="set-region" hidden></p>
<a class="btn primary" href="config" data-t="settings.open"></a>
</div>
</section>
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
<div class="card">
<h2 data-t="setup.name"></h2>
<p class="small muted" data-t="setup.description"></p>
<a class="btn" href="config" data-t="settings.open"></a>
</div>
</section>
</main>
<dialog id="dlg" aria-labelledby="dlg-t" aria-describedby="dlg-b">
<h2 id="dlg-t"></h2>
<p class="small" id="dlg-b"></p>
<div class="row" style="justify-content:flex-end">
<button class="btn" id="dlg-no" type="button" data-t="dialog.cancel"></button>
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
</body>
</html>
+42
View File
@@ -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": "Low price",
"dear": "High price",
"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": "low for today",
"dear_tip": "high for today",
"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."}
}
}
+34 -76
View File
@@ -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 = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe RS485 Controller</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
*{box-sizing:border-box}
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
h1{font-size:18px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
table{width:100%;border-collapse:collapse}
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
tr:last-child td{border-bottom:0}
.k{color:var(--muted);font-weight:400}
ul{margin:6px 0 0;padding-left:18px}
li{margin:3px 0}
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<p class="muted"><a href="config">Configuration &rarr;</a></p>
<div id="banner" class="banner b-warn">loading…</div>
# 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")
<div class="card">
<table id="live"></table>
</div>
<div class="card">
<div class="k">Commissioning</div>
<ul id="checks"></ul>
</div>
def _read(path):
with io.open(path, encoding="utf-8") as f:
return f.read()
<div class="card">
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
<button onclick="act('maint_start')">Force maintenance cycle</button>
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
<div class="muted" id="hint"></div>
</div>
<script>
async function refresh(){
try{
const r = await fetch('status', {cache:'no-store'});
const s = await r.json();
const b = document.getElementById('banner');
b.textContent = s.banner;
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
document.getElementById('live').innerHTML = s.rows
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
document.getElementById('checks').innerHTML = s.checks
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'✓':(c.warn?'!':'✗')} ${c.text}</li>`)
.join('');
document.getElementById('hint').textContent = s.hint || '';
}catch(e){
document.getElementById('banner').textContent = 'cannot reach the add-on';
}
}
async function act(what){
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action: what})});
refresh();
}
refresh(); setInterval(refresh, 3000);
</script></body></html>
"""
def page() -> str:
"""The assembled index page. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
script = _read(os.path.join(STATIC, "app.js"))
if "</script" in script.lower():
raise ValueError("app.js may not contain a closing script tag")
return (_read(os.path.join(STATIC, "index.html"))
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
.replace("/*APP_JS*/", script))
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
@@ -144,9 +98,10 @@ def build_app(controller, selfcfg=None) -> 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)
+583
View File
@@ -0,0 +1,583 @@
"""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)
_nh = homeview.now_block(grid_w=-3000.0, pv_w=1000.0, batt_w=1500.0,
has_solar=True, shadow=False)
_nz = homeview.now_block(grid_w=-1030.0, pv_w=1000.0, batt_w=0.0,
has_solar=True, shadow=False)
_np = homeview.now_block(grid_w=800.0, pv_w=1000.0, batt_w=-300.0,
has_solar=True, shadow=False)
check("House = grid (+ import) + solar + battery (+ giving): 800 + 1000 - 300 "
"= 1500 W",
_np["house_w"] == 1500.0 and _np["lines"]["house"] == "Using")
check("...a sum below zero is not shown as a positive figure: 0 W inside "
"the idle band, and no figure beyond it",
_nz["house_w"] == 0.0 and _nh["house_w"] is None
and _nh["lines"]["house"] == homeview.tile_house(None, True, True))
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 at 19:00 comes after the 13:00 charge, so each battery may give what
# it will hold then: B is at 35 % + 2.5 kWh of 5.4 = 81 %, and 81 - 20 = 61 %
# of 5.4 = 3.3 kWh, so 2.5 (an upper bound, like every amount here).
check("...and giving is capped by what each battery holds above arbitrage's "
"floor after the charge before it",
[r["kwh"] for r in _give] == [2.5, 2.5]
and [r["eur"] for r in _give] == [0.9, 0.9])
def trade(soc, prices=MOCK, provisional_from=None, now=NOW, opts=ARB):
c = make(opts, soc=soc)
c.tariff_plan = provider_plan(prices, provisional_from=provisional_from)
c.tariff_at, c.peak_guard = now, quiet_guard()
s = status(c, now=now)
return ([(r["kind"], r["battery"], r["kwh"]) for r in s["plan"]["runs"]
if r["kind"] in ("grid", "give")], s["plan"]["lines"])
# Review of #144: only what arbitrage.decide() would still do is drawn.
_t, _l = trade((96.0, 35.0))
check("a battery at arbitrage's ceiling gets no charge block, not "
"'Charges up to 0.0 kWh'", ("grid", 0, 0.0) not in _t
and [k for k in _t if k[0] == "grid"] == [("grid", 1, 2.5)])
# 20:00 local: the dearest (19:00) is over; tomorrow's charge is a line. Put
# the dearest later today instead, with the emptiest pack at the floor.
_late = MOCK[:22] + [.40, .24]
_t, _l = trade((62.0, 20.0), prices=_late, now=NOW + timedelta(hours=8))
_t2, _l2 = trade((62.0, 35.0), prices=_late, now=NOW + timedelta(hours=8))
check("no give block while the emptiest battery is at arbitrage's floor: "
"the cycle's sale is graded on the emptiest pack (at 35 % both give)",
not [k for k in _t if k[0] == "give"]
and [k[:2] for k in _t2] == [("give", 0), ("give", 1)])
_t, _l = trade((62.0, 35.0), prices=[x - 0.5 for x in MOCK])
check("no give block at a negative price (the cycle refuses that sale)",
not [k for k in _t if k[0] == "give"]
and [k for k in _t if k[0] == "grid"])
# Running, a missing level is a red row and the site is held. In shadow mode
# the plan is still drawn, so this is where the level gate shows.
_t, _l = trade((None, 35.0), opts=dict(ARB, shadow_mode="on"))
check("with a battery level unknown the cycle does not trade, so nothing is "
"drawn and a line says why (shadow mode, which still plans)",
_t == [] and homeview.line_no_trade("no_level") in _l)
_t, _l = trade((62.0, 35.0), provisional_from=DAY0)
check("a plan with no published prices yet says so, not 'the price gap is "
"too small'",
_t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not in _l)
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_pk = make(ARB)
c_pk.p1_enabled = True # the peak line is shown where P1 runs
c_pk.tariff_plan, c_pk.tariff_at = provider_plan(), NOW
c_pk.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=4200.0,
ceiling_from="month_peak", projected_w=1240.0)
s_pk = status(c_pk)
check("the plan's peak line names the guard's ceiling, which is this month's "
"peak once it is above the limit",
homeview.line_peak_limit(4200.0, False) in s_pk["plan"]["lines"]
and s_pk["month_peak"]["line"].endswith("above 4.2 kW."))
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("</script>", 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 "<link" not in PAGE and "src=" not in PAGE)
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
check(f"every fetch and link is relative, for ingress ({_abs})",
not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS)
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
check(f"every label the page asks for is in app/ui_text ({len(_keys)} "
f"keys; missing: {_missing})", len(_keys) > 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")
+63
View File
@@ -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")
+7 -4
View File
@@ -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 "
+131
View File
@@ -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])))