UX-13 P1: page shell and Home screen on a structured /status
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
month_peak and control beside the old fields. The plan is what the add-on
does today: the arbitrage verdict (the same plan.arbitrage() call as the
cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
"""UX-13 P1: the Home screen's part of `/status`, built from what the add-on
|
||||
already knows and already plans. Reads only; commands nothing.
|
||||
|
||||
`build(controller)` returns `now`, `batteries`, `plan`, `attention`,
|
||||
`month_peak`, `control` and `state`. The old fields (`banner`, `rows`,
|
||||
`checks`, ...) stay beside them for the installer details and for anything
|
||||
that reads the JSON. See DECISIONS/UX-13-one-layout-table.md and the design
|
||||
note, section 3.3.
|
||||
|
||||
The plan shows only what the add-on does today: trading on price (the
|
||||
arbitrage verdict, the same call the control cycle makes), the monthly battery
|
||||
cycle, the peak limit and "follows the house". Battery modes (OPT-11) are not
|
||||
built, so nothing here invents a mode plan. Every sentence is in the words
|
||||
section at the bottom and is held to STYLE.md by test_messages.py.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE
|
||||
from .price import Absence, Uncertainty, brussels
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
from .uimodel import REGION_PRESETS, options_to_ui
|
||||
|
||||
# A battery at or under this many watts is resting, not charging or giving.
|
||||
IDLE_W = 50.0
|
||||
# Display only: the lowest third of today's price range is "cheap", the top
|
||||
# third "expensive". A price range under this is flat, and nothing is marked.
|
||||
FLAT_EUR_KWH = 0.01
|
||||
|
||||
# Severity words the page shows; the same four as STYLE.md section 3.
|
||||
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Time: today's local day, on the billing calendar
|
||||
# --------------------------------------------------------------------------- #
|
||||
def local_day(now: datetime):
|
||||
"""`(midnight_utc, next_midnight_utc)` of the local day holding `now`."""
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
start = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nxt = start.date() + timedelta(days=1)
|
||||
end = datetime(nxt.year, nxt.month, nxt.day, tzinfo=BILLING_TZ)
|
||||
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _hours(t: datetime, day0: datetime) -> float:
|
||||
return (t - day0).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def hhmm(t: datetime) -> str:
|
||||
return f"{brussels(t):%H:%M}"
|
||||
|
||||
|
||||
def _kw(w) -> str:
|
||||
return f"{abs(float(w)) / 1000:.1f} kW"
|
||||
|
||||
|
||||
def _eur(v) -> str:
|
||||
return f"€{float(v):.2f}"
|
||||
|
||||
|
||||
def _kwh(v) -> str:
|
||||
return f"{float(v):.1f} kWh"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Now
|
||||
# --------------------------------------------------------------------------- #
|
||||
def flow(w, *, shadow=False):
|
||||
"""`charging` / `giving` / `resting` / None for one battery power."""
|
||||
if w is None:
|
||||
return None
|
||||
if shadow:
|
||||
return "resting"
|
||||
if w > IDLE_W:
|
||||
return "giving"
|
||||
if w < -IDLE_W:
|
||||
return "charging"
|
||||
return "resting"
|
||||
|
||||
|
||||
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow):
|
||||
"""The four tiles. `house_w` needs all three readings, so without a solar
|
||||
reading it is None rather than a number that leaves the sun out."""
|
||||
house = None
|
||||
if has_solar and None not in (grid_w, pv_w, batt_w):
|
||||
house = float(grid_w) + float(pv_w) + float(batt_w)
|
||||
return {
|
||||
"solar_w": None if pv_w is None else float(pv_w),
|
||||
"house_w": house,
|
||||
"grid_w": None if grid_w is None else float(grid_w),
|
||||
"batt_w": None if batt_w is None else float(batt_w),
|
||||
"has_solar": bool(has_solar),
|
||||
"lines": {
|
||||
"solar": tile_solar(pv_w, has_solar),
|
||||
"house": tile_house(house, has_solar),
|
||||
"grid": tile_grid(grid_w),
|
||||
"batt": tile_batt(batt_w, shadow),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Prices for today's chart
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _points(plan):
|
||||
if plan is None:
|
||||
return ()
|
||||
return plan.read.match(series=lambda r: tuple(r.points),
|
||||
no_data=lambda _r: ())
|
||||
|
||||
|
||||
def _spans(points):
|
||||
"""`[(point, end)]`: each point runs until the next one starts, and the
|
||||
last one for the series' own spacing (15 minutes or an hour)."""
|
||||
pts = sorted(points, key=lambda p: p.start)
|
||||
gaps = [b.start - a.start for a, b in zip(pts, pts[1:])]
|
||||
step = min([g for g in gaps if g > timedelta(0)] or [timedelta(hours=1)])
|
||||
step = min(step, timedelta(hours=1))
|
||||
out = []
|
||||
for i, p in enumerate(pts):
|
||||
nxt = pts[i + 1].start if i + 1 < len(pts) else None
|
||||
end = nxt if nxt is not None and nxt - p.start <= timedelta(hours=1) \
|
||||
else p.start + step
|
||||
out.append((p, end))
|
||||
return out
|
||||
|
||||
|
||||
def _day_prices(c):
|
||||
cache = getattr(c, "home_prices", None)
|
||||
if cache is None:
|
||||
cache = c.home_prices = DayPrices()
|
||||
return cache
|
||||
|
||||
|
||||
class DayPrices:
|
||||
"""Today's prices as seen so far. The refresh keeps only the hours ahead,
|
||||
so the hours already past are remembered here for the chart. Display only:
|
||||
nothing reads this back into a decision."""
|
||||
|
||||
def __init__(self):
|
||||
self.day = None
|
||||
self.points = {}
|
||||
|
||||
def see(self, points, day0, day1):
|
||||
if self.day != day0:
|
||||
self.day, self.points = day0, {}
|
||||
for p in points:
|
||||
if day0 <= p.start < day1:
|
||||
self.points[p.start] = p
|
||||
return sorted(self.points.values(), key=lambda p: p.start)
|
||||
|
||||
|
||||
def hour_bars(points, day0, day1):
|
||||
"""One bar per local hour: the mean of its prices, or None when unknown.
|
||||
`kind` is cheap / dear / mid by thirds of today's range (display only)."""
|
||||
n = int(round(_hours(day1, day0)))
|
||||
bars = []
|
||||
for k in range(n):
|
||||
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1)
|
||||
inside = [p for p in points if a <= p.start < b]
|
||||
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
|
||||
if inside else None)
|
||||
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
|
||||
"end_label": hhmm(b),
|
||||
"eur_kwh": None if price is None else round(price, 4),
|
||||
"estimate": bool(inside) and any(p.provisional
|
||||
for p in inside),
|
||||
"kind": None})
|
||||
known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None]
|
||||
if known and max(known) - min(known) >= FLAT_EUR_KWH:
|
||||
lo, hi = min(known), max(known)
|
||||
third = (hi - lo) / 3.0
|
||||
for b in bars:
|
||||
p = b["eur_kwh"]
|
||||
if p is None:
|
||||
continue
|
||||
b["kind"] = ("cheap" if p <= lo + third else
|
||||
"dear" if p >= hi - third else "mid")
|
||||
elif known:
|
||||
for b in bars:
|
||||
if b["eur_kwh"] is not None:
|
||||
b["kind"] = "mid"
|
||||
return bars
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The plan, per battery
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None,
|
||||
eur=None, price=None):
|
||||
a = max(0.0, _hours(start, day0))
|
||||
z = min(_hours(day1, day0), _hours(end, day0))
|
||||
return {"battery": i, "kind": kind, "start": start.isoformat(),
|
||||
"end": end.isoformat(), "from": hhmm(start),
|
||||
"to": "24:00" if end == day1 else hhmm(end),
|
||||
"start_h": round(a, 3), "end_h": round(z, 3),
|
||||
"what": what, "why": why, "amount": amount,
|
||||
"kwh": None if kwh is None else round(kwh, 2),
|
||||
"eur": None if eur is None else round(eur, 2),
|
||||
"eur_kwh": None if price is None else round(price, 4)}
|
||||
|
||||
|
||||
def trade_verdict(c, plan):
|
||||
"""The arbitrage verdict the control cycle would reach on this plan, or a
|
||||
reason there is none. The SAME call and inputs as `Controller.cycle()`:
|
||||
`plan.arbitrage()` with the peak guard's state, the cycle cost and the
|
||||
no-weather uncertainty. Returns `(verdict_or_None, why_key)`."""
|
||||
if not arbitrage.enabled(c.o):
|
||||
return None, "off"
|
||||
guard = arbitrage.guard_state(c.peak_guard)
|
||||
if guard is None:
|
||||
return None, "not_covered"
|
||||
if plan is None:
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
refused=lambda _v: (None, "peak" if guard else "not_worth"))
|
||||
|
||||
|
||||
def _point_end(plan, point):
|
||||
for p, end in _spans(_points(plan)):
|
||||
if p.start == point.start:
|
||||
return end
|
||||
return point.start + timedelta(hours=1)
|
||||
|
||||
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
"""A charge block and a give block on each battery, where the verdict put
|
||||
them. The site trades as one; each battery's share is capped by its own
|
||||
rating and by the room its level leaves (arbitrage's own floor/ceiling)."""
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
runs, later = [], []
|
||||
for kind, pt in (("grid", verdict.cheapest), ("give", verdict.dearest)):
|
||||
end = _point_end(plan, pt)
|
||||
if end <= now:
|
||||
continue
|
||||
if not (day0 <= pt.start < day1):
|
||||
later.append((kind, pt))
|
||||
continue
|
||||
hrs = (end - pt.start).total_seconds() / 3600.0
|
||||
for i, inv in enumerate(c.inverters):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
if inv.capacity_kwh and inv.soc is not None:
|
||||
room = (ceiling - inv.soc if kind == "grid"
|
||||
else inv.soc - floor)
|
||||
kwh = min(kwh, max(0.0, room) / 100.0 * inv.capacity_kwh)
|
||||
eur = kwh * pt.eur_per_kwh
|
||||
if kind == "grid":
|
||||
what, why, amount = (run_grid_what(kwh, shadow),
|
||||
run_grid_why(pt.eur_per_kwh),
|
||||
run_grid_amount(eur))
|
||||
else:
|
||||
what, why, amount = (run_give_what(kwh, shadow),
|
||||
run_give_why(pt.eur_per_kwh),
|
||||
run_give_amount(eur))
|
||||
runs.append(_run(i, kind, pt.start, end, day0, day1, what, why,
|
||||
amount, kwh=kwh, eur=eur, price=pt.eur_per_kwh))
|
||||
return runs, later
|
||||
|
||||
|
||||
def maintenance_runs(c, day0, day1, now, shadow):
|
||||
"""The monthly battery cycle: under way now, or starting later today.
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read as the
|
||||
control cycle reads it (UTC hour of `now`), shown in local time."""
|
||||
runs, lines = [], []
|
||||
waiting = []
|
||||
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
|
||||
cfg = m.cfg
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
cap = inv.capacity_kwh
|
||||
if m.phase != M_IDLE:
|
||||
hours = _cycle_hours(m.phase, inv.soc, cap, cfg)
|
||||
end = now + timedelta(hours=hours or 1.0)
|
||||
runs.append(_run(i, "cycle", now, end, day0, day1,
|
||||
run_cycle_what(m.phase, shadow),
|
||||
run_cycle_why(shadow),
|
||||
run_cycle_amount(hours)))
|
||||
continue
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
|
||||
microsecond=0)
|
||||
if start + timedelta(hours=1) <= now:
|
||||
start += timedelta(days=1)
|
||||
due_at = m.next_due(now)
|
||||
if due_at is not None and due_at > start:
|
||||
continue
|
||||
if not (day0 <= start < day1):
|
||||
continue
|
||||
waiting.append((m.waited_days(now), i, start))
|
||||
# One battery cycles at a time (maintenance.arbitrate); the one waiting
|
||||
# longest goes first, and none starts while another is under way.
|
||||
busy = any(m.phase != M_IDLE for m in c.maints)
|
||||
waiting.sort(key=lambda t: (-t[0], t[1]))
|
||||
for n, (_w, i, start) in enumerate(waiting):
|
||||
inv, m = c.inverters[i], c.maints[i]
|
||||
hours = _cycle_hours(M_DRAIN, inv.soc, inv.capacity_kwh, m.cfg)
|
||||
if n == 0 and not busy:
|
||||
end = start + timedelta(hours=hours or 1.0)
|
||||
runs.append(_run(i, "cycle", start, end, day0, day1,
|
||||
run_cycle_what(M_IDLE, shadow),
|
||||
run_cycle_why(shadow),
|
||||
run_cycle_amount(hours)))
|
||||
else:
|
||||
lines.append(line_cycle_waits(inv.name))
|
||||
return runs, lines
|
||||
|
||||
|
||||
def _cycle_hours(phase, soc, cap_kwh, cfg):
|
||||
"""Hours left in a cycle, or None when the capacity or level is unknown."""
|
||||
if phase == M_HOLD:
|
||||
return cfg.hold_min / 60.0
|
||||
if not cap_kwh or soc is None:
|
||||
return None
|
||||
hours = cfg.hold_min / 60.0
|
||||
fill_from = soc if phase == M_CHARGE else cfg.soc_floor
|
||||
hours += (max(0.0, cfg.soc_target - fill_from) / 100.0 * cap_kwh
|
||||
/ max(cfg.charge_w / 1000.0, 0.1))
|
||||
if phase not in (M_CHARGE,):
|
||||
hours += (max(0.0, soc - cfg.soc_floor) / 100.0 * cap_kwh
|
||||
/ max(cfg.discharge_w / 1000.0, 0.1))
|
||||
return hours
|
||||
|
||||
|
||||
def peak_run(c, day0, day1, now, shadow, limit_w):
|
||||
"""While the batteries hold the grid under the peak limit, one block for
|
||||
the current 15-minute period on every battery."""
|
||||
pg = c.peak_guard
|
||||
if pg is None or not pg.engaged:
|
||||
return []
|
||||
start = now - timedelta(minutes=now.minute % 15, seconds=now.second,
|
||||
microseconds=now.microsecond)
|
||||
end = start + timedelta(minutes=15)
|
||||
return [_run(i, "peak", start, end, day0, day1,
|
||||
run_peak_what(pg.ceiling_w or limit_w, shadow),
|
||||
run_peak_why(), None)
|
||||
for i in range(len(c.inverters))]
|
||||
|
||||
|
||||
def house_runs(n, day0, day1, now, shadow):
|
||||
""""Follows the house" for the rest of the day, drawn under the others."""
|
||||
return [_run(i, "house", max(now, day0), day1, day0, day1,
|
||||
run_house_what(shadow), run_house_why(shadow),
|
||||
run_house_amount())
|
||||
for i in range(n)]
|
||||
|
||||
|
||||
def region_of(opts):
|
||||
"""The levies preset and its year, as the page model has it (UX-13 P0)."""
|
||||
try:
|
||||
ui = options_to_ui(opts)
|
||||
except Exception: # noqa: BLE001
|
||||
return {"id": None, "year": None}
|
||||
rid = ui.get("ui.region")
|
||||
return {"id": rid, "year": ui.get("region_year")
|
||||
if rid in REGION_PRESETS else None}
|
||||
|
||||
|
||||
def build_plan(c, now, shadow, running, held=False):
|
||||
day0, day1 = local_day(now)
|
||||
fresh = c.fresh_tariff_plan(now)
|
||||
plan = fresh
|
||||
if plan is None or plan.origin == "static":
|
||||
# A static profile is pure arithmetic: price the whole day from it.
|
||||
try:
|
||||
static = static_plan(c.o, day0, day1, now=now)
|
||||
if static.has_prices:
|
||||
plan = static
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
seen = _day_prices(c).see(_points(plan), day0, day1)
|
||||
bars = hour_bars(seen, day0, day1)
|
||||
|
||||
limit_w = c.peak.cap_w # the limit the controller parsed at start
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
if running:
|
||||
runs += house_runs(len(c.inverters), day0, day1, now, shadow)
|
||||
if verdict is not None:
|
||||
trade, later = trade_runs(c, fresh, verdict, day0, day1, now,
|
||||
shadow)
|
||||
runs += trade
|
||||
for kind, pt in later:
|
||||
lines.append(line_trade_tomorrow(kind, hhmm(pt.start),
|
||||
pt.eur_per_kwh, shadow))
|
||||
else:
|
||||
lines.append(line_no_trade(trade_why))
|
||||
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
|
||||
runs += cyc
|
||||
lines += waits
|
||||
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
||||
if c.p1_enabled:
|
||||
lines.append(line_peak_limit(limit_w, shadow))
|
||||
lines = [x for x in lines if x]
|
||||
|
||||
kinds = {r["kind"] for r in runs}
|
||||
if held:
|
||||
verdict_key = "held"
|
||||
elif not running:
|
||||
verdict_key = "stopped"
|
||||
elif "cycle" in kinds:
|
||||
verdict_key = "cycle"
|
||||
elif "grid" in kinds or "give" in kinds:
|
||||
verdict_key = "trade"
|
||||
else:
|
||||
verdict_key = "house"
|
||||
|
||||
known = [b for b in bars if b["eur_kwh"] is not None]
|
||||
price_now = None
|
||||
try:
|
||||
if plan is not None:
|
||||
price_now = plan.price_at(now)
|
||||
except Absence:
|
||||
price_now = None
|
||||
feed = fresh is not None and fresh.from_feed
|
||||
tomorrow = any(not p.provisional and p.start >= day1
|
||||
for p in _points(fresh))
|
||||
region = region_of(c.o)
|
||||
return {
|
||||
"day": brussels(now).date().isoformat(),
|
||||
"now_h": round(_hours(now, day0), 3),
|
||||
"hours": int(round(_hours(day1, day0))),
|
||||
"verdict": VERDICT[verdict_key],
|
||||
"verdict_key": verdict_key,
|
||||
"head": plan_head(verdict_key, shadow),
|
||||
"prices": bars,
|
||||
"runs": runs,
|
||||
"lines": lines,
|
||||
"why": c.why_words,
|
||||
"price_line": line_prices(
|
||||
price_now,
|
||||
min(known, key=lambda b: b["eur_kwh"]) if known else None,
|
||||
max(known, key=lambda b: b["eur_kwh"]) if known else None),
|
||||
"tomorrow_line": line_tomorrow(tomorrow) if feed else "",
|
||||
"region": region,
|
||||
"region_line": (line_region(region["id"], region["year"])
|
||||
if feed else ""),
|
||||
"has_prices": bool(known),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Batteries, attention, month peak, control
|
||||
# --------------------------------------------------------------------------- #
|
||||
def battery_rows(c, shadow, running, held=False):
|
||||
shares = {}
|
||||
alloc = getattr(c, "allocation", None)
|
||||
if shadow and alloc is not None and getattr(alloc, "shares", None):
|
||||
shares = dict(alloc.shares)
|
||||
rows = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
m = c.maints[i] if i < len(c.maints) else None
|
||||
phase = m.phase if m is not None else M_IDLE
|
||||
cap = inv.capacity_kwh
|
||||
rows.append({
|
||||
"name": inv.name,
|
||||
"level_pct": None if inv.soc is None else float(inv.soc),
|
||||
"power_w": None if inv.batt is None else float(inv.batt),
|
||||
"capacity_kwh": cap,
|
||||
"stored_kwh": (None if cap is None or inv.soc is None
|
||||
else round(cap * float(inv.soc) / 100.0, 2)),
|
||||
"state": flow(inv.batt, shadow=shadow),
|
||||
"mode": MODE_HOUSE,
|
||||
"maintenance": phase,
|
||||
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
|
||||
running=running, held=held,
|
||||
would_w=shares.get(inv.name)),
|
||||
# Per-battery reasons arrive with battery modes (OPT-11).
|
||||
"why": None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# Row id -> the title on Home. The row's own sentence is the body.
|
||||
ATTN_TITLES = {
|
||||
"controller": "Control is not running",
|
||||
"meter": "Meter reading",
|
||||
"meter_frozen": messages.T_METER_FROZEN,
|
||||
"freeze_check": "Stuck meter check",
|
||||
"month_peak": "This month's peak",
|
||||
"peak_guard": "Peak limit",
|
||||
"peak_forecast": "Peak limit forecast",
|
||||
"sensor": "Battery readings",
|
||||
"control": "Inverter control",
|
||||
"power_too_high": messages.T_OUTPUT_CLAMP,
|
||||
"not_keeping_up": messages.T_LOOP_BOUND,
|
||||
"write": "Power request not accepted",
|
||||
"direction": "Power went the wrong way",
|
||||
"site_direction": "Power went the wrong way",
|
||||
"circulating": messages.T_CIRCULATING,
|
||||
"delivery": messages.S_NOT_DELIVERING,
|
||||
"estop": "Emergency stop",
|
||||
"pv": "Solar reading",
|
||||
None: "Inverter connection",
|
||||
}
|
||||
# Row id -> the DOCS.md heading under Notifications, where one exists.
|
||||
ATTN_DOCS = {
|
||||
"meter_frozen": messages.T_METER_FROZEN,
|
||||
"power_too_high": messages.T_OUTPUT_CLAMP,
|
||||
"not_keeping_up": messages.T_LOOP_BOUND,
|
||||
"circulating": messages.T_CIRCULATING,
|
||||
"delivery": messages.S_NOT_DELIVERING,
|
||||
}
|
||||
|
||||
|
||||
def attention(checks):
|
||||
"""Every row that is not plain green: red rows as alarms, amber rows as
|
||||
warnings, and green-but-amber rows (a state still settling) as info.
|
||||
Most urgent first; within a level, the page's own row order."""
|
||||
rank = {ALARM: 0, WARNING: 1, INFO: 2}
|
||||
out = []
|
||||
for row in checks:
|
||||
ok, warn = bool(row.get("ok")), bool(row.get("warn"))
|
||||
if ok and not warn:
|
||||
continue
|
||||
sev = INFO if ok else (WARNING if warn else ALARM)
|
||||
rid = row.get("id")
|
||||
out.append({"severity": sev, "id": rid,
|
||||
"title": ATTN_TITLES.get(rid, ATTN_TITLES[None]),
|
||||
"body": row.get("text", ""),
|
||||
"docs": ATTN_DOCS.get(rid)})
|
||||
out.sort(key=lambda a: rank[a["severity"]])
|
||||
return out
|
||||
|
||||
|
||||
def month_peak(c, now):
|
||||
"""This month's peak against the limit, and the 15-minute period so far."""
|
||||
limit_w = c.peak.cap_w # the limit the controller parsed at start
|
||||
local = brussels(now)
|
||||
out = {"month": f"{local:%B}", "limit_w": limit_w, "peak_w": None,
|
||||
"pct": None, "period_w": None, "state": "not_covered",
|
||||
"line": ""}
|
||||
if not c.p1_enabled:
|
||||
out["line"] = line_peak_not_covered()
|
||||
return out
|
||||
reading = c.peak.peak(now)
|
||||
if reading.is_unreadable:
|
||||
out["state"] = "unreadable"
|
||||
elif reading.is_observed:
|
||||
out["state"] = "observed"
|
||||
out["peak_w"] = float(reading.peak_w)
|
||||
out["pct"] = round(100.0 * float(reading.peak_w) / limit_w, 1) \
|
||||
if limit_w > 0 else None
|
||||
else:
|
||||
out["state"] = "none"
|
||||
pg = c.peak_guard
|
||||
ceiling = limit_w
|
||||
if pg is not None:
|
||||
out["period_w"] = pg.projected_w
|
||||
ceiling = pg.ceiling_w or limit_w
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
|
||||
return out
|
||||
|
||||
|
||||
def state_of(c, level, shadow):
|
||||
"""One word for the pill and the banner's colour: running, shadow,
|
||||
stopped, alarm or warning."""
|
||||
if shadow:
|
||||
return "shadow"
|
||||
if level == "bad":
|
||||
return "alarm"
|
||||
if not c.auto:
|
||||
return "stopped"
|
||||
if level == "warn":
|
||||
return "warning"
|
||||
return "running"
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
|
||||
"""The structured half of `/status`. Pure reads of the controller."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
shadow = bool(c.shadow)
|
||||
kind = state_of(c, level, shadow)
|
||||
# A red banner means every inverter is at 0 W (the status cascade), so
|
||||
# nothing is planned until it clears. Shadow mode still plans: it is
|
||||
# what the add-on would do.
|
||||
held = level == "bad" and not shadow
|
||||
running = (bool(c.auto) or shadow) and not held
|
||||
ctl = next((r for r in checks if r.get("id") == "controller"), None)
|
||||
return {
|
||||
"state": {"kind": kind, "pill": pill(kind),
|
||||
"ran": ctl.get("text", "") if ctl else "",
|
||||
"sub": banner_sub(kind)},
|
||||
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
|
||||
has_solar=bool(c.pv_roster), shadow=shadow),
|
||||
"batteries": battery_rows(c, shadow, running, held),
|
||||
"plan": build_plan(c, now, shadow, running, held=held),
|
||||
"attention": ([{"severity": INFO, "id": "shadow",
|
||||
"title": ATTN_SHADOW, "body": attn_shadow_body(),
|
||||
"docs": None}] if shadow else []) + attention(checks),
|
||||
"month_peak": month_peak(c, now),
|
||||
"control": {"running": bool(c.auto), "shadow": shadow,
|
||||
"text": control_text(bool(c.auto), shadow),
|
||||
"maintenance": c.maint.phase,
|
||||
"docs_url": _docs_url()},
|
||||
}
|
||||
|
||||
|
||||
def _docs_url():
|
||||
slug = messages.addon_slug()
|
||||
return None if slug is None else messages.docs_url(slug,
|
||||
messages._site["ha"])
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# The words. STYLE.md: plain, short, present tense. A plan line is at most
|
||||
# two sentences of at most 12 words each (UX-13 design, section 4); a title at
|
||||
# most 5 words; everything else one sentence of at most 25 words. In shadow
|
||||
# mode a text says what the add-on WOULD do. test_messages.py renders every
|
||||
# function below through `text_samples()` and checks it.
|
||||
# =========================================================================== #
|
||||
MODE_HOUSE = "Follows the house"
|
||||
ATTN_SHADOW = "Shadow mode is on"
|
||||
|
||||
|
||||
def attn_shadow_body() -> str:
|
||||
return "The add-on decides everything, but every inverter stays at 0 W."
|
||||
|
||||
VERDICT = {
|
||||
"held": "Held at 0 W",
|
||||
"stopped": "Stopped",
|
||||
"cycle": "Monthly cycle today",
|
||||
"trade": "Trading on price",
|
||||
"house": "Follows the house",
|
||||
}
|
||||
|
||||
|
||||
def _w(shadow, live, would):
|
||||
return would if shadow else live
|
||||
|
||||
|
||||
def pill(kind) -> str:
|
||||
return {"running": "Running", "shadow": "Shadow mode",
|
||||
"stopped": "Stopped", "alarm": "Needs you now",
|
||||
"warning": "Running, with a warning"}[kind]
|
||||
|
||||
|
||||
def banner_sub(kind) -> str:
|
||||
return {
|
||||
"running": "The batteries follow the plan below.",
|
||||
"shadow": "The plans below say what the add-on would do.",
|
||||
"stopped": "Start control below when you are ready.",
|
||||
"alarm": "See Needs attention below for what to do.",
|
||||
"warning": "See Needs attention below.",
|
||||
}[kind]
|
||||
|
||||
|
||||
def tile_solar(pv_w, has_solar) -> str:
|
||||
if not has_solar:
|
||||
return "Not set up"
|
||||
if pv_w is None:
|
||||
return "No reading"
|
||||
return "Producing" if pv_w > IDLE_W else "Not producing"
|
||||
|
||||
|
||||
def tile_house(house_w, has_solar) -> str:
|
||||
if not has_solar:
|
||||
return "Needs a solar reading"
|
||||
return "No reading" if house_w is None else "Using"
|
||||
|
||||
|
||||
def tile_grid(grid_w) -> str:
|
||||
if grid_w is None:
|
||||
return "No reading"
|
||||
if grid_w > IDLE_W:
|
||||
return "Taking from the grid"
|
||||
if grid_w < -IDLE_W:
|
||||
return "Feeding in"
|
||||
return "Balanced"
|
||||
|
||||
|
||||
def tile_batt(batt_w, shadow) -> str:
|
||||
if shadow:
|
||||
return "Held at 0 W"
|
||||
return {"giving": "Giving power", "charging": "Charging",
|
||||
"resting": "Resting", None: "No reading"}[flow(batt_w)]
|
||||
|
||||
|
||||
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
||||
would_w=None) -> str:
|
||||
if soc is None or batt_w is None:
|
||||
return "No reading from this battery."
|
||||
if shadow:
|
||||
if would_w is None or abs(would_w) <= IDLE_W:
|
||||
return "Held at 0 W. Would rest now."
|
||||
verb = "give" if would_w > 0 else "charge"
|
||||
return f"Held at 0 W. Would {verb} {_kw(would_w)} now."
|
||||
if held:
|
||||
return "Held at 0 W until the alarm clears."
|
||||
if not running:
|
||||
return "Held at 0 W while control is stopped."
|
||||
if phase != M_IDLE:
|
||||
return {M_DRAIN: "Monthly cycle: emptying.",
|
||||
M_CHARGE: "Monthly cycle: refilling.",
|
||||
M_HOLD: "Monthly cycle: holding full."}.get(
|
||||
phase, "Monthly cycle under way.")
|
||||
st = flow(batt_w)
|
||||
if st == "giving":
|
||||
return f"Giving {_kw(batt_w)} now."
|
||||
if st == "charging":
|
||||
return f"Charging {_kw(batt_w)} now."
|
||||
return "Resting now."
|
||||
|
||||
|
||||
def plan_head(key, shadow) -> str:
|
||||
if key == "held":
|
||||
return "Every inverter is at 0 W until the alarm clears."
|
||||
if key == "stopped":
|
||||
return "Control is stopped, so nothing is planned."
|
||||
if not shadow:
|
||||
return {
|
||||
"cycle": "A battery runs its monthly cycle today.",
|
||||
"trade": "Charges in the cheapest hour, gives power in the dearest.",
|
||||
"house": "Charges from spare solar and covers the house.",
|
||||
}[key]
|
||||
return "Shadow mode: this is what the add-on would do. " + {
|
||||
"cycle": "A battery would run its monthly cycle today.",
|
||||
"trade": "Would charge when cheapest and give power when dearest.",
|
||||
"house": "Would charge from spare solar and cover the house.",
|
||||
}[key]
|
||||
|
||||
|
||||
def run_grid_what(kwh, shadow) -> str:
|
||||
return _w(shadow, "Charges", "Would charge") + \
|
||||
f" up to {_kwh(kwh)} from the grid."
|
||||
|
||||
|
||||
def run_grid_why(price) -> str:
|
||||
return f"Cheapest price in the list, {_eur(price)} per kWh."
|
||||
|
||||
|
||||
def run_grid_amount(eur) -> str:
|
||||
return f"Costs up to {_eur(eur)}."
|
||||
|
||||
|
||||
def run_give_what(kwh, shadow) -> str:
|
||||
return _w(shadow, "Gives", "Would give") + \
|
||||
f" up to {_kwh(kwh)}, to the house first."
|
||||
|
||||
|
||||
def run_give_why(price) -> str:
|
||||
return f"Dearest price in the list, {_eur(price)} per kWh."
|
||||
|
||||
|
||||
def run_give_amount(eur) -> str:
|
||||
return f"Worth up to {_eur(eur)} at that price."
|
||||
|
||||
|
||||
def run_cycle_what(phase, shadow) -> str:
|
||||
if phase == M_IDLE:
|
||||
return _w(shadow, "Starts", "Would start") + \
|
||||
" the monthly battery cycle."
|
||||
return _w(shadow, "Runs", "Would run") + " the monthly battery cycle now."
|
||||
|
||||
|
||||
def run_cycle_why(shadow=False) -> str:
|
||||
return _w(shadow, "Empties, refills and holds full",
|
||||
"Would empty, refill and hold full") + \
|
||||
", so the level reads right."
|
||||
|
||||
|
||||
def run_cycle_amount(hours) -> str:
|
||||
if hours is None:
|
||||
return "How long depends on the battery."
|
||||
return f"Takes about {max(1, round(hours))} hours."
|
||||
|
||||
|
||||
def run_peak_what(limit_w, shadow) -> str:
|
||||
return _w(shadow, "Gives", "Would give") + \
|
||||
f" power to stay under {_kw(limit_w)}."
|
||||
|
||||
|
||||
def run_peak_why() -> str:
|
||||
return "The peak limit comes before everything else."
|
||||
|
||||
|
||||
def run_house_what(shadow) -> str:
|
||||
return _w(shadow, "Follows", "Would follow") + " the house."
|
||||
|
||||
|
||||
def run_house_why(shadow=False) -> str:
|
||||
return _w(shadow, "Charges from spare solar, gives power",
|
||||
"Would charge from spare solar, give power") + \
|
||||
" when the house needs it."
|
||||
|
||||
|
||||
def run_house_amount() -> str:
|
||||
return "Amounts depend on the sun and the house."
|
||||
|
||||
|
||||
def line_no_trade(key) -> str:
|
||||
return {
|
||||
"off": "",
|
||||
"not_covered": "No trading on price: nothing measures the 15-minute "
|
||||
"peak.",
|
||||
"no_prices": "No trading on price yet: no prices have arrived.",
|
||||
"peak": "No trading on price now: the peak limit comes first.",
|
||||
"not_worth": "No trading on price today. The price gap is too small.",
|
||||
}[key]
|
||||
|
||||
|
||||
def line_trade_tomorrow(kind, at, price, shadow) -> str:
|
||||
verb = {"grid": _w(shadow, "Charges", "Would charge"),
|
||||
"give": _w(shadow, "Gives power", "Would give power")}[kind]
|
||||
return f"Tomorrow: {verb.lower()} at {at}, {_eur(price)} per kWh."
|
||||
|
||||
|
||||
def line_cycle_waits(name) -> str:
|
||||
return f"{name} waits its turn for the monthly cycle."
|
||||
|
||||
|
||||
def line_peak_limit(limit_w, shadow) -> str:
|
||||
return _w(shadow, "The batteries step in", "The batteries would step in") \
|
||||
+ f" above {_kw(limit_w)}."
|
||||
|
||||
|
||||
def line_prices(now_eur, lo, hi) -> str:
|
||||
parts = []
|
||||
if now_eur is not None:
|
||||
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
|
||||
if lo is not None and hi is not None:
|
||||
parts.append(f"Today: cheapest {_eur(lo['eur_kwh'])} at "
|
||||
f"{lo['label']}, dearest {_eur(hi['eur_kwh'])} at "
|
||||
f"{hi['label']}.")
|
||||
return " ".join(parts) or "No prices for today."
|
||||
|
||||
|
||||
def line_tomorrow(known) -> str:
|
||||
return ("Tomorrow's prices are in." if known else
|
||||
"Tomorrow's prices usually arrive after 13:00.")
|
||||
|
||||
|
||||
def line_region(rid, year) -> str:
|
||||
if rid in REGION_PRESETS and year:
|
||||
return f"Levies and VAT: {REGION_WORDS.get(rid, rid)}, {year} figures."
|
||||
return "Levies and VAT: your own figure."
|
||||
|
||||
|
||||
REGION_WORDS = {"flanders": "Flanders, Fluvius Antwerpen"}
|
||||
|
||||
|
||||
def line_peak_not_covered() -> str:
|
||||
return "No peak limit: this meter does not measure 15-minute periods."
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow) -> str:
|
||||
step = _w(shadow, "The batteries step in", "The batteries would step in")
|
||||
if period_w is None:
|
||||
return f"{step} above {_kw(limit_w)}."
|
||||
return (f"This 15-minute period is heading for {_kw(period_w)}. "
|
||||
f"{step} above {_kw(limit_w)}.")
|
||||
|
||||
|
||||
def control_text(running, shadow) -> str:
|
||||
if shadow:
|
||||
return "Shadow mode: every inverter stays at 0 W."
|
||||
if running:
|
||||
return "Control is running."
|
||||
return "Control is stopped. Every inverter is at 0 W."
|
||||
|
||||
|
||||
def text_samples() -> list:
|
||||
"""`(kind, text)` for every sentence above, rendered with real values, so
|
||||
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
|
||||
(2 sentences of 12 words), line (1 sentence of 25 words)."""
|
||||
out = [("title", t) for t in VERDICT.values()]
|
||||
out += [("title", t) for t in ATTN_TITLES.values()]
|
||||
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
|
||||
("line", attn_shadow_body())]
|
||||
for k in ("running", "shadow", "stopped", "alarm", "warning"):
|
||||
out += [("title", pill(k)), ("line", banner_sub(k))]
|
||||
for sh in (False, True):
|
||||
out += [("title", tile_solar(v, s)) for v, s in
|
||||
((None, False), (None, True), (3900, True), (0, True))]
|
||||
out += [("title", tile_house(v, s)) for v, s in
|
||||
((None, False), (None, True), (1100, True))]
|
||||
out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)]
|
||||
out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)]
|
||||
for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD):
|
||||
for w in (2500, -2500, 0):
|
||||
out.append(("plan", battery_now(62, w, phase=phase,
|
||||
shadow=sh, running=True,
|
||||
would_w=-2400)))
|
||||
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh,
|
||||
running=False)))
|
||||
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False,
|
||||
running=False, held=True)))
|
||||
out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh,
|
||||
running=True)))
|
||||
for k in VERDICT:
|
||||
out.append(("plan", plan_head(k, sh)))
|
||||
out += [("plan", run_grid_what(12.34, sh)),
|
||||
("plan", run_give_what(9.6, sh)),
|
||||
("plan", run_cycle_what(M_IDLE, sh)),
|
||||
("plan", run_cycle_what(M_DRAIN, sh)),
|
||||
("plan", run_peak_what(3500, sh)),
|
||||
("plan", run_house_what(sh)),
|
||||
("plan", line_trade_tomorrow("grid", "02:00", 0.1234, sh)),
|
||||
("plan", line_trade_tomorrow("give", "19:00", 0.3612, sh)),
|
||||
("plan", line_peak_limit(3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh)),
|
||||
("plan", line_period(None, 3500, sh)),
|
||||
("plan", control_text(True, sh)),
|
||||
("plan", control_text(False, sh))]
|
||||
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
|
||||
("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)),
|
||||
("plan", run_cycle_why()), ("plan", run_cycle_why(True)),
|
||||
("plan", run_cycle_amount(None)),
|
||||
("plan", run_cycle_amount(5.4)), ("plan", run_peak_why()),
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
|
||||
{"eur_kwh": 0.36, "label": "19:00"})),
|
||||
("plan", line_prices(None, None, None)),
|
||||
("plan", line_tomorrow(True)), ("plan", line_tomorrow(False)),
|
||||
("plan", line_region("flanders", 2026)),
|
||||
("plan", line_region("own", None)),
|
||||
("plan", line_peak_not_covered())]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_worth")]
|
||||
return out
|
||||
@@ -54,6 +54,7 @@ from .clamp import (
|
||||
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import homeview
|
||||
from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
@@ -1047,6 +1048,9 @@ class Controller:
|
||||
# because `self.peak_guard` is a different cycle's decision by the time
|
||||
# the status page renders.
|
||||
self.arb_headroom_w = None
|
||||
# UX-13 P1: today's prices as the Home chart has seen them. Display
|
||||
# only; nothing reads it back into a decision.
|
||||
self.home_prices = homeview.DayPrices()
|
||||
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
|
||||
# is the exact, derived statement of "the latest cycle did not get as
|
||||
# far as the arbitrage step", which is the fact the *Arbitrage* row
|
||||
@@ -5508,7 +5512,7 @@ class Controller:
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
return {
|
||||
out = {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
"hint": self.hint,
|
||||
# The action log itself, newest first. The page does not show it
|
||||
@@ -5517,6 +5521,21 @@ class Controller:
|
||||
# peak guard's projection) are written nowhere else.
|
||||
"events": list(self.events),
|
||||
}
|
||||
# UX-13 P1: the Home screen's structured half. Read-only, and it may
|
||||
# never cost the page its rows: a fault here is logged and the old
|
||||
# fields are served alone (the page then shows the installer details).
|
||||
try:
|
||||
out.update(homeview.build(self, level=level, checks=checks))
|
||||
except Exception: # noqa: BLE001
|
||||
# Once per process at ERROR: the page polls every 3 s.
|
||||
if not getattr(self, "_home_failed", False):
|
||||
self._home_failed = True
|
||||
_LOG.exception("Home view could not be built, so the page "
|
||||
"shows the plain status rows only")
|
||||
else:
|
||||
_LOG.debug("Home view failed again", exc_info=True)
|
||||
out["home_error"] = True
|
||||
return out
|
||||
|
||||
async def handle_action(self, what: str):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use strict";
|
||||
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
|
||||
external files. Every URL is relative, because ingress serves the add-on
|
||||
under a generated path prefix. Every word comes from app/ui_text (fixed
|
||||
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
|
||||
is checked in Python. Data is always set as text, never as HTML. */
|
||||
(function () {
|
||||
const T = JSON.parse(document.getElementById("ui-text").textContent).home;
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
|
||||
function h(tag, attrs, ...kids) {
|
||||
const e = document.createElement(tag);
|
||||
for (const k in attrs || {}) {
|
||||
const v = attrs[k];
|
||||
if (v === false || v == null) continue;
|
||||
if (k === "class") e.className = v;
|
||||
else if (k === "style") e.style.cssText = v;
|
||||
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
||||
else e.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const c of kids.flat()) {
|
||||
if (c == null || c === false) continue;
|
||||
e.append(c.nodeType ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
return e;
|
||||
}
|
||||
const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
|
||||
const eur = (v) => "€" + Number(v).toFixed(2);
|
||||
|
||||
// Fixed labels from ui_text.
|
||||
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
|
||||
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
|
||||
|
||||
let S = null; // the last /status
|
||||
let busy = false;
|
||||
|
||||
/* ---------------- toast and confirm dialog ---------------- */
|
||||
function toast(text) {
|
||||
const e = $("#toast"); e.textContent = text; e.classList.add("show");
|
||||
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
|
||||
}
|
||||
let onYes = null;
|
||||
function ask(key, fn) {
|
||||
$("#dlg-t").textContent = t("dialog." + key + ".name");
|
||||
$("#dlg-b").textContent = t("dialog." + key + ".description");
|
||||
$("#dlg-yes").textContent = t("dialog." + key + ".yes");
|
||||
onYes = fn;
|
||||
const d = $("#dlg");
|
||||
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
|
||||
}
|
||||
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
|
||||
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
|
||||
|
||||
/* ---------------- data ---------------- */
|
||||
async function getStatus() {
|
||||
const r = await fetch("status", { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function act(what) {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what }) });
|
||||
if (!r.ok) throw new Error("action " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function refresh() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { S = await getStatus(); render(); }
|
||||
catch (e) { offline(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- shell ---------------- */
|
||||
function banner(el, level, title, sub, role) {
|
||||
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, sub ? h("small", {}, sub) : null)));
|
||||
}
|
||||
function offline() {
|
||||
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
|
||||
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
|
||||
}
|
||||
function renderPill() {
|
||||
const st = S.state || null;
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
|
||||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderPill();
|
||||
const structured = S.now && S.plan && !S.home_error;
|
||||
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null);
|
||||
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
renderControl(structured);
|
||||
renderDetails(!structured);
|
||||
renderBatteries();
|
||||
renderSettings();
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
function val(id, w, show) {
|
||||
const e = $(id);
|
||||
if (w == null || !show) { e.textContent = "–"; return; }
|
||||
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
|
||||
}
|
||||
function levelBar(pct, label) {
|
||||
const known = pct != null;
|
||||
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
|
||||
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
|
||||
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
|
||||
}
|
||||
function renderNow() {
|
||||
const n = S.now;
|
||||
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
|
||||
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
|
||||
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
|
||||
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
|
||||
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
|
||||
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
|
||||
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
|
||||
}
|
||||
|
||||
/* ---------------- Home: today's plan ---------------- */
|
||||
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
|
||||
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
|
||||
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
|
||||
["peak", "background:var(--bad)"], ["house", null]];
|
||||
function tipText(lines) { return lines.filter(Boolean).join(" "); }
|
||||
function chartItem(tag, cls, style, title, lines) {
|
||||
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
|
||||
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
|
||||
}
|
||||
function roving(group) {
|
||||
const items = [...group.querySelectorAll("[data-tip]")];
|
||||
items.forEach((el, i) => {
|
||||
el.tabIndex = i === 0 ? 0 : -1;
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
|
||||
if (d === undefined) return;
|
||||
e.preventDefault();
|
||||
const j = Math.max(0, Math.min(items.length - 1, i + d));
|
||||
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
function renderPlan() {
|
||||
const p = S.plan;
|
||||
$("#verdict").textContent = p.verdict;
|
||||
$("#plan-head").textContent = p.head;
|
||||
const hours = p.hours || 24;
|
||||
const bars = $("#bars");
|
||||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||||
bars.setAttribute("aria-label", t("plan.name"));
|
||||
bars.replaceChildren(...p.prices.map((b) => {
|
||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||||
const est = b.estimate ? ", " + t("plan.estimate_tip") : "";
|
||||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||
}));
|
||||
roving(bars);
|
||||
$("#no-prices").hidden = known.length > 0;
|
||||
const lanes = $("#lanes");
|
||||
lanes.replaceChildren();
|
||||
S.batteries.forEach((b, i) => {
|
||||
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
|
||||
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
|
||||
for (const r of runs) {
|
||||
if (r.end_h <= r.start_h) continue;
|
||||
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
|
||||
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
|
||||
}
|
||||
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
|
||||
roving(lane);
|
||||
});
|
||||
const nl = $("#nowline");
|
||||
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
|
||||
const used = new Set(p.runs.map((r) => r.kind));
|
||||
known.forEach((b) => used.add(b.kind));
|
||||
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
|
||||
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
|
||||
const list = [];
|
||||
S.batteries.forEach((b, i) => {
|
||||
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
|
||||
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
|
||||
}
|
||||
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
|
||||
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
|
||||
});
|
||||
for (const line of p.lines) list.push(h("li", {}, line));
|
||||
$("#plan-list").replaceChildren(...list);
|
||||
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
|
||||
(function () {
|
||||
const tip = $("#tip"); let hot = null;
|
||||
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
|
||||
function show(el, x, y) {
|
||||
if (hot && hot !== el) hot.classList.remove("hot");
|
||||
hot = el; el.classList.add("hot");
|
||||
const lines = JSON.parse(el.dataset.tip);
|
||||
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
|
||||
tip.classList.add("show");
|
||||
const r = tip.getBoundingClientRect();
|
||||
let L = x + 12, T2 = y - r.height - 12;
|
||||
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
|
||||
if (L < 8) L = 8;
|
||||
if (T2 < 8) T2 = y + 16;
|
||||
tip.style.left = L + "px"; tip.style.top = T2 + "px";
|
||||
}
|
||||
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const el = target(e);
|
||||
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
|
||||
});
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
const el = target(e);
|
||||
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
|
||||
else if (!el) hide();
|
||||
});
|
||||
document.addEventListener("focusin", (e) => {
|
||||
const el = target(e);
|
||||
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
|
||||
});
|
||||
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
||||
addEventListener("scroll", hide, { passive: true });
|
||||
})();
|
||||
|
||||
/* ---------------- Home: peak, attention, control, details ---------------- */
|
||||
function renderPeak() {
|
||||
const m = S.month_peak;
|
||||
$("#peak-month").textContent = m.month;
|
||||
const covered = m.state !== "not_covered";
|
||||
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
|
||||
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
|
||||
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
|
||||
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
|
||||
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
|
||||
const bar = $("#peak-meter i");
|
||||
bar.style.width = pct + "%";
|
||||
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
|
||||
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
|
||||
$("#peak-line").textContent = m.line;
|
||||
}
|
||||
function renderAttention() {
|
||||
const list = S.attention;
|
||||
const icon = { alarm: "×", warning: "!", info: "i" };
|
||||
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
|
||||
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
|
||||
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
|
||||
h("div", { class: "small" }, a.body),
|
||||
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
|
||||
const n = list.filter((a) => a.severity !== "info").length;
|
||||
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
|
||||
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
|
||||
const badge = $("#attn-badge");
|
||||
badge.textContent = n; badge.hidden = !n;
|
||||
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
||||
}
|
||||
function renderControl(structured) {
|
||||
const running = structured ? S.control.running : null;
|
||||
const btn = $("#btn-ctl");
|
||||
btn.textContent = running === false ? t("control.start") : t("control.stop");
|
||||
btn.className = "btn" + (running === false ? " primary" : "");
|
||||
$("#ctl-text").textContent = structured ? S.control.text : "";
|
||||
$("#hint").textContent = S.hint || "";
|
||||
const docs = $("#docs-link");
|
||||
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
}
|
||||
function renderDetails(open) {
|
||||
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
|
||||
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
|
||||
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
|
||||
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
|
||||
return h("li", { class: cls }, mark + " " + c.text);
|
||||
}));
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */
|
||||
function renderBatteries() {
|
||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("dl", { class: "kv" },
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
|
||||
}
|
||||
function renderSettings() {
|
||||
const line = S.plan && S.plan.region_line;
|
||||
$("#set-region").textContent = line || ""; $("#set-region").hidden = !line;
|
||||
}
|
||||
|
||||
/* ---------------- control buttons ---------------- */
|
||||
// The action is a toggle, so the page reads the live state first and sends
|
||||
// nothing when control is already where the person asked it to be.
|
||||
async function setControl(want) {
|
||||
try {
|
||||
const now = await getStatus();
|
||||
if (!now.control || now.control.running !== want) await act("auto_toggle");
|
||||
toast(want ? t("toast.started") : t("toast.stopped"));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
refresh();
|
||||
}
|
||||
$("#btn-ctl").addEventListener("click", () => {
|
||||
const running = S && S.control ? S.control.running : true;
|
||||
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
|
||||
});
|
||||
async function simple(what) {
|
||||
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
|
||||
refresh();
|
||||
}
|
||||
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
|
||||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||||
|
||||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||
const TABS = [...document.querySelectorAll(".tab")];
|
||||
function select(tab, focus) {
|
||||
TABS.forEach((x) => {
|
||||
const on = x === tab;
|
||||
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
|
||||
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
|
||||
});
|
||||
if (focus) tab.focus();
|
||||
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
|
||||
schedule();
|
||||
}
|
||||
TABS.forEach((x, i) => {
|
||||
x.addEventListener("click", () => select(x, false));
|
||||
x.addEventListener("keydown", (e) => {
|
||||
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
|
||||
if (j === undefined) return;
|
||||
e.preventDefault(); select(TABS[j % TABS.length], true);
|
||||
});
|
||||
});
|
||||
function fromHash() {
|
||||
const want = (location.hash || "#home").slice(1);
|
||||
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
|
||||
}
|
||||
addEventListener("hashchange", fromHash);
|
||||
|
||||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-batt").getAttribute("aria-selected") === "true");
|
||||
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
|
||||
if (!live && timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
document.addEventListener("visibilitychange", schedule);
|
||||
fromHash();
|
||||
refresh();
|
||||
})();
|
||||
@@ -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">☀</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">⌂</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">⇅</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">▮</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>
|
||||
@@ -34,5 +34,47 @@
|
||||
"applies": {
|
||||
"restart": {"name": "Applies after a restart"},
|
||||
"now": {"name": "Applies right away"}
|
||||
},
|
||||
"home": {
|
||||
"app": {"name": "Battery add-on"},
|
||||
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
|
||||
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
|
||||
"plan": {
|
||||
"name": "Today's plan",
|
||||
"description": "Point at a bar or a block for details, or tap it on a phone.",
|
||||
"cheap": "Cheap hour",
|
||||
"dear": "Expensive hour",
|
||||
"estimate": "Estimate",
|
||||
"grid": "Charges from the grid",
|
||||
"give": "Gives power",
|
||||
"cycle": "Monthly battery cycle",
|
||||
"peak": "Peak limit",
|
||||
"house": "Follows the house",
|
||||
"now": "now",
|
||||
"time_to": "to",
|
||||
"per_kwh": "per kWh",
|
||||
"cheap_tip": "a cheap hour",
|
||||
"dear_tip": "an expensive hour",
|
||||
"estimate_tip": "estimate from yesterday",
|
||||
"none_tip": "No price known",
|
||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
||||
"list": "The plan in words"
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
|
||||
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"},
|
||||
"start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"},
|
||||
"maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"},
|
||||
"maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}
|
||||
},
|
||||
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
|
||||
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
|
||||
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
|
||||
"settings": {"name": "Settings", "description": "The new settings screens arrive in a later update, so use the settings page for now.", "open": "Open the settings page"},
|
||||
"setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 →</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)
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py`
|
||||
|
||||
1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`,
|
||||
`hint`, `events`) AND carries the structured half the Home screen reads:
|
||||
`state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`.
|
||||
Both are pinned here, by name and shape.
|
||||
2. The plan shows only what the add-on does today. Trading on price is the
|
||||
verdict the control cycle would reach (the same `plan.arbitrage()` call),
|
||||
drawn where the cheapest and dearest prices are, with kWh and € per block.
|
||||
The monthly cycle, the peak limit and "follows the house" are the rest.
|
||||
3. Shadow mode says "would", a stopped site plans nothing, and a red row is an
|
||||
alarm under "Needs attention".
|
||||
4. A fault in the Home view never costs the page its old rows.
|
||||
5. The page: served at `/` from app/static, no external file, relative URLs
|
||||
only, every label resolves in app/ui_text, and the region year reaches
|
||||
`config/model` for the P2 Settings screens.
|
||||
|
||||
The STYLE scan of every Home sentence is test_messages.py part 8.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
from app import homeview, web
|
||||
from app.main import Controller
|
||||
from app.peakguard import PeakDecision
|
||||
from app.price import PricePoint, PriceProduct, PriceRead
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.uimodel import REGION_PRESETS
|
||||
from named_absence import field, nth, present
|
||||
|
||||
FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"]
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures: a real Controller, with its readings set directly.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
|
||||
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
|
||||
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
||||
|
||||
|
||||
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
|
||||
auto=True):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt = soc[i], batt[i]
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = grid
|
||||
return c
|
||||
|
||||
|
||||
def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None):
|
||||
pts = []
|
||||
for d in range(days):
|
||||
for k, p in enumerate(prices):
|
||||
start = day0 + timedelta(days=d, hours=k)
|
||||
pts.append(PricePoint(start, p, provisional_from is not None
|
||||
and start >= provisional_from))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", NOW), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def quiet_guard(projected_w=1240.0):
|
||||
return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=projected_w)
|
||||
|
||||
|
||||
def status(c, now=NOW):
|
||||
s = c.status_payload()
|
||||
# The status route builds at wall-clock time; rebuild the structured half
|
||||
# at the fixture's instant so the day and the hours are the mockup's.
|
||||
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now))
|
||||
return s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. /status: the old fields stay, the structured half is added")
|
||||
# --------------------------------------------------------------------------- #
|
||||
c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0)
|
||||
s1 = c1.status_payload()
|
||||
OLD = {"banner", "level", "rows", "checks", "hint", "events"}
|
||||
NEW = {"state", "now", "batteries", "plan", "attention", "month_peak",
|
||||
"control"}
|
||||
check(f"every old field is still there, with its old type ({sorted(OLD)})",
|
||||
OLD <= set(s1) and isinstance(s1["rows"], list)
|
||||
and all(len(r) == 2 for r in s1["rows"])
|
||||
and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"])
|
||||
and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad")
|
||||
and isinstance(s1["events"], list))
|
||||
check(f"...and the structured half is added beside them ({sorted(NEW)})",
|
||||
NEW <= set(s1) and "home_error" not in s1)
|
||||
check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1))
|
||||
== json.loads(json.dumps(s1)))
|
||||
check("`state` names the kind, the pill word, the control row and a sub-line",
|
||||
set(s1["state"]) == {"kind", "pill", "ran", "sub"}
|
||||
and s1["state"]["kind"] == "running")
|
||||
check("`now` carries the four tiles in W and a plain line for each",
|
||||
{"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"}
|
||||
<= set(s1["now"])
|
||||
and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"}
|
||||
and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0)
|
||||
check("...and with no solar reading the house figure is NOT invented: it is "
|
||||
"None and the tile says why",
|
||||
s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False
|
||||
and s1["now"]["lines"]["house"] == homeview.tile_house(None, False))
|
||||
_b = s1["batteries"][0]
|
||||
check("`batteries[]` has level, power, state, mode, the now line and why",
|
||||
{"name", "level_pct", "power_w", "state", "mode", "now", "why",
|
||||
"capacity_kwh", "stored_kwh", "maintenance"} <= set(_b)
|
||||
and _b["level_pct"] == 62.0 and _b["state"] == "giving"
|
||||
and _b["now"] == "Giving 1.0 kW now.")
|
||||
_p = s1["plan"]
|
||||
check("`plan` carries the verdict, the prices, the runs, the lines and "
|
||||
"the region",
|
||||
{"verdict", "head", "prices", "runs", "lines", "now_h", "hours",
|
||||
"price_line", "tomorrow_line", "region", "region_line"} <= set(_p)
|
||||
and len(_p["prices"]) in (23, 24, 25))
|
||||
check("`attention[]` has severity, title, body and docs; `month_peak` and "
|
||||
"`control` are there",
|
||||
all({"severity", "title", "body", "docs"} <= set(a)
|
||||
for a in s1["attention"])
|
||||
and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"}
|
||||
<= set(s1["month_peak"])
|
||||
and {"running", "shadow", "text", "maintenance", "docs_url"}
|
||||
<= set(s1["control"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the plan is what the add-on actually does")
|
||||
# --------------------------------------------------------------------------- #
|
||||
s_house = status(make(TWO))
|
||||
check("with trading off and no cycle due, each battery follows the house "
|
||||
"from now to the end of the day, and nothing else is drawn",
|
||||
[r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"]
|
||||
and s_house["plan"]["verdict_key"] == "house"
|
||||
and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01
|
||||
for r in s_house["plan"]["runs"])
|
||||
and s_house["plan"]["runs"][0]["to"] == "24:00")
|
||||
|
||||
ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05)
|
||||
c_arb = make(ARB)
|
||||
c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW
|
||||
c_arb.peak_guard = quiet_guard()
|
||||
s_arb = status(c_arb)
|
||||
_runs = s_arb["plan"]["runs"]
|
||||
_grid = [r for r in _runs if r["kind"] == "grid"]
|
||||
_give = [r for r in _runs if r["kind"] == "give"]
|
||||
check("trading on price: a charge block at the cheapest hour (13:00) and a "
|
||||
"give block at the dearest (19:00), on each battery",
|
||||
[(r["battery"], r["from"], r["to"]) for r in _grid]
|
||||
== [(0, "13:00", "14:00"), (1, "13:00", "14:00")]
|
||||
and [(r["battery"], r["from"], r["to"]) for r in _give]
|
||||
== [(0, "19:00", "20:00"), (1, "19:00", "20:00")]
|
||||
and s_arb["plan"]["verdict_key"] == "trade")
|
||||
check("...placed where the control cycle's own verdict puts them: the same "
|
||||
"plan.arbitrage() call on the same plan",
|
||||
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
|
||||
.Uncertainty.NO_WEATHER}))
|
||||
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
||||
refused=lambda v: None)
|
||||
== (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19)))
|
||||
# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of
|
||||
# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5.
|
||||
check("each block carries kWh and €: charging is capped by the battery's own "
|
||||
"rating and the room its level leaves, € = kWh x that hour's price",
|
||||
[r["kwh"] for r in _grid] == [2.5, 2.5]
|
||||
and [r["eur"] for r in _grid] == [0.35, 0.35]
|
||||
and [r["eur_kwh"] for r in _grid] == [0.14, 0.14]
|
||||
and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"]
|
||||
and "€0.35" in _grid[0]["amount"])
|
||||
# Giving: A holds 62 - 20 = 42 % of 9.6 = 4.03 -> 2.5. B holds 35 - 20 = 15 %
|
||||
# of 5.4 = 0.81 kWh, which is what it can give above arbitrage's floor.
|
||||
check("...and giving is capped by what each battery holds above arbitrage's "
|
||||
"floor (Battery B: 15 % of 5.4 kWh)",
|
||||
[r["kwh"] for r in _give] == [2.5, 0.81]
|
||||
and [r["eur"] for r in _give] == [0.9, 0.29])
|
||||
check("the price bars are today's 24 hours, from the real series, with the "
|
||||
"cheap and expensive thirds marked",
|
||||
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
||||
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
||||
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
||||
and s_arb["plan"]["prices"][0]["kind"] == "mid"
|
||||
and s_arb["plan"]["prices"][13]["label"] == "13:00")
|
||||
check("the price line names the price now, today's cheapest and dearest, and "
|
||||
"that tomorrow's prices are in",
|
||||
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
|
||||
"Today: cheapest €0.14 at 13:00, "
|
||||
"dearest €0.36 at 19:00.")
|
||||
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
|
||||
|
||||
c_late = make(ARB)
|
||||
c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW
|
||||
c_late.peak_guard = quiet_guard()
|
||||
s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local
|
||||
check("a block whose hour is over is not drawn: at 20:40 no trade is left "
|
||||
"today",
|
||||
not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
||||
|
||||
c_peak = make(ARB)
|
||||
c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW
|
||||
c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0,
|
||||
ceiling_w=3500.0, ceiling_from="cap",
|
||||
projected_w=3900.0)
|
||||
s_peak = status(c_peak)
|
||||
check("while the peak limit holds, the verdict refuses as the cycle does: no "
|
||||
"trade block, a plain line, and a peak block for this 15-minute period",
|
||||
not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
||||
and homeview.line_no_trade("peak") in s_peak["plan"]["lines"]
|
||||
and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"]
|
||||
if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")])
|
||||
|
||||
c_blind = make(ARB)
|
||||
c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW
|
||||
s_blind = status(c_blind) # meter_source off: the guard never runs
|
||||
check("with nothing measuring the 15-minute peak, trading is NOT drawn, as "
|
||||
"the cycle refuses it (guard state None)",
|
||||
not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
||||
and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"])
|
||||
|
||||
c_flat = make(ARB)
|
||||
c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW
|
||||
c_flat.peak_guard = quiet_guard()
|
||||
s_flat = status(c_flat)
|
||||
check("a flat day: no trade, no cheap or expensive hour marked",
|
||||
not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
||||
and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"}
|
||||
and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"])
|
||||
|
||||
c_old = make(ARB)
|
||||
c_old.tariff_plan = provider_plan()
|
||||
c_old.tariff_at = NOW - timedelta(hours=3) # past its max age
|
||||
c_old.peak_guard = quiet_guard()
|
||||
s_old = status(c_old)
|
||||
check("a stale plan is not traded on by the cycle, so it is not drawn",
|
||||
not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
||||
|
||||
MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12)
|
||||
c_m = make(MAINT)
|
||||
s_m = status(c_m)
|
||||
_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"]
|
||||
check("a monthly cycle due today is drawn on ONE battery, at the start hour "
|
||||
"the cycle uses (12 UTC is 14:00 here), and the other one waits",
|
||||
len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00"
|
||||
and s_m["plan"]["verdict_key"] == "cycle"
|
||||
and any("waits its turn" in x for x in s_m["plan"]["lines"]))
|
||||
check("...with its duration from the battery's capacity and the cycle's rates "
|
||||
"and hold time",
|
||||
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
|
||||
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
|
||||
c_m.maints[0].store.set("phase", "drain")
|
||||
s_md = status(c_m)
|
||||
check("a cycle under way is drawn from now, and the battery's now line says "
|
||||
"so",
|
||||
[r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"]
|
||||
== ["11:40"]
|
||||
and s_md["batteries"][0]["now"] == "Monthly cycle: emptying."
|
||||
and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. shadow mode, a stopped site, an alarm")
|
||||
# --------------------------------------------------------------------------- #
|
||||
c_sh = make(dict(ARB, shadow_mode="on"))
|
||||
c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW
|
||||
c_sh.peak_guard = quiet_guard()
|
||||
s_sh = status(c_sh)
|
||||
_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]]
|
||||
check("shadow: the state is shadow, the battery tile is held at 0 W, and "
|
||||
"every block and the head say 'would'",
|
||||
s_sh["state"]["kind"] == "shadow"
|
||||
and s_sh["now"]["lines"]["batt"] == "Held at 0 W"
|
||||
and all("ould" in x for x in _texts)
|
||||
and s_sh["plan"]["head"].startswith("Shadow mode:"))
|
||||
check("...and 'Shadow mode is on' is the first item under Needs attention",
|
||||
s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW
|
||||
and s_sh["attention"][0]["severity"] == "info")
|
||||
check("...and each battery's now line says it is held at 0 W",
|
||||
all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"]))
|
||||
|
||||
s_stop = status(make(TWO, auto=False))
|
||||
check("stopped: nothing is planned and the head says so",
|
||||
s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped"
|
||||
and s_stop["plan"]["verdict_key"] == "stopped"
|
||||
and s_stop["control"]["running"] is False
|
||||
and all(b["now"] == "Held at 0 W while control is stopped."
|
||||
for b in s_stop["batteries"]))
|
||||
|
||||
c_al = make(ONE, soc=(62.0,), batt=(0.0,))
|
||||
c_al.inverters[0].sign_wd.tripped = True
|
||||
s_al = c_al.status_payload()
|
||||
_red = [a for a in s_al["attention"] if a["severity"] == "alarm"]
|
||||
check("an alarm: level bad, state alarm, and the red row is the first item "
|
||||
"under Needs attention",
|
||||
s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm"
|
||||
and _red and s_al["attention"][0] is _red[0]
|
||||
and _red[0]["title"] == homeview.ATTN_TITLES["direction"])
|
||||
check("...and nothing is planned while every inverter is held at 0 W",
|
||||
s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held")
|
||||
_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
||||
check("...and every red, amber and settling row reaches Needs attention with "
|
||||
"its own sentence as the body, and no green row does",
|
||||
[a["body"] for a in homeview.attention(s_al["checks"])]
|
||||
== [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
||||
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
|
||||
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a fault in the Home view never costs the page its rows")
|
||||
# --------------------------------------------------------------------------- #
|
||||
_real = homeview.build
|
||||
try:
|
||||
homeview.build = lambda *a, **k: 1 / 0
|
||||
s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload()
|
||||
finally:
|
||||
homeview.build = _real
|
||||
check("the old fields are served and `home_error` says the rest is missing",
|
||||
OLD <= set(s_err) and s_err.get("home_error") is True
|
||||
and not (NEW & set(s_err)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the page")
|
||||
# --------------------------------------------------------------------------- #
|
||||
PAGE = web.page()
|
||||
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))["home"]
|
||||
|
||||
|
||||
def resolve(path):
|
||||
node = UI
|
||||
for k in path.split("."):
|
||||
if not isinstance(node, dict) or k not in node:
|
||||
return None
|
||||
node = node[k]
|
||||
return node
|
||||
|
||||
|
||||
check("the page is assembled: no placeholder is left and the words parse",
|
||||
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
|
||||
and "/*UI_TEXT*/" not in PAGE
|
||||
and json.loads(PAGE.split('id="ui-text">', 1)[1]
|
||||
.split("</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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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])))
|
||||
Reference in New Issue
Block a user