- 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
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""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])))
|