1 """Handle the frontend for Home Assistant."""
3 from __future__
import annotations
5 from collections.abc
import Callable, Iterator
6 from functools
import lru_cache, partial
10 from typing
import Any, TypedDict
12 from aiohttp
import hdrs, web, web_urldispatcher
14 from propcache
import cached_property
15 import voluptuous
as vol
39 from .storage
import async_setup_frontend_storage
42 CONF_THEMES =
"themes"
43 CONF_THEMES_MODES =
"modes"
44 CONF_THEMES_LIGHT =
"light"
45 CONF_THEMES_DARK =
"dark"
46 CONF_EXTRA_HTML_URL =
"extra_html_url"
47 CONF_EXTRA_HTML_URL_ES5 =
"extra_html_url_es5"
48 CONF_EXTRA_MODULE_URL =
"extra_module_url"
49 CONF_EXTRA_JS_URL_ES5 =
"extra_js_url_es5"
50 CONF_FRONTEND_REPO =
"development_repo"
51 CONF_JS_VERSION =
"javascript_version"
53 DEFAULT_THEME_COLOR =
"#03A9F4"
56 DATA_PANELS =
"frontend_panels"
57 DATA_JS_VERSION =
"frontend_js_version"
58 DATA_EXTRA_MODULE_URL =
"frontend_extra_module_url"
59 DATA_EXTRA_JS_URL_ES5 =
"frontend_extra_js_url_es5"
62 "frontend_ws_subscribers"
65 THEMES_STORAGE_KEY = f
"{DOMAIN}_theme"
66 THEMES_STORAGE_VERSION = 1
67 THEMES_SAVE_DELAY = 60
68 DATA_THEMES_STORE =
"frontend_themes_store"
69 DATA_THEMES =
"frontend_themes"
70 DATA_DEFAULT_THEME =
"frontend_default_theme"
71 DATA_DEFAULT_DARK_THEME =
"frontend_default_dark_theme"
72 DEFAULT_THEME =
"default"
73 VALUE_NO_THEME =
"none"
75 PRIMARY_COLOR =
"primary-color"
77 _LOGGER = logging.getLogger(__name__)
79 EXTENDED_THEME_SCHEMA = vol.Schema(
84 vol.Optional(CONF_THEMES_MODES): vol.Schema(
86 vol.Optional(CONF_THEMES_LIGHT): vol.Schema({cv.string: cv.string}),
87 vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}),
93 THEME_SCHEMA = vol.Schema(
98 {cv.string: cv.string},
100 EXTENDED_THEME_SCHEMA,
106 CONFIG_SCHEMA = vol.Schema(
110 vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
111 vol.Optional(CONF_THEMES): THEME_SCHEMA,
112 vol.Optional(CONF_EXTRA_MODULE_URL): vol.All(
113 cv.ensure_list, [cv.string]
115 vol.Optional(CONF_EXTRA_JS_URL_ES5): vol.All(
116 cv.ensure_list, [cv.string]
119 vol.Optional(CONF_EXTRA_HTML_URL): cv.match_all,
120 vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all,
121 vol.Optional(CONF_JS_VERSION): cv.match_all,
125 extra=vol.ALLOW_EXTRA,
128 SERVICE_SET_THEME =
"set_theme"
129 SERVICE_RELOAD_THEMES =
"reload_themes"
133 """Manage the manifest.json contents."""
136 """Init the manifest manager."""
141 """Return an item in the manifest."""
146 """Return the serialized manifest."""
153 """Add a keyval to the manifest.json."""
160 "background_color":
"#FFFFFF",
162 "Home automation platform that puts local control and privacy first."
165 "display":
"standalone",
168 "src": f
"/static/icons/favicon-{size}x{size}.png",
169 "sizes": f
"{size}x{size}",
173 for size
in (192, 384, 512, 1024)
177 "src": f
"/static/icons/maskable_icon-{size}x{size}.png",
178 "sizes": f
"{size}x{size}",
180 "purpose":
"maskable",
182 for size
in (48, 72, 96, 128, 192, 384, 512)
186 "src":
"/static/images/screenshots/screenshot-1.png",
192 "name":
"Home Assistant",
193 "short_name":
"Home Assistant",
194 "start_url":
"/?homescreen=1",
195 "id":
"/?homescreen=1",
196 "theme_color": DEFAULT_THEME_COLOR,
197 "prefer_related_applications":
True,
198 "related_applications": [
199 {
"platform":
"play",
"id":
"io.homeassistant.companion.android"}
206 """Manage urls to be used on the frontend.
208 This is abstracted into a class because
209 some integrations add a remove these directly
215 on_change: Callable[[str, str],
None],
218 """Init the url manager."""
220 self.
urlsurls = frozenset(urls)
222 def add(self, url: str) ->
None:
223 """Add a url to the set."""
224 self.
urlsurls = frozenset([*self.
urlsurls, url])
228 """Remove a url from the set."""
229 self.
urlsurls = self.
urlsurls - {url}
234 """Abstract class for panels."""
240 sidebar_icon: str |
None =
None
243 sidebar_title: str |
None =
None
246 frontend_url_path: str |
None =
None
249 config: dict[str, Any] |
None =
None
252 require_admin =
False
255 config_panel_domain: str |
None =
None
260 sidebar_title: str |
None,
261 sidebar_icon: str |
None,
262 frontend_url_path: str |
None,
263 config: dict[str, Any] |
None,
265 config_panel_domain: str |
None,
267 """Initialize a built-in panel."""
278 """Panel as dictionary."""
283 "config": self.
configconfig,
295 sidebar_title: str |
None =
None,
296 sidebar_icon: str |
None =
None,
297 frontend_url_path: str |
None =
None,
298 config: dict[str, Any] |
None =
None,
299 require_admin: bool =
False,
301 update: bool =
False,
302 config_panel_domain: str |
None =
None,
304 """Register a built-in panel."""
315 panels = hass.data.setdefault(DATA_PANELS, {})
317 if not update
and panel.frontend_url_path
in panels:
318 raise ValueError(f
"Overwriting panel {panel.frontend_url_path}")
320 panels[panel.frontend_url_path] = panel
322 hass.bus.async_fire(EVENT_PANELS_UPDATED)
328 hass: HomeAssistant, frontend_url_path: str, *, warn_if_unknown: bool =
True
330 """Remove a built-in panel."""
331 panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path,
None)
335 _LOGGER.warning(
"Removing unknown panel %s", frontend_url_path)
338 hass.bus.async_fire(EVENT_PANELS_UPDATED)
342 """Register extra js or module url to load.
344 This function allows custom integrations to register extra js or module.
346 key = DATA_EXTRA_JS_URL_ES5
if es5
else DATA_EXTRA_MODULE_URL
347 hass.data[key].
add(url)
351 """Remove extra js or module url to load.
353 This function allows custom integrations to remove extra js or module.
355 key = DATA_EXTRA_JS_URL_ES5
if es5
else DATA_EXTRA_MODULE_URL
356 hass.data[key].
remove(url)
360 """Add a keyval to the manifest.json."""
361 MANIFEST_JSON.update_key(key, val)
365 """Return root path to the frontend files."""
366 if dev_repo_path
is not None:
367 return pathlib.Path(dev_repo_path) /
"hass_frontend"
372 return hass_frontend.where()
375 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
376 """Set up the serving of the frontend."""
378 websocket_api.async_register_command(hass, websocket_get_icons)
379 websocket_api.async_register_command(hass, websocket_get_panels)
380 websocket_api.async_register_command(hass, websocket_get_themes)
381 websocket_api.async_register_command(hass, websocket_get_translations)
382 websocket_api.async_register_command(hass, websocket_get_version)
383 websocket_api.async_register_command(hass, websocket_subscribe_extra_js)
386 conf = config.get(DOMAIN, {})
388 for key
in (CONF_EXTRA_HTML_URL, CONF_EXTRA_HTML_URL_ES5, CONF_JS_VERSION):
391 "Please remove %s from your frontend config. It is no longer supported",
395 repo_path = conf.get(CONF_FRONTEND_REPO)
396 is_dev = repo_path
is not None
399 static_paths_configs: list[StaticPathConfig] = []
401 for path, should_cache
in (
402 (
"service_worker.js",
False),
403 (
"sw-modern.js",
False),
404 (
"sw-modern.js.map",
False),
405 (
"sw-legacy.js",
False),
406 (
"sw-legacy.js.map",
False),
407 (
"robots.txt",
False),
408 (
"onboarding.html",
not is_dev),
409 (
"static",
not is_dev),
410 (
"frontend_latest",
not is_dev),
411 (
"frontend_es5",
not is_dev),
413 static_paths_configs.append(
417 static_paths_configs.append(
421 hass.http.register_redirect(
422 "/.well-known/change-password",
"/profile", redirect_exc=web.HTTPFound
425 local = hass.config.path(
"www")
426 if await hass.async_add_executor_job(os.path.isdir, local):
429 await hass.http.async_register_static_paths(static_paths_configs)
431 hass.http.register_redirect(
"/shopping-list",
"/todo")
433 hass.http.app.router.register_resource(
IndexView(repo_path, hass))
441 sidebar_title=
"developer_tools",
442 sidebar_icon=
"hass:hammer",
446 def async_change_listener(
451 subscribers = hass.data[DATA_WS_SUBSCRIBERS]
453 "change_type": change_type,
454 "item": {
"type": resource_type,
"url": url},
456 for connection, msg_id
in subscribers:
457 connection.send_message(websocket_api.event_message(msg_id, json_msg))
459 hass.data[DATA_EXTRA_MODULE_URL] =
UrlManager(
460 partial(async_change_listener,
"module"), conf.get(CONF_EXTRA_MODULE_URL, [])
462 hass.data[DATA_EXTRA_JS_URL_ES5] =
UrlManager(
463 partial(async_change_listener,
"es5"), conf.get(CONF_EXTRA_JS_URL_ES5, [])
465 hass.data[DATA_WS_SUBSCRIBERS] = set()
473 hass: HomeAssistant, themes: dict[str, Any] |
None
475 """Set up themes data and services."""
476 hass.data[DATA_THEMES] = themes
or {}
478 store = hass.data[DATA_THEMES_STORE] =
Store(
479 hass, THEMES_STORAGE_VERSION, THEMES_STORAGE_KEY
482 if not (theme_data := await store.async_load())
or not isinstance(theme_data, dict):
484 theme_name = theme_data.get(DATA_DEFAULT_THEME, DEFAULT_THEME)
485 dark_theme_name = theme_data.get(DATA_DEFAULT_DARK_THEME)
487 if theme_name == DEFAULT_THEME
or theme_name
in hass.data[DATA_THEMES]:
488 hass.data[DATA_DEFAULT_THEME] = theme_name
490 hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
492 if dark_theme_name == DEFAULT_THEME
or dark_theme_name
in hass.data[DATA_THEMES]:
493 hass.data[DATA_DEFAULT_DARK_THEME] = dark_theme_name
496 def update_theme_and_fire_event() -> None:
497 """Update theme_color in manifest."""
498 name = hass.data[DATA_DEFAULT_THEME]
499 themes = hass.data[DATA_THEMES]
500 if name != DEFAULT_THEME:
501 MANIFEST_JSON.update_key(
504 "app-header-background-color",
505 themes[name].
get(PRIMARY_COLOR, DEFAULT_THEME_COLOR),
509 MANIFEST_JSON.update_key(
"theme_color", DEFAULT_THEME_COLOR)
510 hass.bus.async_fire(EVENT_THEMES_UPDATED)
513 def set_theme(call: ServiceCall) ->
None:
514 """Set backend-preferred theme."""
515 name = call.data[CONF_NAME]
516 mode = call.data.get(
"mode",
"light")
519 name
not in (DEFAULT_THEME, VALUE_NO_THEME)
520 and name
not in hass.data[DATA_THEMES]
522 _LOGGER.warning(
"Theme %s not found", name)
525 light_mode = mode ==
"light"
527 theme_key = DATA_DEFAULT_THEME
if light_mode
else DATA_DEFAULT_DARK_THEME
529 if name == VALUE_NO_THEME:
530 to_set = DEFAULT_THEME
if light_mode
else None
532 _LOGGER.info(
"Theme %s set as default %s theme", name, mode)
535 hass.data[theme_key] = to_set
536 store.async_delay_save(
538 DATA_DEFAULT_THEME: hass.data[DATA_DEFAULT_THEME],
539 DATA_DEFAULT_DARK_THEME: hass.data.get(DATA_DEFAULT_DARK_THEME),
543 update_theme_and_fire_event()
545 async
def reload_themes(_: ServiceCall) ->
None:
548 new_themes = config.get(DOMAIN, {}).
get(CONF_THEMES, {})
549 hass.data[DATA_THEMES] = new_themes
550 if hass.data[DATA_DEFAULT_THEME]
not in new_themes:
551 hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
553 hass.data.get(DATA_DEFAULT_DARK_THEME)
554 and hass.data.get(DATA_DEFAULT_DARK_THEME)
not in new_themes
556 hass.data[DATA_DEFAULT_DARK_THEME] =
None
557 update_theme_and_fire_event()
559 service.async_register_admin_service(
566 vol.Required(CONF_NAME): cv.string,
567 vol.Optional(CONF_MODE): vol.Any(
"dark",
"light"),
572 service.async_register_admin_service(
573 hass, DOMAIN, SERVICE_RELOAD_THEMES, reload_themes
578 @lru_cache(maxsize=1)
580 return template.render(**kwargs)
584 """Serve the frontend."""
586 def __init__(self, repo_path: str |
None, hass: HomeAssistant) ->
None:
587 """Initialize the frontend view."""
588 super().
__init__(name=
"frontend:index")
595 """Return resource's canonical path."""
599 def _route(self) -> web_urldispatcher.ResourceRoute:
600 """Return the index route."""
601 return web_urldispatcher.ResourceRoute(
"GET", self.
getget, self)
604 """Construct url for resource with additional params."""
608 self, request: web.Request
609 ) -> tuple[web_urldispatcher.UrlMappingMatchInfo |
None, set[str]]:
612 Return (UrlMappingMatchInfo, allowed_methods) pair.
616 and (parts := request.rel_url.parts)
618 and parts[1]
not in self.
hasshass.data[DATA_PANELS]
622 if request.method != hdrs.METH_GET:
625 return web_urldispatcher.UrlMappingMatchInfo({}, self.
_route_route), {
"GET"}
628 """Add a prefix to processed URLs.
630 Required for subapplications support.
634 """Return a dict with additional info useful for introspection."""
635 return {
"panels":
list(self.
hasshass.data[DATA_PANELS])}
638 """Perform a raw match against path."""
647 tpl = jinja2.Template(file.read())
655 async
def get(self, request: web.Request) -> web.Response:
656 """Serve the index page for panel pages."""
657 hass = request.app[KEY_HASS]
659 if not onboarding.async_is_onboarded(hass):
660 return web.Response(status=302, headers={
"location":
"/onboarding.html"})
662 template = self.
_template_cache_template_cache
or await hass.async_add_executor_job(
666 extra_modules: frozenset[str]
667 extra_js_es5: frozenset[str]
668 if hass.config.safe_mode:
669 extra_modules = frozenset()
670 extra_js_es5 = frozenset()
672 extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls
673 extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls
675 response = web.Response(
678 theme_color=MANIFEST_JSON[
"theme_color"],
679 extra_modules=extra_modules,
680 extra_js_es5=extra_js_es5,
682 content_type=
"text/html",
684 response.enable_compression()
688 """Return length of resource."""
691 def __iter__(self) -> Iterator[web_urldispatcher.ResourceRoute]:
692 """Iterate over routes."""
693 return iter([self.
_route_route])
697 """View to return a manifest.json."""
699 requires_auth =
False
700 url =
"/manifest.json"
701 name =
"manifestjson"
704 def get(self, request: web.Request) -> web.Response:
705 """Return the manifest.json."""
706 response = web.Response(
707 text=MANIFEST_JSON.json, content_type=
"application/manifest+json"
709 response.enable_compression()
713 @websocket_api.websocket_command(
{
"type": "frontend/get_icons",
vol.Required("category"): vol.In({
"entity",
"entity_component",
"services"}),
714 vol.Optional(
"integration"): vol.All(cv.ensure_list, [str]),
717 @websocket_api.async_response
719 hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
721 """Handle get icons command."""
725 msg.get(
"integration"),
727 connection.send_message(
728 websocket_api.result_message(msg[
"id"], {
"resources": resources})
733 @websocket_api.websocket_command({"type": "get_panels"})
735 hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
737 """Handle get panels command."""
738 user_is_admin = connection.user.is_admin
740 panel_key: panel.to_response()
741 for panel_key, panel
in connection.hass.data[DATA_PANELS].items()
742 if user_is_admin
or not panel.require_admin
745 connection.send_message(websocket_api.result_message(msg[
"id"], panels))
749 @websocket_api.websocket_command({"type": "frontend/get_themes"})
751 hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
753 """Handle get themes command."""
754 if hass.config.recovery_mode
or hass.config.safe_mode:
755 connection.send_message(
756 websocket_api.result_message(
760 "default_theme":
"default",
766 connection.send_message(
767 websocket_api.result_message(
770 "themes": hass.data[DATA_THEMES],
771 "default_theme": hass.data[DATA_DEFAULT_THEME],
772 "default_dark_theme": hass.data.get(DATA_DEFAULT_DARK_THEME),
778 @websocket_api.websocket_command(
{
"type": "frontend/get_translations",
vol.Required("language"): str,
779 vol.Required(
"category"): str,
780 vol.Optional(
"integration"): vol.All(cv.ensure_list, [str]),
781 vol.Optional(
"config_flow"): bool,
784 @websocket_api.async_response
786 hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
788 """Handle get translations command."""
793 msg.get(
"integration"),
794 msg.get(
"config_flow"),
796 connection.send_message(
797 websocket_api.result_message(msg[
"id"], {
"resources": resources})
801 @websocket_api.websocket_command({"type": "frontend/get_version"})
802 @websocket_api.async_response
804 hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
806 """Handle get version command."""
811 for req
in integration.requirements:
812 if req.startswith(
"home-assistant-frontend=="):
813 frontend = req.removeprefix(
"home-assistant-frontend==")
816 connection.send_error(msg[
"id"],
"unknown_version",
"Version not found")
818 connection.send_result(msg[
"id"], {
"version": frontend})
822 @websocket_api.websocket_command({"type": "frontend/subscribe_extra_js"})
824 hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
826 """Subscribe to URL manager updates."""
828 subscribers = hass.data[DATA_WS_SUBSCRIBERS]
829 subscribers.add((connection, msg[
"id"]))
832 def cancel_subscription() -> None:
833 subscribers.remove((connection, msg[
"id"]))
835 connection.subscriptions[msg[
"id"]] = cancel_subscription
836 connection.send_message(websocket_api.result_message(msg[
"id"]))
840 """Represent the panel response type."""
845 config: dict[str, Any] |
None
848 config_panel_domain: str |
None
849
web_urldispatcher.ResourceRoute _route(self)
None __init__(self, str|None repo_path, HomeAssistant hass)
bool raw_match(self, str path)
URL url_for(self, **str kwargs)
None add_prefix(self, str prefix)
web.Response get(self, web.Request request)
jinja2.Template get_template(self)
dict[str, list[str]] get_info(self)
tuple[web_urldispatcher.UrlMappingMatchInfo|None, set[str]] resolve(self, web.Request request)
Iterator[web_urldispatcher.ResourceRoute] __iter__(self)
web.Response get(self, web.Request request)
None __init__(self, dict data)
None update_key(self, str key, str val)
Any __getitem__(self, str key)
None __init__(self, str component_name, str|None sidebar_title, str|None sidebar_icon, str|None frontend_url_path, dict[str, Any]|None config, bool require_admin, str|None config_panel_domain)
PanelRespons to_response(self)
None remove(self, str url)
None __init__(self, Callable[[str, str], None] on_change, list[str] urls)
bool add(self, _T matcher)
bool remove(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
None async_setup_frontend_storage(HomeAssistant hass)
None add_manifest_json_key(str key, Any val)
None websocket_get_version(HomeAssistant hass, ActiveConnection connection, dict[str, Any] msg)
None websocket_get_panels(HomeAssistant hass, ActiveConnection connection, dict[str, Any] msg)
None async_remove_panel(HomeAssistant hass, str frontend_url_path, *bool warn_if_unknown=True)
None websocket_get_themes(HomeAssistant hass, ActiveConnection connection, dict[str, Any] msg)
None add_extra_js_url(HomeAssistant hass, str url, bool es5=False)
None async_register_built_in_panel(HomeAssistant hass, str component_name, str|None sidebar_title=None, str|None sidebar_icon=None, str|None frontend_url_path=None, dict[str, Any]|None config=None, bool require_admin=False, *bool update=False, str|None config_panel_domain=None)
bool async_setup(HomeAssistant hass, ConfigType config)
None websocket_subscribe_extra_js(HomeAssistant hass, ActiveConnection connection, dict[str, Any] msg)
str _async_render_index_cached(jinja2.Template template, **Any kwargs)
None websocket_get_icons(HomeAssistant hass, ActiveConnection connection, dict[str, Any] msg)
None _async_setup_themes(HomeAssistant hass, dict[str, Any]|None themes)
None remove_extra_js_url(HomeAssistant hass, str url, bool es5=False)
None websocket_get_translations(HomeAssistant hass, ActiveConnection connection, dict[str, Any] msg)
pathlib.Path _frontend_root(str|None dev_repo_path)
None open(self, **Any kwargs)
dict async_hass_config_yaml(HomeAssistant hass)
dict[str, Any] async_get_icons(HomeAssistant hass, str category, Iterable[str]|None integrations=None)
str json_dumps_sorted(Any data)
dict[str, str] async_get_translations(HomeAssistant hass, str language, str category, Iterable[str]|None integrations=None, bool|None config_flow=None)
Integration async_get_integration(HomeAssistant hass, str domain)