- 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
296 lines
14 KiB
Python
296 lines
14 KiB
Python
"""The ingress UI: status, commissioning checks and the two buttons that matter.
|
|
|
|
Served inside Home Assistant, so no authentication of our own and no external
|
|
port. All URLs here are RELATIVE - ingress serves the add-on under a generated
|
|
path prefix, and an absolute "/status" would 404 in the field while working
|
|
perfectly on a developer's laptop.
|
|
|
|
Design bias: this page must be readable at the end of a long day, on a phone, in
|
|
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; 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
|
|
anything the panel exposes reach the inverter? - is answered by reading one
|
|
short file.
|
|
|
|
⚠️ THE ANSWER TO THAT QUESTION, STATED SO IT CAN BE CHECKED: the configuration
|
|
routes below read `GET /addons/self/info`, run the SHIPPED validator, write
|
|
`POST /addons/self/options` and, on an explicitly confirmed request, `POST
|
|
/addons/self/restart`. NONE of them writes a setpoint, clears a latch, changes a
|
|
mode or reaches `controller.handle_action()`. The set of things an operator can
|
|
do to the inverter through this panel is exactly what `/action` offered before
|
|
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
|
|
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")
|
|
|
|
# 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")
|
|
|
|
|
|
def _read(path):
|
|
with io.open(path, encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
|
|
def page() -> str:
|
|
"""The assembled index page. `</` is escaped inside the two inline
|
|
scripts, so no text in them can close the tag early."""
|
|
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
|
|
script = _read(os.path.join(STATIC, "app.js"))
|
|
if "</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
|
|
# validate.py's rules compare a configured ceiling against the setpoint
|
|
# device's OWN reported range, and that range is only knowable by asking Home
|
|
# Assistant. When ESPHome is asleep or the entity does not exist yet the
|
|
# startup gate SKIPS those two rules rather than guessing - so the panel skips
|
|
# them too, and says so, because a green page that is green for a reason the
|
|
# operator cannot see is the exact failure this add-on keeps refusing to ship.
|
|
DEVICE_NOTE = (
|
|
"%s did not report its power range, so two checks were skipped, here and "
|
|
"at start. If they fail later, the add-on will not start then.")
|
|
|
|
|
|
def build_app(controller, selfcfg=None) -> web.Application:
|
|
"""The ingress app. `selfcfg` is injectable so a check can drive it.
|
|
|
|
⚠️ IT IS BUILT FROM THE CONTROLLER'S OWN SESSION AND TOKEN, not from a
|
|
second `ClientSession`. `amain()` owns one session for the process and every
|
|
Home Assistant call already goes through it; a second one here would be a
|
|
second connection pool whose failures nothing reports.
|
|
"""
|
|
if selfcfg is None:
|
|
selfcfg = selfconfig.SelfConfig(controller.hass.session,
|
|
controller.hass.token)
|
|
entities = EntityCache(controller.hass)
|
|
index_page = page()
|
|
|
|
async def index(_request):
|
|
return web.Response(text=index_page, content_type="text/html")
|
|
|
|
async def status(_request):
|
|
return web.json_response(controller.status_payload())
|
|
|
|
async def action(request):
|
|
try:
|
|
body = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
what = body.get("action")
|
|
result = await controller.handle_action(what)
|
|
if result is None:
|
|
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
|
return web.json_response({"ok": True, "result": result})
|
|
|
|
# --- UX-02: the configuration surface ---------------------------------
|
|
async def config_index(_request):
|
|
return web.Response(text=CONFIG_PAGE, content_type="text/html")
|
|
|
|
async def _proposed(request):
|
|
try:
|
|
body = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
options = body.get("options")
|
|
if not isinstance(options, dict):
|
|
raise web.HTTPBadRequest(text="expected an options object")
|
|
return body, options
|
|
|
|
async def config_model(request):
|
|
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
|
|
|
⚠️ `running_differs` IS NOT COSMETIC. `controller.o` is the blob this
|
|
PROCESS started with (`/data/options.json`); `info["options"]` is what
|
|
the Supervisor has STORED and would apply at the next start. They differ
|
|
exactly when somebody has saved and not restarted - here, or in the
|
|
Configuration tab. Printing the difference is the only way an operator
|
|
finds out that the page they are reading is not describing the running
|
|
system, and it is also how an edit made on the other surface becomes
|
|
visible on this one.
|
|
"""
|
|
try:
|
|
info = await selfcfg.info()
|
|
except selfconfig.SupervisorUnavailable as err:
|
|
_LOG.error("cannot read the add-on's own options: %s", err)
|
|
return web.json_response({"error": str(err)}, status=502)
|
|
options = info.get("options") or {}
|
|
lang = str(request.query.get("lang", "en") or "en")[:8]
|
|
model = field_model(info.get("schema") or [],
|
|
info.get("translations") or {}, lang)
|
|
model.update({
|
|
"options": options,
|
|
"fingerprint": selfconfig.fingerprint(options),
|
|
"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)
|
|
|
|
async def config_validate(request):
|
|
"""The SHIPPED validator, run against a proposed blob. Writes nothing.
|
|
|
|
⚠️ IT READS HOME ASSISTANT AND WRITES NOTHING ANYWHERE. The two
|
|
device-range rules need `hass.limits()`, which is a GET of an entity's
|
|
state. The page debounces, so a burst of typing is one call after the
|
|
typing stops rather than one per keystroke.
|
|
|
|
⚠️ THE STRINGS ARE PASSED THROUGH UNTOUCHED. Not summarised, not
|
|
renumbered, not mapped to a severity. validate.py's sentences are the
|
|
product this card is delivering; anything this layer did to them would
|
|
be a second opinion about a rule it does not own.
|
|
"""
|
|
_body, options = await _proposed(request)
|
|
notes = []
|
|
problems, unread = await selfconfig.problems_and_ranges(
|
|
options, controller.hass, notes=notes)
|
|
by_key, unanchored = problems_by_field(problems, _keys_of(options))
|
|
# UX-13: warnings and info beside the refusals. Never block a save.
|
|
# The entity checks use the picker's cached list and never wait on HA.
|
|
notes += config_notes(options, entities=entities.peek())
|
|
payload = {"problems": problems, "by_key": by_key,
|
|
"unanchored": unanchored,
|
|
"notes": [n._asdict() for n in notes]}
|
|
if unread:
|
|
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
|
return web.json_response(payload)
|
|
|
|
def _keys_of(options):
|
|
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
|
|
# sound only because the page posts the WHOLE blob it read - which is
|
|
# `App.options`, the merge of config.yaml's defaults with the stored
|
|
# ones, so every option key is in it. Nothing here types a key name;
|
|
# see configui.problems_by_field() for what is done with them.
|
|
return sorted(options)
|
|
|
|
async def config_save(request):
|
|
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
|
|
body, options = await _proposed(request)
|
|
expect = str(body.get("fingerprint") or "")
|
|
try:
|
|
result = await selfconfig.save(selfcfg, options, expect,
|
|
hass=controller.hass)
|
|
except selfconfig.SaveRefused as refused:
|
|
if refused.kind == "stale":
|
|
stored = refused.stored or {}
|
|
return web.json_response(
|
|
{"kind": "stale", "changed": refused.changed,
|
|
"stored": stored,
|
|
# The page needs the CURRENT token to be able to save
|
|
# again after the operator has read the difference. That
|
|
# is not a force door: it takes a second, deliberate press
|
|
# made in front of what changed.
|
|
"fingerprint": selfconfig.fingerprint(stored)},
|
|
status=409)
|
|
by_key, unanchored = problems_by_field(refused.problems,
|
|
_keys_of(options))
|
|
return web.json_response(
|
|
{"kind": "invalid", "problems": refused.problems,
|
|
"by_key": by_key, "unanchored": unanchored}, status=422)
|
|
except selfconfig.SupervisorUnavailable as err:
|
|
_LOG.error("saving options failed: %s", err)
|
|
return web.json_response({"error": str(err)}, status=502)
|
|
controller.log_event("configuration saved from the panel - "
|
|
"not applied until the add-on restarts",
|
|
hint=messages.hint("settings_saved"))
|
|
return web.json_response({"ok": True, **result})
|
|
|
|
async def config_restart(request):
|
|
"""Restart the add-on, at the operator's explicit request.
|
|
|
|
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
|
|
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
|
|
the request must carry the intent token; a reload, a retry or a stray
|
|
POST cannot spend a control interruption. It restarts THIS ADD-ON and
|
|
nothing else: no setpoint is written from here, and a latched failsafe
|
|
is restored from the Store on the way back up rather than cleared.
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
try:
|
|
await selfcfg.restart(str(body.get("confirm") or ""))
|
|
except selfconfig.NotConfirmed as err:
|
|
return web.json_response({"error": str(err)}, status=400)
|
|
except selfconfig.SupervisorUnavailable as err:
|
|
_LOG.error("restart request failed: %s", err)
|
|
return web.json_response({"error": str(err)}, status=502)
|
|
controller.log_event("restart requested from the configuration panel",
|
|
hint=messages.hint("restart_requested"))
|
|
return web.json_response({"ok": True})
|
|
|
|
async def ha_entities(request):
|
|
"""UX-13: Home Assistant's entities for the picker. A read only, and
|
|
never an error: when Home Assistant is down the list says so."""
|
|
kind = str(request.query.get("kind", "") or "")[:16]
|
|
return web.json_response(await entities.payload(kind))
|
|
|
|
app = web.Application()
|
|
app.add_routes([
|
|
web.get("/", index),
|
|
web.get("/status", status),
|
|
web.post("/action", action),
|
|
web.get("/config", config_index),
|
|
web.get("/config/model", config_model),
|
|
web.post("/config/validate", config_validate),
|
|
web.post("/config/save", config_save),
|
|
web.post("/config/restart", config_restart),
|
|
web.get("/ha/entities", ha_entities),
|
|
])
|
|
return app
|
|
|
|
|
|
def access_log():
|
|
"""UX-10: the page polls /status every few seconds, and aiohttp logs each
|
|
request at INFO. That access log is kept for a DEBUG log level only."""
|
|
return (logging.getLogger("aiohttp.access")
|
|
if _LOG.isEnabledFor(logging.DEBUG) else None)
|
|
|
|
|
|
async def start(controller, port: int = 8099, selfcfg=None):
|
|
runner = web.AppRunner(build_app(controller, selfcfg),
|
|
access_log=access_log())
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, "0.0.0.0", port)
|
|
await site.start()
|
|
_LOG.info("ingress UI listening on :%s", port)
|
|
return runner
|