mirror of
https://github.com/home-assistant/core.git
synced 2026-02-18 03:40:52 +00:00
Compare commits
6 Commits
dev
...
frontend_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc11c1033 | ||
|
|
c61aa5770c | ||
|
|
e0a32668db | ||
|
|
4790161617 | ||
|
|
f34676785e | ||
|
|
1207ab3527 |
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user