Text only. "stroom te opnemen" -> "stroom op te nemen"; Developer Tools is "Ontwikkelhulpmiddelen" in HA and ui_text; the green stuck-meter row no longer starts "Vastgelopen meter:"; maintenance banner names the phase in Dutch; double "dan" in the device note; plainer peak reserve, peak guard, refused commands and missing-entity wording. Two stale comments now say the banner and Home bodies follow the page's language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
500 lines
25 KiB
Python
500 lines
25 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); P3 adds
|
|
`pack_sell`, that battery's selling switch. 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, price, selfconfig, uipage, uitext
|
|
from .haentities import EntityCache
|
|
from .homeview import priced_hours, region_of
|
|
from .validate import config_notes, note_text, setup_checks
|
|
from .config_page import CONFIG_PAGE
|
|
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
|
problems_by_field, secret_keys)
|
|
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", "pack_sell")
|
|
# MAINT-11: start or stop one battery's maintenance cycle (the unit is optional).
|
|
MAINT_ACTIONS = ("maint_start", "maint_abort")
|
|
|
|
# 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.")
|
|
DEVICE_NOTE_NL = (
|
|
"%s meldde geen vermogensbereik, dus twee controles zijn overgeslagen, "
|
|
"hier en bij het starten. Falen ze later, dan start de add-on niet.")
|
|
|
|
|
|
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)
|
|
elif what in MAINT_ACTIONS and body.get("unit") is not None:
|
|
# MAINT-11: one battery's cycle. No unit = the old bare call below.
|
|
result = await controller.handle_action(what, unit=body["unit"])
|
|
if isinstance(result, dict) and "error" in result:
|
|
return web.json_response(
|
|
{"ok": False, "error": result["error"],
|
|
"code": result["code"], "unit": result["unit"]},
|
|
status=409 if result["code"] != "unit" else 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
|
|
|
|
def _secrets(info):
|
|
# PRICE-01 F1: every `password` option of the schema, and the price
|
|
# token by name in case a schema is missing.
|
|
return secret_keys((info or {}).get("schema"), price.SECRET_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 {}
|
|
secrets = _secrets(info)
|
|
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": hide_secrets(options, secrets),
|
|
"fingerprint": hand_out(options),
|
|
"running": hide_secrets(controller.o, secrets),
|
|
"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": hide_secrets(options_to_ui(options), secrets),
|
|
# 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")
|
|
try:
|
|
info_now = await selfcfg.info()
|
|
options = keep_secrets(options, info_now.get("options"),
|
|
_secrets(info_now))
|
|
except Exception: # noqa: BLE001 - a check never fails for this
|
|
pass
|
|
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())
|
|
# UX-46: one language choice for everything below that has words.
|
|
lang = uitext.lang_of(_body.get("lang"))
|
|
payload = {"problems": problems, "by_key": by_key,
|
|
"unanchored": unanchored,
|
|
"notes": [n._asdict() for n in notes]}
|
|
if unread:
|
|
payload["device_note"] = (DEVICE_NOTE_NL if lang == "nl"
|
|
else DEVICE_NOTE) % ", ".join(
|
|
sorted(set(unread)))
|
|
if model is not None:
|
|
payload["messages"] = (
|
|
uipage.derive_messages(errors, problems, notes, model,
|
|
_keys_of(options), lang)
|
|
if errors
|
|
else uipage.anchors(problems, notes, model, _keys_of(options),
|
|
lang))
|
|
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
|
if _body.get("setup"):
|
|
payload["setup"] = [
|
|
dict(n._asdict(), text=note_text(n, lang))
|
|
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 = priced_hours(prices)
|
|
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
|
|
secrets = frozenset(price.SECRET_OPTIONS)
|
|
if isinstance(posted, dict):
|
|
try:
|
|
info_now = await selfcfg.info()
|
|
stored_now = info_now.get("options") or {}
|
|
secrets = _secrets(info_now)
|
|
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)
|
|
# PRICE-01 F1: a secret the page did not change is the stored one.
|
|
options = keep_secrets(options, stored_now, secrets)
|
|
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": hide_secrets(stored, secrets),
|
|
# 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
|