Compare commits

...

6 Commits

Author SHA1 Message Date
Paul Bottein
9dc11c1033 Handle invalid data 2026-02-11 12:13:38 +01:00
Paul Bottein
c61aa5770c Update homeassistant/components/frontend/__init__.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-11 12:08:40 +01:00
Paul Bottein
e0a32668db Register notfound panel 2026-02-11 10:17:04 +01:00
Paul Bottein
4790161617 Always load home panel 2026-02-11 09:49:24 +01:00
Paul Bottein
f34676785e Align with lovelace logic 2026-02-10 19:04:31 +01:00
Paul Bottein
1207ab3527 Add configurable panel properties to frontend 2026-02-10 18:59:37 +01:00
2 changed files with 332 additions and 7 deletions

View File

@@ -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"], "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 msg[key] is None:
panel_config.pop(key, None)
else:
panel_config[key] = msg[key]
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."""

View File

@@ -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"