Initial Commit
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
import asyncio
|
||||
from homeassistant.components import frontend, http, panel_custom
|
||||
from homeassistant.const import (
|
||||
CONF_DEFAULT,
|
||||
CONF_HEADERS,
|
||||
CONF_ICON,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
CONF_PATH,
|
||||
CONF_URL,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, cast
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
LOGGER as _LOGGER,
|
||||
API_BASE,
|
||||
URL_BASE,
|
||||
WorkMode,
|
||||
UIMode,
|
||||
RewriteMode,
|
||||
ConfMode,
|
||||
)
|
||||
from .config import IngressStore, IngressCfg, RewriteCfg
|
||||
from .ingress import IngressView, std_header_name
|
||||
from .websocket_api import async_register_websocket_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from typing import Any, Final, TypedDict
|
||||
from .client import IngressClient
|
||||
|
||||
class DomainData(TypedDict):
|
||||
client: IngressClient
|
||||
config: IngressStore
|
||||
panels: set[str]
|
||||
|
||||
|
||||
VERSION = ""
|
||||
CONNECT_TIMEOUT = 20
|
||||
|
||||
CONF_TITLE: "Final" = "title"
|
||||
CONF_MATCH: "Final" = "match"
|
||||
CONF_REPLACE: "Final" = "replace"
|
||||
CONF_INDEX: "Final" = "index"
|
||||
CONF_PARENT: "Final" = "parent"
|
||||
CONF_WORK_MODE: "Final" = "work_mode"
|
||||
CONF_UI_MODE: "Final" = "ui_mode"
|
||||
CONF_REWRITE: "Final" = "rewrite"
|
||||
CONF_COOKIE_NAME: "Final" = "cookie_name"
|
||||
CONF_EXPIRE_TIME: "Final" = "expire_time"
|
||||
CONF_STATIC_TOKEN: "Final" = "static_token"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: cv.schema_with_slug_keys(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(panel_custom.CONF_REQUIRE_ADMIN, default=False): cv.boolean,
|
||||
vol.Optional(CONF_TITLE): cv.string,
|
||||
vol.Optional(CONF_ICON): cv.icon,
|
||||
vol.Required(CONF_URL): vol.Any(
|
||||
cv.string,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MATCH): cv.string,
|
||||
vol.Required(CONF_REPLACE): cv.string,
|
||||
vol.Required(CONF_DEFAULT): cv.string,
|
||||
}
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_INDEX, default=""): cv.string,
|
||||
vol.Optional(CONF_PARENT): cv.string,
|
||||
vol.Optional(CONF_WORK_MODE, default=WorkMode.INGRESS): vol.In(list(WorkMode)),
|
||||
vol.Optional(CONF_UI_MODE, default=UIMode.NORMAL): vol.In(list(UIMode)),
|
||||
vol.Optional(CONF_HEADERS): vol.Schema({str: cv.string}),
|
||||
vol.Optional(CONF_REWRITE): [
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MODE): vol.In(list(RewriteMode)),
|
||||
vol.Optional(CONF_PATH): cv.string,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_MATCH): cv.string,
|
||||
vol.Required(CONF_REPLACE): cv.string,
|
||||
}
|
||||
)
|
||||
],
|
||||
vol.Optional(CONF_COOKIE_NAME): cv.string,
|
||||
vol.Optional(CONF_STATIC_TOKEN): cv.string,
|
||||
vol.Optional(CONF_EXPIRE_TIME): cv.positive_int,
|
||||
}
|
||||
)
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: "HomeAssistant", config) -> bool:
|
||||
# init once
|
||||
data = await _async_init(hass)
|
||||
|
||||
# init config entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if not entries:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(DOMAIN, context={"source": "import"})
|
||||
)
|
||||
|
||||
# init yaml mode
|
||||
if not entries or entries[0].data[CONF_MODE] == ConfMode.YAML:
|
||||
await setup_domain(hass, data, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: "HomeAssistant", entry: "ConfigEntry") -> bool:
|
||||
if entry.data[CONF_MODE] != ConfMode.AGENT:
|
||||
return True
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from .client import create_client
|
||||
from .client.exceptions import ClientException
|
||||
|
||||
# init client
|
||||
client = create_client(hass, entry.data[CONF_URL], async_get_clientsession(hass))
|
||||
client.on_client_event("ready", on_remote_ready)
|
||||
|
||||
# get config
|
||||
config = None
|
||||
try:
|
||||
async with asyncio.timeout(CONNECT_TIMEOUT):
|
||||
await client.connect()
|
||||
config = await get_remote_config(hass, client)
|
||||
except ClientException as err:
|
||||
raise ConfigEntryNotReady("Failed to connect to ingress agent") from err
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Failed to connect to ingress agent")
|
||||
raise ConfigEntryNotReady("Unknown error connecting to the ingress agent") from err
|
||||
finally:
|
||||
if config is None:
|
||||
client.disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda e: client.disconnect())
|
||||
)
|
||||
|
||||
# setup config
|
||||
data: DomainData = hass.data[DOMAIN]
|
||||
data["client"] = client
|
||||
await setup_domain(hass, data, config)
|
||||
return True
|
||||
|
||||
|
||||
async def on_remote_ready(hass: "HomeAssistant", client: "IngressClient", _: "Any"):
|
||||
await client.send_command({"type": "subscribe"})
|
||||
|
||||
|
||||
async def get_remote_config(hass: "HomeAssistant", client: "IngressClient") -> dict[str, "Any"]:
|
||||
# TODO
|
||||
return {DOMAIN: {}}
|
||||
|
||||
|
||||
async def async_unload_entry(hass: "HomeAssistant", entry: "ConfigEntry") -> bool:
|
||||
client: IngressClient
|
||||
if client := hass.data[DOMAIN].pop("client", None):
|
||||
client.disconnect()
|
||||
return True
|
||||
|
||||
|
||||
async def setup_domain(hass: "HomeAssistant", data: "DomainData", config) -> None:
|
||||
# clean
|
||||
for name in data["panels"]:
|
||||
frontend.async_remove_panel(hass, name)
|
||||
data["panels"].clear()
|
||||
data["config"].clear()
|
||||
|
||||
# parse config
|
||||
config, panels = _parse_config(hass, config)
|
||||
|
||||
# add ingress
|
||||
now = int(time.time())
|
||||
for cfg in config:
|
||||
data["config"].add_ingress(cfg, now)
|
||||
|
||||
# add panels
|
||||
data["panels"].update(panels)
|
||||
await asyncio.gather(*(panel_custom.async_register_panel(hass, **v) for v in panels.values()))
|
||||
|
||||
|
||||
async def _async_init(hass: "HomeAssistant") -> "DomainData":
|
||||
"""Init DomainData and service once."""
|
||||
data: DomainData = hass.data.setdefault(DOMAIN, {})
|
||||
if "config" not in cast(dict, data):
|
||||
from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
|
||||
# get version
|
||||
global VERSION
|
||||
VERSION = await get_version(hass, DOMAIN)
|
||||
|
||||
# register static url
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "www")
|
||||
await _async_register_static_paths(hass.http, [(URL_BASE, static_dir, False)])
|
||||
|
||||
# register ingress view
|
||||
config = IngressStore()
|
||||
hass.http.register_view(IngressView(hass, config, async_get_clientsession(hass)))
|
||||
|
||||
# register websocket api
|
||||
await async_register_websocket_api(hass)
|
||||
|
||||
# register reload config
|
||||
async def reload_config(call: "ServiceCall"):
|
||||
data: DomainData = hass.data[DOMAIN]
|
||||
if client := data.get("client"):
|
||||
config = await get_remote_config(hass, client)
|
||||
else:
|
||||
config = await async_integration_yaml_config(hass, DOMAIN)
|
||||
await setup_domain(hass, data, config or {})
|
||||
hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context)
|
||||
|
||||
async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config)
|
||||
|
||||
# init data
|
||||
data.update(config=config, panels=set())
|
||||
return data
|
||||
|
||||
|
||||
async def get_version(hass: "HomeAssistant", domain: str) -> str:
|
||||
version = "unknown"
|
||||
try:
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
integration = await async_get_integration(hass, domain)
|
||||
version = integration.version or version
|
||||
except Exception:
|
||||
pass
|
||||
return version
|
||||
|
||||
|
||||
async def _async_register_static_paths(hass_http: "http.HomeAssistantHTTP", configs):
|
||||
if hasattr(hass_http, "async_register_static_paths"):
|
||||
await hass_http.async_register_static_paths([http.StaticPathConfig(*c) for c in configs])
|
||||
else:
|
||||
for c in configs:
|
||||
hass_http.register_static_path(*c)
|
||||
|
||||
|
||||
def _parse_config(
|
||||
hass: "HomeAssistant", config
|
||||
) -> tuple[list[IngressCfg], dict[str, dict[str, "Any"]]]:
|
||||
cfgs: list[IngressCfg] = []
|
||||
panels: dict[str, dict[str, Any]] = {}
|
||||
ingresses, children = {}, []
|
||||
placeholder = "$http_x_ingress_path"
|
||||
entrypoint = f"{URL_BASE}/entrypoint.js?v={VERSION}"
|
||||
for name, data in config.get(DOMAIN, {}).items():
|
||||
cfg: dict[str, Any]
|
||||
ingress_cfg = None
|
||||
work_mode = data[CONF_WORK_MODE]
|
||||
url, front_url = data[CONF_URL], {}
|
||||
if isinstance(url, dict):
|
||||
url, front_url = url[CONF_DEFAULT], url
|
||||
if work_mode not in (WorkMode.IFRAME, WorkMode.CUSTOM):
|
||||
# init backend config
|
||||
ingress_cfg = dict(mode=work_mode, name=name, entry=name)
|
||||
ingress_path, sub_path = f"{API_BASE}/{name}", ""
|
||||
if work_mode != WorkMode.HASSIO:
|
||||
url = url.replace(placeholder, ingress_path).rstrip("/")
|
||||
if "://" not in url:
|
||||
url = f"http://{url}"
|
||||
pos = url.find("/", url.index("://") + 3)
|
||||
if pos > 0:
|
||||
url, sub_path = url[:pos], url[pos:]
|
||||
headers = {}
|
||||
for k, v in data.get(CONF_HEADERS, {}).items():
|
||||
headers[std_header_name(k)] = v.replace(placeholder, ingress_path)
|
||||
ingress_cfg.update(
|
||||
origin=URL(url),
|
||||
sub_path=sub_path,
|
||||
headers=headers,
|
||||
cookie_name=data.get(CONF_COOKIE_NAME),
|
||||
expire_time=data.get(CONF_EXPIRE_TIME),
|
||||
static_token=data.get(CONF_STATIC_TOKEN),
|
||||
)
|
||||
url += sub_path
|
||||
if work_mode in (WorkMode.INGRESS, WorkMode.SUBAPP):
|
||||
rewrites: list[RewriteCfg] = []
|
||||
if rewrite := data.get(CONF_REWRITE):
|
||||
ingress_path = re.escape(ingress_path)
|
||||
for item in rewrite:
|
||||
item[CONF_REPLACE] = item[CONF_REPLACE].replace(
|
||||
placeholder, ingress_path
|
||||
)
|
||||
rewrites.append(RewriteCfg(**item))
|
||||
ingress_cfg.update(rewrites=rewrites)
|
||||
ingress_cfg = IngressCfg(**ingress_cfg)
|
||||
cfgs.append(ingress_cfg)
|
||||
# init frontend config
|
||||
cfg = {"token": ingress_cfg.token, "index": data[CONF_INDEX].lstrip("/")}
|
||||
if work_mode == WorkMode.AUTH:
|
||||
cfg["url"] = front_url
|
||||
cfg["field"] = ingress_cfg.cookie_name
|
||||
elif work_mode == WorkMode.HASSIO:
|
||||
cfg["addon"] = url
|
||||
elif work_mode == WorkMode.INGRESS:
|
||||
ingresses[name] = ingress_cfg
|
||||
else:
|
||||
cfg = {"url": front_url}
|
||||
if data[CONF_INDEX]:
|
||||
url = url.rstrip("/")
|
||||
cfg["index"] = data[CONF_INDEX].lstrip("/")
|
||||
if front_url.get(CONF_MATCH):
|
||||
front_url[CONF_DEFAULT] = url
|
||||
elif "url" in cfg:
|
||||
cfg["url"] = url
|
||||
|
||||
cfg["ui_mode"] = work_mode if work_mode == WorkMode.CUSTOM else data[CONF_UI_MODE]
|
||||
title, icon = data.get(CONF_TITLE), data.get(CONF_ICON)
|
||||
if parent := data.get(CONF_PARENT):
|
||||
if name.startswith(parent) and name[len(parent) : len(parent) + 1] == "_":
|
||||
name = name[len(parent) + 1 :]
|
||||
if ingress_cfg:
|
||||
ingress_cfg.entry = f"{parent}/{name}"
|
||||
if title:
|
||||
cfg["title"] = title
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
children.append((name, parent, cfg, ingress_cfg))
|
||||
continue
|
||||
|
||||
panels[name] = dict(
|
||||
webcomponent_name="ha-panel-ingress",
|
||||
js_url=entrypoint,
|
||||
frontend_url_path=name,
|
||||
sidebar_title=title,
|
||||
sidebar_icon=icon,
|
||||
require_admin=data[panel_custom.CONF_REQUIRE_ADMIN],
|
||||
embed_iframe=False,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
for child, parent, cfg, ingress_cfg in children:
|
||||
if ingress_cfg and ingress_cfg.mode == WorkMode.SUBAPP:
|
||||
if parent not in ingresses:
|
||||
_LOGGER.error(
|
||||
"parent ingress[%s] not found, skip subapp[%s]!", parent, ingress_cfg.name
|
||||
)
|
||||
continue
|
||||
# ingress: add subapp to parent's sub_apps
|
||||
pi_cfg = ingresses[parent]
|
||||
if not pi_cfg.sub_apps:
|
||||
pi_cfg.sub_apps = []
|
||||
pi_cfg.sub_apps.append(ingress_cfg)
|
||||
# panel: use parent's parent if parent is a child panel
|
||||
if parent not in panels:
|
||||
parent = pi_cfg.entry.partition("/")
|
||||
parent, child = parent[0], f"{parent[2]}-{child}"
|
||||
ingress_cfg.entry = f"{parent}/{child}"
|
||||
if parent not in panels:
|
||||
_LOGGER.error("parent panel[%s] not found, skip child panel[%s]!", parent, child)
|
||||
continue
|
||||
# panel: add child to parent's children
|
||||
panels[parent]["config"].setdefault("children", {})[child] = cfg
|
||||
|
||||
return cfgs, panels
|
||||
Reference in New Issue
Block a user