The foundation for the new settings page. No visible UI change. - app/uilayout.py: screen -> group -> part -> field for every option in config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display scale, step, entity kind, show conditions, Simple/wizard placement and "applies after a restart". Ranges stay in the schema, words in translations and the new app/ui_text/en.json. - app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one inverter vs the list, solar rows, static_profile from simple prices, a year-stamped region preset for the levies figure, and an OPT-11 hook for arbitrage_enabled that changes nothing yet). Unchanged derivations write the stored values back, so the round trip is exact. - validate.py: validate.Note, a warn/info tier. Each logged warning is also a note when a list is passed; config_notes() adds checks that need live data (missing entity, a kW sensor read as W, a limit below this month's peak). /config/validate returns them as "notes". No refusal changed: 261 recorded validator calls, 0 differ (tools/ux13_validator_calls.py). - GET ha/entities: Home Assistant's entity list for the picker, read through hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200 with the old list marked old, or an empty list that says why. - DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI code": keys may appear in uilayout.py and uimodel.py only, both tested. - Tests: test_uilayout, test_uimodel (round trip over the recorded calls, the TEST-23 option files and config.yaml defaults), test_haentities, a warning-tier part in test_validate; test_messages scans notes and ui_text; test_pv and test_selfconfig part 7 widened on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
338 lines
16 KiB
Python
338 lines
16 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 and is unchanged. `/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 logging
|
|
|
|
from aiohttp import web
|
|
|
|
from . import messages, selfconfig
|
|
from .haentities import EntityCache
|
|
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>
|
|
|
|
<div class="card">
|
|
<table id="live"></table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="k">Commissioning</div>
|
|
<ul id="checks"></ul>
|
|
</div>
|
|
|
|
<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>
|
|
"""
|
|
|
|
|
|
# ⚠️ 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)
|
|
|
|
async def index(_request):
|
|
return web.Response(text=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,
|
|
})
|
|
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
|