A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
462 lines
23 KiB
Python
462 lines
23 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.
|
|
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
|
|
not options is a Store key, `setup_done`, written when the wizard's save
|
|
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
|
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
|
|
commands nothing; there `/action` answers 409.
|
|
OPT-11 P1 adds two `/action` values, `pack_use` and `pack_mode`, which set one
|
|
battery's on/off switch or mode in the store (Controller._pack_action). They
|
|
are the only actions that name a battery; the configuration routes still reach
|
|
none of them.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
from aiohttp import web
|
|
|
|
from . import messages, selfconfig, uipage, uitext
|
|
from .haentities import EntityCache
|
|
from .homeview import region_of
|
|
from .validate import config_notes, setup_checks
|
|
from .config_page import CONFIG_PAGE
|
|
from .configui import changed_keys, field_model, problems_by_field
|
|
from .uimodel import DeriveError, options_to_ui
|
|
|
|
_LOG = logging.getLogger("goodwe.web")
|
|
|
|
# OPT-11: the per-battery actions, which name a battery and a value.
|
|
PACK_ACTIONS = ("pack_use", "pack_mode")
|
|
|
|
# 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/<lang>.json (English
|
|
# and, since P5, Dutch; the page picks by the browser's language). 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")
|
|
|
|
|
|
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({lang: uitext.raw(lang) for lang in uitext.LANGS},
|
|
ensure_ascii=False)
|
|
scripts = {}
|
|
for name in ("app.js", "settings.js"):
|
|
scripts[name] = _read(os.path.join(STATIC, name))
|
|
if "</script" in scripts[name].lower():
|
|
raise ValueError(f"{name} 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*/", scripts["app.js"])
|
|
.replace("/*SETTINGS_JS*/", scripts["settings.js"]))
|
|
|
|
|
|
# ⚠️ 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()
|
|
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
|
|
# save can name only the keys that moved underneath the page (finding a).
|
|
handed: dict = {}
|
|
|
|
def hand_out(options):
|
|
fp = selfconfig.fingerprint(options)
|
|
handed.pop(fp, None)
|
|
handed[fp] = options
|
|
while len(handed) > 16:
|
|
handed.pop(next(iter(handed)))
|
|
return fp
|
|
|
|
def store_data():
|
|
store = getattr(controller, "store", None)
|
|
data = getattr(store, "data", None)
|
|
return data if isinstance(data, dict) else None
|
|
|
|
async def index(_request):
|
|
return web.Response(text=index_page, content_type="text/html")
|
|
|
|
async def status(request):
|
|
# UX-13 P5: Home's sentences in the page's language, English or Dutch.
|
|
lang = uitext.lang_of(request.query.get("lang"))
|
|
if lang == "en":
|
|
return web.json_response(controller.status_payload())
|
|
return web.json_response(controller.status_payload(lang=lang))
|
|
|
|
async def action(request):
|
|
try:
|
|
body = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
what = body.get("action")
|
|
if getattr(controller, "setup_only", False):
|
|
# UX-15: nothing to start or stop until setup is saved.
|
|
return web.json_response(
|
|
{"ok": False, "error": messages.row_setup_only()}, status=409)
|
|
if what in PACK_ACTIONS:
|
|
# OPT-11: one battery's switch or mode. Store state for the named
|
|
# battery, applied on the next cycle; nothing is written here.
|
|
result = await controller.handle_action(
|
|
what, unit=body.get("unit"), value=body.get("value"))
|
|
if isinstance(result, dict) and "error" in result:
|
|
return web.json_response({"ok": False,
|
|
"error": result["error"]},
|
|
status=400)
|
|
else:
|
|
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, stored=None):
|
|
"""(body, options, model). UX-13's settings view posts its page
|
|
`model`, which app/uimodel.py turns into the options blob here, so the
|
|
validator and the Supervisor only ever see a real blob. The classic
|
|
page posts `options`. Raises DeriveError for a model no blob can say.
|
|
|
|
With `stored` (a save) and the `base` model the page opened, only the
|
|
keys this page changed are written onto what is stored now, so a save
|
|
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
|
|
try:
|
|
body = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
model = body.get("model")
|
|
if isinstance(model, dict):
|
|
mine = uipage.to_options(model, stored)
|
|
base = body.get("base")
|
|
if stored is not None and isinstance(base, dict):
|
|
was, _errors = uipage.to_options_partial(base, stored)
|
|
mine = uipage.only_edits(mine, was, stored)
|
|
return body, mine, model
|
|
options = body.get("options")
|
|
if not isinstance(options, dict):
|
|
raise web.HTTPBadRequest(text="expected an options object")
|
|
return body, options, None
|
|
|
|
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": hand_out(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),
|
|
# UX-13 P2: the Installer view draws from these two.
|
|
"layout": uipage.layout(info.get("schema") or [],
|
|
info.get("translations") or {}, lang),
|
|
"ui": options_to_ui(options),
|
|
# UX-13 P4: the setup wizard, and why it opens by itself.
|
|
"setup": uipage.setup_spec(),
|
|
"setup_needed": uipage.setup_needed(
|
|
options, store_data(), bool(getattr(controller, "auto", False)),
|
|
fresh=getattr(getattr(controller, "store", None), "fresh",
|
|
False) is True),
|
|
# UX-15: this process serves setup only and commands nothing.
|
|
"setup_only": getattr(controller, "setup_only", False) is True,
|
|
})
|
|
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.
|
|
"""
|
|
try:
|
|
_body = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
model = _body.get("model")
|
|
errors = []
|
|
if isinstance(model, dict):
|
|
# UX-15 F1: a part the page cannot turn into options blocks its
|
|
# own step, and everything else is still checked.
|
|
options, errors = uipage.to_options_partial(model)
|
|
else:
|
|
model, options = None, _body.get("options")
|
|
if not isinstance(options, dict):
|
|
raise web.HTTPBadRequest(text="expected an options object")
|
|
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)))
|
|
if model is not None:
|
|
payload["messages"] = (
|
|
uipage.derive_messages(errors, problems, notes, model,
|
|
_keys_of(options),
|
|
uitext.lang_of(_body.get("lang")))
|
|
if errors
|
|
else uipage.anchors(problems, notes, model, _keys_of(options)))
|
|
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
|
if _body.get("setup"):
|
|
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
|
return web.json_response(payload)
|
|
|
|
async def _setup_checks(options):
|
|
"""The wizard's live checks: a list at most a few seconds old, and
|
|
what the running add-on already knows (its meter, today's prices)."""
|
|
rows, age = await entities.get(max_age=5.0)
|
|
if age is not None and age >= 30.0:
|
|
rows = None # Home Assistant is not answering
|
|
try:
|
|
prices = (controller.status_payload().get("plan") or {}).get(
|
|
"prices") or []
|
|
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
|
except Exception: # noqa: BLE001 - a display figure, never a failure
|
|
known = None
|
|
return setup_checks(options, entities=rows, running=controller.o,
|
|
meter_now_w=getattr(controller, "grid", None),
|
|
prices_known=known)
|
|
|
|
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."""
|
|
# The page model carries no stored values of the keys it shows, so the
|
|
# blob is settled against what is stored: an unchanged value is written
|
|
# exactly as stored (uipage.to_options).
|
|
try:
|
|
posted = await request.json()
|
|
except ValueError:
|
|
raise web.HTTPBadRequest(text="expected JSON")
|
|
stored_now = None
|
|
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
|
|
try:
|
|
stored_now = (await selfcfg.info()).get("options") or {}
|
|
except selfconfig.SupervisorUnavailable as err:
|
|
_LOG.error("saving options failed: %s", err)
|
|
return web.json_response({"error": str(err)}, status=502)
|
|
try:
|
|
body, options, model = await _proposed(request, stored_now)
|
|
except DeriveError:
|
|
model = posted["model"]
|
|
_opts, errors = uipage.to_options_partial(model, stored_now)
|
|
lang = uitext.lang_of(posted.get("lang"))
|
|
messages_ = [uipage.derive_message(e, lang) for e in errors]
|
|
return web.json_response(
|
|
{"kind": "invalid", "problems": [], "by_key": {},
|
|
"unanchored": [], "messages": messages_,
|
|
"steps": uipage.step_problems(messages_, model)},
|
|
status=422)
|
|
expect = str(body.get("fingerprint") or "")
|
|
try:
|
|
result = await selfconfig.save(selfcfg, options, expect,
|
|
hass=controller.hass,
|
|
base=handed.get(expect))
|
|
except selfconfig.SaveRefused as refused:
|
|
if refused.kind == "stale":
|
|
stored = refused.stored or {}
|
|
# The fields to look at are the ones the OTHER surface moved,
|
|
# not this page's own edits (UX-13 P2 test, finding a).
|
|
moved = (refused.changed if refused.moved is None
|
|
else refused.moved)
|
|
return web.json_response(
|
|
{"kind": "stale", "changed": refused.changed,
|
|
"moved": refused.moved,
|
|
"changed_fields": (uipage.fields_of(moved, model)
|
|
if model is not None else []),
|
|
"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": hand_out(stored)},
|
|
status=409)
|
|
by_key, unanchored = problems_by_field(refused.problems,
|
|
_keys_of(options))
|
|
payload = {"kind": "invalid", "problems": refused.problems,
|
|
"by_key": by_key, "unanchored": unanchored}
|
|
if model is not None:
|
|
payload["messages"] = uipage.anchors(refused.problems, [],
|
|
model, _keys_of(options))
|
|
payload["steps"] = uipage.step_problems(payload["messages"],
|
|
model)
|
|
return web.json_response(payload, 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"))
|
|
# `handed` keeps `expect`: another page may have opened the same blob.
|
|
store = getattr(controller, "store", None)
|
|
if (model is not None and body.get("setup") == "done"
|
|
and store is not None):
|
|
store.set(uipage.SETUP_DONE,
|
|
datetime.now(timezone.utc).isoformat(timespec="seconds"))
|
|
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]
|
|
one = str(request.query.get("entity", "") or "")[:255]
|
|
if one:
|
|
# UX-13 P4's kettle test: one entity, read now.
|
|
return web.json_response(await entities.reading(one))
|
|
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
|