"""Handle the frontend for Home Assistant.""" from collections.abc import Callable, Iterator from functools import lru_cache, partial import logging import os import pathlib import shutil from typing import Any, TypedDict from aiohttp import hdrs, web, web_urldispatcher import jinja2 from propcache.api import cached_property import voluptuous as vol from yarl import URL from homeassistant.components import onboarding, websocket_api from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig from homeassistant.components.websocket_api import ERR_NOT_FOUND, ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import ( CONF_MODE, CONF_NAME, EVENT_PANELS_UPDATED, EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey from .pr_download import download_pr_artifact from .storage import ( async_setup_frontend_storage, async_system_store as async_system_store, ) _LOGGER = logging.getLogger(__name__) DOMAIN = "frontend" CONF_NAME_DARK = "name_dark" CONF_THEMES = "themes" CONF_THEMES_MODES = "modes" CONF_THEMES_LIGHT = "light" CONF_THEMES_DARK = "dark" CONF_EXTRA_HTML_URL = "extra_html_url" CONF_EXTRA_HTML_URL_ES5 = "extra_html_url_es5" CONF_EXTRA_MODULE_URL = "extra_module_url" CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5" CONF_FRONTEND_REPO = "development_repo" CONF_JS_VERSION = "javascript_version" CONF_DEVELOPMENT_PR = "development_pr" CONF_GITHUB_TOKEN = "github_token" DEV_ARTIFACTS_DIR = "development_artifacts" DEFAULT_THEME_COLOR = "#2980b9" DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels") DATA_EXTRA_MODULE_URL: HassKey[UrlManager] = HassKey("frontend_extra_module_url") DATA_EXTRA_JS_URL_ES5: HassKey[UrlManager] = HassKey("frontend_extra_js_url_es5") DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = HassKey( "frontend_ws_subscribers" ) THEMES_STORAGE_KEY = f"{DOMAIN}_theme" THEMES_STORAGE_VERSION = 1 THEMES_SAVE_DELAY = 60 DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store") DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes") PANELS_STORAGE_KEY = f"{DOMAIN}_panels" PANELS_STORAGE_VERSION = 1 PANELS_SAVE_DELAY = 10 DATA_PANELS_STORE: HassKey[Store[dict[str, dict[str, Any]]]] = HassKey( "frontend_panels_store" ) DATA_PANELS_CONFIG: HassKey[dict[str, dict[str, Any]]] = HassKey( "frontend_panels_config" ) DATA_DEFAULT_THEME = "frontend_default_theme" DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" VALUE_NO_THEME = "none" PRIMARY_COLOR = "primary-color" LEGACY_THEME_SCHEMA = vol.Any( # Legacy theme scheme {cv.string: cv.string}, # New extended schema with mode support { # Theme variables that apply to all modes cv.string: cv.string, # Mode specific theme variables vol.Optional(CONF_THEMES_MODES): vol.Schema( { vol.Optional(CONF_THEMES_LIGHT): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}), } ), }, ) THEME_SCHEMA = vol.Schema( { # Theme variables that apply to all modes cv.string: cv.string, # Mode specific theme variables vol.Optional(CONF_THEMES_MODES): vol.All( { vol.Optional(CONF_THEMES_LIGHT): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}), }, cv.has_at_least_one_key(CONF_THEMES_LIGHT, CONF_THEMES_DARK), ), } ) def _validate_themes(themes: dict) -> dict[str, Any]: """Validate themes.""" validated_themes = {} for theme_name, theme in themes.items(): theme_name = cv.string(theme_name) LEGACY_THEME_SCHEMA(theme) try: validated_themes[theme_name] = THEME_SCHEMA(theme) except vol.Invalid as err: _LOGGER.error("Theme %s is invalid: %s", theme_name, err) return validated_themes CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Optional(CONF_FRONTEND_REPO): cv.isdir, vol.Inclusive(CONF_DEVELOPMENT_PR, "development_pr"): cv.positive_int, vol.Inclusive(CONF_GITHUB_TOKEN, "development_pr"): cv.string, vol.Optional(CONF_THEMES): vol.All(dict, _validate_themes), vol.Optional(CONF_EXTRA_MODULE_URL): vol.All( cv.ensure_list, [cv.string] ), vol.Optional(CONF_EXTRA_JS_URL_ES5): vol.All( cv.ensure_list, [cv.string] ), # We no longer use these options. vol.Optional(CONF_EXTRA_HTML_URL): cv.match_all, vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all, vol.Optional(CONF_JS_VERSION): cv.match_all, }, ) }, extra=vol.ALLOW_EXTRA, ) SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" class Manifest: """Manage the manifest.json contents.""" def __init__(self, data: dict) -> None: """Init the manifest manager.""" self.manifest = data self._serialize() def __getitem__(self, key: str) -> Any: """Return an item in the manifest.""" return self.manifest[key] @property def json(self) -> str: """Return the serialized manifest.""" return self._serialized def _serialize(self) -> None: self._serialized = json_dumps_sorted(self.manifest) def update_key(self, key: str, val: str) -> None: """Add a keyval to the manifest.json.""" self.manifest[key] = val self._serialize() MANIFEST_JSON = Manifest( { "background_color": "#FFFFFF", "description": ( "Home automation platform that puts local control and privacy first." ), "dir": "ltr", "display": "standalone", "icons": [ { "src": f"/static/icons/favicon-{size}x{size}.png", "sizes": f"{size}x{size}", "type": "image/png", "purpose": "any", } for size in (192, 384, 512, 1024) ] + [ { "src": f"/static/icons/maskable_icon-{size}x{size}.png", "sizes": f"{size}x{size}", "type": "image/png", "purpose": "maskable", } for size in (48, 72, 96, 128, 192, 384, 512) ], "screenshots": [ { "src": "/static/images/screenshots/screenshot-1.png", "sizes": "413x792", "type": "image/png", } ], "lang": "en-US", "name": "Home Assistant", "short_name": "Home Assistant", "start_url": "/?homescreen=1", "id": "/?homescreen=1", "theme_color": DEFAULT_THEME_COLOR, "prefer_related_applications": True, "related_applications": [ {"platform": "play", "id": "io.homeassistant.companion.android"} ], } ) class UrlManager: """Manage urls to be used on the frontend. This is abstracted into a class because some integrations add a remove these directly on hass.data """ def __init__( self, on_change: Callable[[str, str], None], urls: list[str], ) -> None: """Init the url manager.""" self._on_change = on_change self.urls = frozenset(urls) def add(self, url: str) -> None: """Add a url to the set.""" self.urls = frozenset([*self.urls, url]) self._on_change("added", url) def remove(self, url: str) -> None: """Remove a url from the set.""" self.urls = self.urls - {url} self._on_change("removed", url) class Panel: """Abstract class for panels.""" # Name of the webcomponent component_name: str # Icon to show in the sidebar sidebar_icon: str | None = None # Title to show in the sidebar sidebar_title: str | None = None # If the panel should be visible by default in the sidebar sidebar_default_visible: bool = True # Url to show the panel in the frontend frontend_url_path: str # Config to pass to the webcomponent config: dict[str, Any] | None = None # If the panel should only be visible to admins require_admin = False # If the panel should be shown in the sidebar show_in_sidebar = True # If the panel is a configuration panel for a integration config_panel_domain: str | None = None def __init__( self, component_name: str, sidebar_title: str | None, sidebar_icon: str | None, sidebar_default_visible: bool, frontend_url_path: str | None, config: dict[str, Any] | None, require_admin: bool, config_panel_domain: str | None, show_in_sidebar: bool, ) -> None: """Initialize a built-in panel.""" self.component_name = component_name self.sidebar_title = sidebar_title self.sidebar_icon = sidebar_icon self.frontend_url_path = frontend_url_path or component_name self.config = config self.require_admin = require_admin self.config_panel_domain = config_panel_domain self.show_in_sidebar = show_in_sidebar self.sidebar_default_visible = sidebar_default_visible @callback def to_response( self, config_override: dict[str, Any] | None = None ) -> PanelResponse: """Panel as dictionary.""" response: PanelResponse = { "component_name": self.component_name, "icon": self.sidebar_icon, "title": self.sidebar_title, "default_visible": self.sidebar_default_visible, "config": self.config, "url_path": self.frontend_url_path, "require_admin": self.require_admin, "config_panel_domain": self.config_panel_domain, "show_in_sidebar": self.show_in_sidebar, } if config_override: if "require_admin" in config_override: response["require_admin"] = config_override["require_admin"] if "show_in_sidebar" in config_override: response["show_in_sidebar"] = config_override["show_in_sidebar"] if "icon" in config_override: response["icon"] = config_override["icon"] if "title" in config_override: response["title"] = config_override["title"] return response @callback def async_register_built_in_panel( hass: HomeAssistant, component_name: str, sidebar_title: str | None = None, sidebar_icon: str | None = None, sidebar_default_visible: bool = True, frontend_url_path: str | None = None, config: dict[str, Any] | None = None, require_admin: bool = False, *, update: bool = False, config_panel_domain: str | None = None, show_in_sidebar: bool = True, ) -> None: """Register a built-in panel.""" panel = Panel( component_name, sidebar_title, sidebar_icon, sidebar_default_visible, frontend_url_path, config, require_admin, config_panel_domain, show_in_sidebar, ) panels = hass.data.setdefault(DATA_PANELS, {}) if not update and panel.frontend_url_path in panels: raise ValueError(f"Overwriting panel {panel.frontend_url_path}") panels[panel.frontend_url_path] = panel hass.bus.async_fire(EVENT_PANELS_UPDATED) @callback def async_remove_panel( hass: HomeAssistant, frontend_url_path: str, *, warn_if_unknown: bool = True ) -> None: """Remove a built-in panel.""" panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) if panel is None: if warn_if_unknown: _LOGGER.warning("Removing unknown panel %s", frontend_url_path) return hass.bus.async_fire(EVENT_PANELS_UPDATED) @callback def async_panel_exists(hass: HomeAssistant, frontend_url_path: str) -> bool: """Return if a panel is registered for the given frontend URL path.""" return frontend_url_path in hass.data.get(DATA_PANELS, {}) def add_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: """Register extra js or module url to load. This function allows custom integrations to register extra js or module. """ key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL hass.data[key].add(url) def remove_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: """Remove extra js or module url to load. This function allows custom integrations to remove extra js or module. """ key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL hass.data[key].remove(url) def add_manifest_json_key(key: str, val: Any) -> None: """Add a keyval to the manifest.json.""" MANIFEST_JSON.update_key(key, val) def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: """Return root path to the frontend files.""" if dev_repo_path is not None: return pathlib.Path(dev_repo_path) / "hass_frontend" # Keep import here so that we can import frontend without installing reqs import hass_frontend # noqa: PLC0415 return hass_frontend.where() async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) panels_store = hass.data[DATA_PANELS_STORE] = Store[dict[str, dict[str, Any]]]( hass, PANELS_STORAGE_VERSION, PANELS_STORAGE_KEY ) loaded: Any = await panels_store.async_load() if not isinstance(loaded, dict): if loaded is not None: _LOGGER.warning("Ignoring invalid panel storage data") loaded = {} hass.data[DATA_PANELS_CONFIG] = loaded websocket_api.async_register_command(hass, websocket_get_icons) websocket_api.async_register_command(hass, websocket_get_panels) websocket_api.async_register_command(hass, websocket_get_themes) websocket_api.async_register_command(hass, websocket_get_translations) websocket_api.async_register_command(hass, websocket_get_version) websocket_api.async_register_command(hass, websocket_subscribe_extra_js) websocket_api.async_register_command(hass, websocket_update_panel) hass.http.register_view(ManifestJSONView()) conf = config.get(DOMAIN, {}) for key in (CONF_EXTRA_HTML_URL, CONF_EXTRA_HTML_URL_ES5, CONF_JS_VERSION): if key in conf: _LOGGER.error( "Please remove %s from your frontend config. It is no longer supported", key, ) repo_path = conf.get(CONF_FRONTEND_REPO) dev_pr_number = conf.get(CONF_DEVELOPMENT_PR) pr_cache_dir = pathlib.Path(hass.config.cache_path(DOMAIN, DEV_ARTIFACTS_DIR)) if not dev_pr_number and pr_cache_dir.exists(): try: await hass.async_add_executor_job(shutil.rmtree, pr_cache_dir) _LOGGER.debug("Cleaned up frontend development artifacts") except OSError as err: _LOGGER.warning( "Could not clean up frontend development artifacts: %s", err ) # Priority: development_repo > development_pr > integrated if repo_path and dev_pr_number: _LOGGER.warning( "Both development_repo and development_pr are specified for frontend. " "Using development_repo, remove development_repo to use " "automatic PR download" ) dev_pr_number = None if dev_pr_number: github_token: str = conf[CONF_GITHUB_TOKEN] try: dev_pr_dir = await download_pr_artifact( hass, dev_pr_number, github_token, pr_cache_dir ) repo_path = str(dev_pr_dir) _LOGGER.info("Using frontend from PR #%s", dev_pr_number) except HomeAssistantError as err: _LOGGER.error( "Failed to download PR #%s: %s, falling back" " to the integrated frontend", dev_pr_number, err, ) except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception( "Unexpected error downloading PR #%s, " "falling back to the integrated frontend", dev_pr_number, ) is_dev = repo_path is not None root_path = _frontend_root(repo_path) static_paths_configs: list[StaticPathConfig] = [] for path, should_cache in ( ("service_worker.js", False), ("sw-modern.js", False), ("sw-modern.js.map", False), ("sw-legacy.js", False), ("sw-legacy.js.map", False), ("robots.txt", False), ("onboarding.html", not is_dev), ("static", not is_dev), ("frontend_latest", not is_dev), ("frontend_es5", not is_dev), ): static_paths_configs.append( StaticPathConfig(f"/{path}", str(root_path / path), should_cache) ) static_paths_configs.append( StaticPathConfig("/auth/authorize", str(root_path / "authorize.html"), False) ) # https://wicg.github.io/change-password-url/ hass.http.register_redirect( "/.well-known/change-password", "/profile", redirect_exc=web.HTTPFound ) local = hass.config.path("www") if await hass.async_add_executor_job(os.path.isdir, local): static_paths_configs.append(StaticPathConfig("/local", local, not is_dev)) await hass.http.async_register_static_paths(static_paths_configs) # Shopping list panel was replaced by todo panel in 2023.11 hass.http.register_redirect("/shopping-list", "/todo") # Developer tools moved to config panel in 2026.2 for url in ( "/developer-tools", "/developer-tools/yaml", "/developer-tools/state", "/developer-tools/action", "/developer-tools/template", "/developer-tools/event", "/developer-tools/statistics", "/developer-tools/assist", "/developer-tools/debug", ): hass.http.register_redirect(url, f"/config{url}") hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel( hass, "light", sidebar_icon="mdi:lamps", sidebar_title="light", show_in_sidebar=False, ) async_register_built_in_panel( hass, "security", sidebar_icon="mdi:security", sidebar_title="security", show_in_sidebar=False, ) async_register_built_in_panel( hass, "climate", sidebar_icon="mdi:home-thermometer", sidebar_title="climate", show_in_sidebar=False, ) async_register_built_in_panel( hass, "home", sidebar_icon="mdi:home", sidebar_title="home", show_in_sidebar=False, ) async_register_built_in_panel( hass, "maintenance", sidebar_icon="mdi:wrench", sidebar_title="maintenance", show_in_sidebar=False, ) async_register_built_in_panel(hass, "profile") async_register_built_in_panel(hass, "notfound") @callback def async_change_listener( resource_type: str, change_type: str, url: str, ) -> None: subscribers = hass.data[DATA_WS_SUBSCRIBERS] json_msg = { "change_type": change_type, "item": {"type": resource_type, "url": url}, } for connection, msg_id in subscribers: connection.send_message(websocket_api.event_message(msg_id, json_msg)) hass.data[DATA_EXTRA_MODULE_URL] = UrlManager( partial(async_change_listener, "module"), conf.get(CONF_EXTRA_MODULE_URL, []) ) hass.data[DATA_EXTRA_JS_URL_ES5] = UrlManager( partial(async_change_listener, "es5"), conf.get(CONF_EXTRA_JS_URL_ES5, []) ) hass.data[DATA_WS_SUBSCRIBERS] = set() await _async_setup_themes(hass, conf.get(CONF_THEMES)) return True def _validate_selected_theme(theme: str) -> str: """Validate that a user selected theme is a valid theme.""" if theme in (DEFAULT_THEME, VALUE_NO_THEME): return theme hass = async_get_hass() if theme not in hass.data[DATA_THEMES]: raise vol.Invalid(f"Theme {theme} not found") return theme async def _async_setup_themes( hass: HomeAssistant, themes: dict[str, Any] | None ) -> None: """Set up themes data and services.""" hass.data[DATA_THEMES] = themes or {} store = hass.data[DATA_THEMES_STORE] = Store( hass, THEMES_STORAGE_VERSION, THEMES_STORAGE_KEY ) if not (theme_data := await store.async_load()) or not isinstance(theme_data, dict): theme_data = {} theme_name = theme_data.get(DATA_DEFAULT_THEME, DEFAULT_THEME) dark_theme_name = theme_data.get(DATA_DEFAULT_DARK_THEME) if theme_name == DEFAULT_THEME or theme_name in hass.data[DATA_THEMES]: hass.data[DATA_DEFAULT_THEME] = theme_name else: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME if dark_theme_name == DEFAULT_THEME or dark_theme_name in hass.data[DATA_THEMES]: hass.data[DATA_DEFAULT_DARK_THEME] = dark_theme_name @callback def update_theme_and_fire_event() -> None: """Update theme_color in manifest.""" name = hass.data[DATA_DEFAULT_THEME] themes = hass.data[DATA_THEMES] if name != DEFAULT_THEME: MANIFEST_JSON.update_key( "theme_color", themes[name].get( "app-header-background-color", themes[name].get(PRIMARY_COLOR, DEFAULT_THEME_COLOR), ), ) else: MANIFEST_JSON.update_key("theme_color", DEFAULT_THEME_COLOR) hass.bus.async_fire(EVENT_THEMES_UPDATED) @callback def set_theme(call: ServiceCall) -> None: """Set backend-preferred theme.""" def _update_hass_theme(theme: str, light: bool) -> None: theme_key = DATA_DEFAULT_THEME if light else DATA_DEFAULT_DARK_THEME if theme == VALUE_NO_THEME: to_set = DEFAULT_THEME if light else None else: _LOGGER.info( "Theme %s set as default %s theme", theme, "light" if light else "dark", ) to_set = theme hass.data[theme_key] = to_set name = call.data.get(CONF_NAME) if name is not None and CONF_MODE in call.data: mode = call.data.get("mode", "light") light_mode = mode == "light" _update_hass_theme(name, light_mode) else: name_dark = call.data.get(CONF_NAME_DARK) if name: _update_hass_theme(name, True) if name_dark: _update_hass_theme(name_dark, False) store.async_delay_save( lambda: { DATA_DEFAULT_THEME: hass.data[DATA_DEFAULT_THEME], DATA_DEFAULT_DARK_THEME: hass.data.get(DATA_DEFAULT_DARK_THEME), }, THEMES_SAVE_DELAY, ) update_theme_and_fire_event() async def reload_themes(_: ServiceCall) -> None: """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) try: new_themes = _validate_themes(new_themes) except vol.Invalid as err: raise HomeAssistantError(f"Failed to reload themes: {err}") from err hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME if ( hass.data.get(DATA_DEFAULT_DARK_THEME) and hass.data.get(DATA_DEFAULT_DARK_THEME) not in new_themes ): hass.data[DATA_DEFAULT_DARK_THEME] = None update_theme_and_fire_event() service.async_register_admin_service( hass, DOMAIN, SERVICE_SET_THEME, set_theme, vol.All( { vol.Optional(CONF_NAME): _validate_selected_theme, vol.Exclusive(CONF_NAME_DARK, "dark_modes"): _validate_selected_theme, vol.Exclusive(CONF_MODE, "dark_modes"): vol.Any("dark", "light"), }, cv.has_at_least_one_key(CONF_NAME, CONF_NAME_DARK), ), ) service.async_register_admin_service( hass, DOMAIN, SERVICE_RELOAD_THEMES, reload_themes ) @callback @lru_cache(maxsize=1) def _async_render_index_cached(template: jinja2.Template, **kwargs: Any) -> str: return template.render(**kwargs) class IndexView(web_urldispatcher.AbstractResource): """Serve the frontend.""" def __init__(self, repo_path: str | None, hass: HomeAssistant) -> None: """Initialize the frontend view.""" super().__init__(name="frontend:index") self.repo_path = repo_path self.hass = hass self._template_cache: jinja2.Template | None = None @cached_property def canonical(self) -> str: """Return resource's canonical path.""" return "/" @cached_property def _route(self) -> web_urldispatcher.ResourceRoute: """Return the index route.""" return web_urldispatcher.ResourceRoute("GET", self.get, self) def url_for(self, **kwargs: str) -> URL: """Construct url for resource with additional params.""" return URL("/") async def resolve( self, request: web.Request ) -> tuple[web_urldispatcher.UrlMappingMatchInfo | None, set[str]]: """Resolve resource. Return (UrlMappingMatchInfo, allowed_methods) pair. """ if ( request.path != "/" and (parts := request.rel_url.parts) and len(parts) > 1 and parts[1] not in self.hass.data[DATA_PANELS] ): return None, set() if request.method != hdrs.METH_GET: return None, {"GET"} return web_urldispatcher.UrlMappingMatchInfo({}, self._route), {"GET"} def add_prefix(self, prefix: str) -> None: """Add a prefix to processed URLs. Required for subapplications support. """ def get_info(self) -> dict[str, list[str]]: # type: ignore[override] """Return a dict with additional info useful for introspection.""" panels = self.hass.data[DATA_PANELS] return {"panels": list(panels)} def raw_match(self, path: str) -> bool: """Perform a raw match against path.""" return False def get_template(self) -> jinja2.Template: """Get template.""" if (tpl := self._template_cache) is None: with (_frontend_root(self.repo_path) / "index.html").open( encoding="utf8" ) as file: tpl = jinja2.Template(file.read()) # Cache template if not running from repository if self.repo_path is None: self._template_cache = tpl return tpl async def get(self, request: web.Request) -> web.Response: """Serve the index page for panel pages.""" hass = request.app[KEY_HASS] if not onboarding.async_is_onboarded(hass): return web.Response(status=302, headers={"location": "/onboarding.html"}) template = self._template_cache or await hass.async_add_executor_job( self.get_template ) extra_modules: frozenset[str] extra_js_es5: frozenset[str] if hass.config.safe_mode: extra_modules = frozenset() extra_js_es5 = frozenset() else: extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls response = web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], extra_modules=extra_modules, extra_js_es5=extra_js_es5, ), content_type="text/html", ) response.enable_compression() return response def __len__(self) -> int: """Return length of resource.""" return 1 def __iter__(self) -> Iterator[web_urldispatcher.ResourceRoute]: """Iterate over routes.""" return iter([self._route]) class ManifestJSONView(HomeAssistantView): """View to return a manifest.json.""" requires_auth = False url = "/manifest.json" name = "manifestjson" @callback def get(self, request: web.Request) -> web.Response: """Return the manifest.json.""" response = web.Response( text=MANIFEST_JSON.json, content_type="application/manifest+json" ) response.enable_compression() return response @websocket_api.websocket_command( { "type": "frontend/get_icons", vol.Required("category"): vol.In( {"conditions", "entity", "entity_component", "services", "triggers"} ), vol.Optional("integration"): vol.All(cv.ensure_list, [str]), } ) @websocket_api.async_response async def websocket_get_icons( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get icons command.""" resources = await async_get_icons( hass, msg["category"], msg.get("integration"), ) connection.send_message( websocket_api.result_message(msg["id"], {"resources": resources}) ) @callback @websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get panels command.""" user_is_admin = connection.user.is_admin panels_config = hass.data[DATA_PANELS_CONFIG] panels: dict[str, PanelResponse] = {} for panel_key, panel in connection.hass.data[DATA_PANELS].items(): config_override = panels_config.get(panel_key) require_admin = ( config_override.get("require_admin", panel.require_admin) if config_override else panel.require_admin ) if not user_is_admin and require_admin: continue panels[panel_key] = panel.to_response(config_override) connection.send_message(websocket_api.result_message(msg["id"], panels)) @callback @websocket_api.websocket_command({"type": "frontend/get_themes"}) def websocket_get_themes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get themes command.""" if hass.config.recovery_mode or hass.config.safe_mode: connection.send_message( websocket_api.result_message( msg["id"], { "themes": {}, "default_theme": "default", }, ) ) return connection.send_message( websocket_api.result_message( msg["id"], { "themes": hass.data[DATA_THEMES], "default_theme": hass.data[DATA_DEFAULT_THEME], "default_dark_theme": hass.data.get(DATA_DEFAULT_DARK_THEME), }, ) ) @websocket_api.websocket_command( { "type": "frontend/get_translations", vol.Required("language"): str, vol.Required("category"): str, vol.Optional("integration"): vol.All(cv.ensure_list, [str]), vol.Optional("config_flow"): bool, } ) @websocket_api.async_response async def websocket_get_translations( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get translations command.""" resources = await async_get_translations( hass, msg["language"], msg["category"], msg.get("integration"), msg.get("config_flow"), ) connection.send_message( websocket_api.result_message(msg["id"], {"resources": resources}) ) @websocket_api.websocket_command({"type": "frontend/get_version"}) @websocket_api.async_response async def websocket_get_version( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get version command.""" integration = await async_get_integration(hass, "frontend") frontend = None for req in integration.requirements: if req.startswith("home-assistant-frontend=="): frontend = req.removeprefix("home-assistant-frontend==") if frontend is None: connection.send_error(msg["id"], "unknown_version", "Version not found") else: connection.send_result(msg["id"], {"version": frontend}) @callback @websocket_api.websocket_command({"type": "frontend/subscribe_extra_js"}) def websocket_subscribe_extra_js( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Subscribe to URL manager updates.""" subscribers = hass.data[DATA_WS_SUBSCRIBERS] subscribers.add((connection, msg["id"])) @callback def cancel_subscription() -> None: subscribers.remove((connection, msg["id"])) connection.subscriptions[msg["id"]] = cancel_subscription connection.send_message(websocket_api.result_message(msg["id"])) @websocket_api.websocket_command( { vol.Required("type"): "frontend/update_panel", vol.Required("url_path"): str, vol.Optional("title"): vol.Any(cv.string, None), vol.Optional("icon"): vol.Any(cv.icon, None), vol.Optional("require_admin"): vol.Any(cv.boolean, None), vol.Optional("show_in_sidebar"): vol.Any(cv.boolean, None), } ) @websocket_api.require_admin @websocket_api.async_response async def websocket_update_panel( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle update panel command.""" url_path: str = msg["url_path"] if url_path not in hass.data.get(DATA_PANELS, {}): connection.send_error(msg["id"], ERR_NOT_FOUND, "Panel not found") return panels_config = hass.data[DATA_PANELS_CONFIG] panel_config = dict(panels_config.get(url_path, {})) for key in ("title", "icon", "require_admin", "show_in_sidebar"): if key in msg: if (value := msg[key]) is None: panel_config.pop(key, None) else: panel_config[key] = value if panel_config: panels_config[url_path] = panel_config else: panels_config.pop(url_path, None) hass.data[DATA_PANELS_STORE].async_delay_save( lambda: hass.data[DATA_PANELS_CONFIG], PANELS_SAVE_DELAY ) hass.bus.async_fire(EVENT_PANELS_UPDATED) connection.send_result(msg["id"]) class PanelResponse(TypedDict): """Represent the panel response type.""" component_name: str icon: str | None title: str | None default_visible: bool config: dict[str, Any] | None url_path: str require_admin: bool config_panel_domain: str | None show_in_sidebar: bool