diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f487064cafd5..e8ab7acae4a2 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -18,7 +18,7 @@ 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 ActiveConnection +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import ( CONF_MODE, @@ -78,6 +78,16 @@ 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" @@ -312,9 +322,11 @@ class Panel: self.sidebar_default_visible = sidebar_default_visible @callback - def to_response(self) -> PanelResponse: + def to_response( + self, config_override: dict[str, Any] | None = None + ) -> PanelResponse: """Panel as dictionary.""" - return { + response: PanelResponse = { "component_name": self.component_name, "icon": self.sidebar_icon, "title": self.sidebar_title, @@ -324,6 +336,18 @@ class Panel: "require_admin": self.require_admin, "config_panel_domain": self.config_panel_domain, } + if config_override: + if "require_admin" in config_override: + response["require_admin"] = config_override["require_admin"] + if config_override.get("show_in_sidebar") is False: + response["title"] = None + response["icon"] = None + else: + if "icon" in config_override: + response["icon"] = config_override["icon"] + if "title" in config_override: + response["title"] = config_override["title"] + return response @bind_hass @@ -415,12 +439,24 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: 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, {}) @@ -559,6 +595,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) async_register_built_in_panel(hass, "profile") + async_register_built_in_panel(hass, "notfound") @callback def async_change_listener( @@ -883,11 +920,18 @@ def websocket_get_panels( ) -> None: """Handle get panels command.""" user_is_admin = connection.user.is_admin - panels = { - panel_key: panel.to_response() - for panel_key, panel in connection.hass.data[DATA_PANELS].items() - if user_is_admin or not panel.require_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)) @@ -986,6 +1030,50 @@ def websocket_subscribe_extra_js( 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.""" diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index b13dd999ec99..dc5a8cbabd08 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -1209,3 +1209,240 @@ async def test_setup_with_development_pr_unexpected_error( await hass.async_block_till_done() assert "Unexpected error downloading PR #12345" in caplog.text + + +async def test_update_panel( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test frontend/update_panel command.""" + # Verify initial state + await ws_client.send_json({"id": 1, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["light"]["icon"] == "mdi:lamps" + assert msg["result"]["light"]["title"] == "light" + assert msg["result"]["light"]["require_admin"] is False + + # Update the light panel + events = async_capture_events(hass, EVENT_PANELS_UPDATED) + await ws_client.send_json( + { + "id": 2, + "type": "frontend/update_panel", + "url_path": "light", + "title": "My Lights", + "icon": "mdi:lightbulb", + "require_admin": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(events) == 1 + + # Verify the panel was updated + await ws_client.send_json({"id": 3, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["light"]["icon"] == "mdi:lightbulb" + assert msg["result"]["light"]["title"] == "My Lights" + assert msg["result"]["light"]["require_admin"] is True + + +async def test_update_panel_partial( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test that partial updates only change specified properties.""" + # Update only title + await ws_client.send_json( + { + "id": 1, + "type": "frontend/update_panel", + "url_path": "climate", + "title": "HVAC", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Verify only title changed, others kept defaults + await ws_client.send_json({"id": 2, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["climate"]["title"] == "HVAC" + assert msg["result"]["climate"]["icon"] == "mdi:home-thermometer" + assert msg["result"]["climate"]["require_admin"] is False + assert msg["result"]["climate"]["default_visible"] is False + + +async def test_update_panel_not_found(ws_client: MockHAClientWebSocket) -> None: + """Test that non-existent panels are rejected.""" + await ws_client.send_json( + { + "id": 1, + "type": "frontend/update_panel", + "url_path": "nonexistent", + "title": "Does Not Exist", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + +async def test_update_panel_requires_admin( + hass: HomeAssistant, + ws_client: MockHAClientWebSocket, + hass_admin_user: MockUser, +) -> None: + """Test that non-admin users cannot update panels.""" + hass_admin_user.groups = [] + + await ws_client.send_json( + { + "id": 1, + "type": "frontend/update_panel", + "url_path": "light", + "title": "My Lights", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + + +@pytest.mark.usefixtures("ignore_frontend_deps") +async def test_update_panel_persists( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test that panel config is loaded from storage on startup.""" + hass_storage["frontend_panels"] = { + "key": "frontend_panels", + "version": 1, + "data": { + "light": { + "title": "Saved Lights", + "icon": "mdi:lamp", + "require_admin": True, + }, + }, + } + + assert await async_setup_component(hass, "frontend", {}) + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "get_panels"}) + msg = await client.receive_json() + assert msg["result"]["light"]["title"] == "Saved Lights" + assert msg["result"]["light"]["icon"] == "mdi:lamp" + assert msg["result"]["light"]["require_admin"] is True + + # Verify other panels still have defaults + assert msg["result"]["climate"]["title"] == "climate" + assert msg["result"]["climate"]["icon"] == "mdi:home-thermometer" + + +async def test_update_panel_reset_param( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test that setting a param to None resets it to the original value.""" + # First set a custom icon + await ws_client.send_json( + { + "id": 1, + "type": "frontend/update_panel", + "url_path": "security", + "icon": "mdi:shield", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + await ws_client.send_json({"id": 2, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["security"]["icon"] == "mdi:shield" + + # Reset icon by setting to None — should restore original + await ws_client.send_json( + { + "id": 3, + "type": "frontend/update_panel", + "url_path": "security", + "icon": None, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + await ws_client.send_json({"id": 4, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["security"]["icon"] == "mdi:security" + + +async def test_update_panel_hide_sidebar( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test that show_in_sidebar=false clears title and icon like lovelace.""" + # Verify initial state has title and icon + await ws_client.send_json({"id": 1, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["light"]["title"] == "light" + assert msg["result"]["light"]["icon"] == "mdi:lamps" + + # Hide from sidebar + await ws_client.send_json( + { + "id": 2, + "type": "frontend/update_panel", + "url_path": "light", + "show_in_sidebar": False, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Title and icon should be None + await ws_client.send_json({"id": 3, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["light"]["title"] is None + assert msg["result"]["light"]["icon"] is None + + # Show in sidebar again by resetting show_in_sidebar + await ws_client.send_json( + { + "id": 4, + "type": "frontend/update_panel", + "url_path": "light", + "show_in_sidebar": None, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Title and icon should be restored + await ws_client.send_json({"id": 5, "type": "get_panels"}) + msg = await ws_client.receive_json() + assert msg["result"]["light"]["title"] == "light" + assert msg["result"]["light"]["icon"] == "mdi:lamps" + + +async def test_panels_config_invalid_storage( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that corrupted panel storage is ignored with a warning.""" + hass_storage["frontend_panels"] = { + "key": "frontend_panels", + "version": 1, + "data": "not_a_dict", + } + + assert await async_setup_component(hass, "frontend", {}) + assert "Ignoring invalid panel storage data" in caplog.text + + client = await hass_ws_client(hass) + + # Panels should still load with defaults + await client.send_json({"id": 1, "type": "get_panels"}) + msg = await client.receive_json() + assert msg["result"]["light"]["title"] == "light" + assert msg["result"]["light"]["icon"] == "mdi:lamps"