Files
adminandClaude Opus 5 0a1e61dbc9 Packaged as a Home Assistant add-on, with a field guide
Turns the reference RS485 controller into something a technician can install
at a client site: a typed config form instead of YAML, an ingress UI that
names misconfiguration in words, and persistent state that cannot be broken by
a timezone.

Why an add-on rather than YAML packages or blueprints:

- Blueprints cannot create helpers, and the maintenance cycle is a state
  machine whose phase and completion date must survive restarts.
- YAML packages need filesystem access, a configuration.yaml edit and a
  restart - none of which belong in a client install.
- Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token
  to generate, store or leak on someone else's machine.
- Requires HA OS/Supervised. Container and Core installs cannot run add-ons at
  all, which is a market decision, not an oversight.

The control law and the maintenance machine are pure functions with no Home
Assistant imports, and both ship with runnable checks (22 and 22 assertions).
Every assertion corresponds to a rule whose absence caused an observed failure
on hardware - the saturation duration term, the clamp-before-slew ordering, the
deadband, the sign convention.

One behaviour deliberately differs from the implementation it replaces: when
its inputs go missing this commands 0 W rather than replaying the last
setpoint. The reference version kept replaying, which the hardware watchdog
cannot catch - from the ESP32's side, Home Assistant is still talking to it.

Includes the ESPHome firmware (now parameterised: node name, inverter rating,
watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the
commissioning gates, all judged on the wire rather than on how Home Assistant
looks, plus the written statement a site without an e-stop needs signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 01:15:25 +02:00

132 lines
5.0 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.
"""
import logging
from aiohttp import web
_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>
<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>
"""
def build_app(controller) -> web.Application:
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})
app = web.Application()
app.add_routes([
web.get("/", index),
web.get("/status", status),
web.post("/action", action),
])
return app
async def start(controller, port: int = 8099):
runner = web.AppRunner(build_app(controller))
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