Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
314 lines
14 KiB
Python
314 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 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.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from aiohttp import web
|
|
|
|
from . import messages, selfconfig
|
|
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)
|
|
|
|
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)
|
|
problems, unread = await selfconfig.problems_and_ranges(
|
|
options, controller.hass)
|
|
by_key, unanchored = problems_by_field(problems, _keys_of(options))
|
|
payload = {"problems": problems, "by_key": by_key,
|
|
"unanchored": unanchored}
|
|
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})
|
|
|
|
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),
|
|
])
|
|
return app
|
|
|
|
|
|
async def start(controller, port: int = 8099, selfcfg=None):
|
|
runner = web.AppRunner(build_app(controller, selfcfg))
|
|
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
|