mirror of
https://github.com/home-assistant/core.git
synced 2025-12-06 16:08:09 +00:00
Compare commits
2 Commits
device_tra
...
lovelace_d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa3c7578fc | ||
|
|
4b619a5904 |
@@ -14,9 +14,14 @@ from homeassistant.config import (
|
||||
from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import collection, config_validation as cv
|
||||
from homeassistant.helpers import (
|
||||
collection,
|
||||
config_validation as cv,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.frame import report_usage
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
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
|
||||
@@ -34,6 +39,7 @@ from .const import ( # noqa: F401
|
||||
DEFAULT_ICON,
|
||||
DOMAIN,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
LOVELACE_CONFIG_FILE,
|
||||
LOVELACE_DATA,
|
||||
MODE_STORAGE,
|
||||
MODE_YAML,
|
||||
@@ -135,14 +141,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
mode = config[DOMAIN][CONF_MODE]
|
||||
yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
|
||||
|
||||
frontend.async_register_built_in_panel(
|
||||
hass,
|
||||
DOMAIN,
|
||||
config={"mode": mode},
|
||||
sidebar_title="overview",
|
||||
sidebar_icon="mdi:view-dashboard",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
# Deprecated - Remove in 2026.6
|
||||
# For YAML mode, register the default panel (temporary until user migrates)
|
||||
if mode == MODE_YAML:
|
||||
frontend.async_register_built_in_panel(
|
||||
hass,
|
||||
DOMAIN,
|
||||
config={"mode": mode},
|
||||
sidebar_title="overview",
|
||||
sidebar_icon="mdi:view-dashboard",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
_async_create_yaml_mode_repair(hass)
|
||||
# End deprecation
|
||||
|
||||
async def reload_resources_service_handler(service_call: ServiceCall) -> None:
|
||||
"""Reload yaml resources."""
|
||||
@@ -282,6 +293,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
dashboards_collection.async_add_listener(storage_dashboard_changed)
|
||||
await dashboards_collection.async_load()
|
||||
|
||||
# Migrate default lovelace panel to dashboard entry (storage mode only)
|
||||
if mode == MODE_STORAGE:
|
||||
await _async_migrate_default_config(hass, dashboards_collection)
|
||||
|
||||
dashboard.DashboardsCollectionWebSocket(
|
||||
dashboards_collection,
|
||||
"lovelace/dashboards",
|
||||
@@ -360,3 +375,94 @@ async def _create_map_dashboard(
|
||||
|
||||
map_store = hass.data[LOVELACE_DATA].dashboards["map"]
|
||||
await map_store.async_save({"strategy": {"type": "map"}})
|
||||
|
||||
|
||||
async def _async_migrate_default_config(
|
||||
hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection
|
||||
) -> None:
|
||||
"""Migrate default lovelace storage config to a named dashboard entry.
|
||||
|
||||
This migration:
|
||||
1. Checks if .storage/lovelace exists with data
|
||||
2. Checks if a dashboard with url_path "lovelace" already exists (skip if so)
|
||||
3. Checks if .storage/lovelace.lovelace already exists (skip if so - incomplete migration)
|
||||
4. Creates a new dashboard entry with url_path "lovelace"
|
||||
5. Copies data to .storage/lovelace.lovelace
|
||||
6. Removes old .storage/lovelace file
|
||||
"""
|
||||
# Check if already migrated (dashboard with url_path "lovelace" exists)
|
||||
for item in dashboards_collection.async_items():
|
||||
if item.get(CONF_URL_PATH) == DOMAIN:
|
||||
return
|
||||
|
||||
# Check if old storage data exists
|
||||
old_store = Store[dict[str, Any]](
|
||||
hass, dashboard.CONFIG_STORAGE_VERSION, dashboard.CONFIG_STORAGE_KEY_DEFAULT
|
||||
)
|
||||
old_data = await old_store.async_load()
|
||||
if old_data is None or old_data.get("config") is None:
|
||||
return
|
||||
|
||||
# Check if new storage data already exists (incomplete previous migration)
|
||||
new_store = Store[dict[str, Any]](
|
||||
hass,
|
||||
dashboard.CONFIG_STORAGE_VERSION,
|
||||
dashboard.CONFIG_STORAGE_KEY.format(DOMAIN),
|
||||
)
|
||||
new_data = await new_store.async_load()
|
||||
if new_data is not None:
|
||||
_LOGGER.warning(
|
||||
"Both old and new lovelace storage files exist, skipping migration"
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.info("Migrating default lovelace config to dashboard entry")
|
||||
|
||||
# Get translated title for the dashboard
|
||||
translations = await async_get_translations(
|
||||
hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
|
||||
)
|
||||
title = translations.get(
|
||||
"component.onboarding.dashboard.overview.title", "Overview"
|
||||
)
|
||||
|
||||
# Create dashboard entry
|
||||
try:
|
||||
await dashboards_collection.async_create_item(
|
||||
{
|
||||
CONF_ALLOW_SINGLE_WORD: True,
|
||||
CONF_ICON: DEFAULT_ICON,
|
||||
CONF_TITLE: title,
|
||||
CONF_URL_PATH: DOMAIN,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to create dashboard entry during migration")
|
||||
return
|
||||
|
||||
# Save data to new location
|
||||
await new_store.async_save(old_data)
|
||||
|
||||
# Remove old file
|
||||
await old_store.async_remove()
|
||||
|
||||
_LOGGER.info("Successfully migrated default lovelace config to dashboard entry")
|
||||
|
||||
|
||||
# Deprecated - Remove in 2026.6
|
||||
@callback
|
||||
def _async_create_yaml_mode_repair(hass: HomeAssistant) -> None:
|
||||
"""Create repair issue for YAML mode migration."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"yaml_mode_deprecated",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="yaml_mode_deprecated",
|
||||
translation_placeholders={"config_file": LOVELACE_CONFIG_FILE},
|
||||
)
|
||||
|
||||
|
||||
# End deprecation
|
||||
|
||||
@@ -286,7 +286,7 @@ class DashboardsCollection(collection.DictStorageCollection):
|
||||
if not allow_single_word and "-" not in url_path:
|
||||
raise vol.Invalid("Url path needs to contain a hyphen (-)")
|
||||
|
||||
if url_path in self.hass.data[DATA_PANELS]:
|
||||
if DATA_PANELS in self.hass.data and url_path in self.hass.data[DATA_PANELS]:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="url_already_exists",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"message": "The URL \"{url}\" is already in use. Please choose a different one."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"yaml_mode_deprecated": {
|
||||
"description": "Starting with Home Assistant 2026.6, the default Lovelace dashboard will no longer support YAML mode. To migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Rename `{config_file}` to a new filename (e.g., `my-dashboard.yaml`)\n3. Add a dashboard entry in your `configuration.yaml`:\n\n```yaml\nlovelace:\n dashboards:\n lovelace:\n mode: yaml\n filename: my-dashboard.yaml\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode migration required"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload_resources": {
|
||||
"description": "Reloads dashboard resources from the YAML-configuration.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"living_room": "Living Room"
|
||||
},
|
||||
"dashboard": {
|
||||
"map": { "title": "Map" }
|
||||
"map": { "title": "Map" },
|
||||
"overview": { "title": "Overview" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.lovelace import const, dashboard
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import assert_setup_component, async_capture_events
|
||||
@@ -29,111 +30,190 @@ def mock_onboarding_done() -> Generator[MagicMock]:
|
||||
yield mock_onboarding
|
||||
|
||||
|
||||
async def test_lovelace_from_storage(
|
||||
async def test_lovelace_from_storage_new_installation(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test we load lovelace config from storage."""
|
||||
"""Test new installation has no default lovelace panel."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
|
||||
# No default lovelace panel for new installations
|
||||
assert "lovelace" not in hass.data.get(frontend.DATA_PANELS, {})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Dashboards list should be empty
|
||||
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == []
|
||||
|
||||
|
||||
async def test_lovelace_from_storage_migration(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test we migrate existing lovelace config from storage to dashboard."""
|
||||
# Pre-populate storage with existing lovelace config
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"version": 1,
|
||||
"key": dashboard.CONFIG_STORAGE_KEY_DEFAULT,
|
||||
"data": {"config": {"views": [{"title": "Home"}]}},
|
||||
}
|
||||
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
|
||||
# After migration, lovelace panel should be registered as a dashboard
|
||||
assert "lovelace" in hass.data[frontend.DATA_PANELS]
|
||||
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/config"})
|
||||
# Dashboard should be in the list
|
||||
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "config_not_found"
|
||||
assert response["success"]
|
||||
assert len(response["result"]) == 1
|
||||
assert response["result"][0]["url_path"] == "lovelace"
|
||||
assert response["result"][0]["title"] == "Overview"
|
||||
|
||||
# Fetch migrated config
|
||||
await client.send_json({"id": 6, "type": "lovelace/config", "url_path": "lovelace"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"views": [{"title": "Home"}]}
|
||||
|
||||
# Old storage key should be gone, new one should exist
|
||||
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
|
||||
assert dashboard.CONFIG_STORAGE_KEY.format("lovelace") in hass_storage
|
||||
|
||||
# Store new config
|
||||
events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
|
||||
|
||||
await client.send_json(
|
||||
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
|
||||
{
|
||||
"id": 7,
|
||||
"type": "lovelace/config/save",
|
||||
"url_path": "lovelace",
|
||||
"config": {"yo": "hello"},
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format("lovelace")]["data"] == {
|
||||
"config": {"yo": "hello"}
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
# Load new config
|
||||
await client.send_json({"id": 7, "type": "lovelace/config"})
|
||||
await client.send_json({"id": 8, "type": "lovelace/config", "url_path": "lovelace"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
assert response["result"] == {"yo": "hello"}
|
||||
|
||||
# Test with recovery mode
|
||||
hass.config.recovery_mode = True
|
||||
await client.send_json({"id": 8, "type": "lovelace/config"})
|
||||
await client.send_json({"id": 9, "type": "lovelace/config", "url_path": "lovelace"})
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "config_not_found"
|
||||
|
||||
await client.send_json(
|
||||
{"id": 9, "type": "lovelace/config/save", "config": {"yo": "hello"}}
|
||||
{
|
||||
"id": 10,
|
||||
"type": "lovelace/config/save",
|
||||
"url_path": "lovelace",
|
||||
"config": {"yo": "hello"},
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
|
||||
await client.send_json({"id": 10, "type": "lovelace/config/delete"})
|
||||
await client.send_json(
|
||||
{"id": 11, "type": "lovelace/config/delete", "url_path": "lovelace"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
|
||||
|
||||
async def test_lovelace_from_storage_save_before_load(
|
||||
async def test_lovelace_migration_skipped_when_both_files_exist(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test we can load lovelace config from storage."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Store new config
|
||||
await client.send_json(
|
||||
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": {"yo": "hello"}
|
||||
"""Test migration is skipped when both old and new storage files exist."""
|
||||
# Pre-populate both old and new storage (simulating incomplete migration)
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"version": 1,
|
||||
"key": dashboard.CONFIG_STORAGE_KEY_DEFAULT,
|
||||
"data": {"config": {"views": [{"title": "Old"}]}},
|
||||
}
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY.format("lovelace")] = {
|
||||
"version": 1,
|
||||
"key": dashboard.CONFIG_STORAGE_KEY.format("lovelace"),
|
||||
"data": {"config": {"views": [{"title": "New"}]}},
|
||||
}
|
||||
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
|
||||
async def test_lovelace_from_storage_delete(
|
||||
# No dashboard should be created (migration skipped)
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == []
|
||||
|
||||
|
||||
async def test_lovelace_migration_skipped_when_already_migrated(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test we delete lovelace config from storage."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Store new config
|
||||
await client.send_json(
|
||||
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": {"yo": "hello"}
|
||||
"""Test migration is skipped when dashboard already exists."""
|
||||
# Pre-populate dashboards with existing lovelace dashboard
|
||||
hass_storage[dashboard.DASHBOARDS_STORAGE_KEY] = {
|
||||
"version": 1,
|
||||
"key": dashboard.DASHBOARDS_STORAGE_KEY,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "lovelace",
|
||||
"url_path": "lovelace",
|
||||
"title": "Overview",
|
||||
"icon": "mdi:view-dashboard",
|
||||
"show_in_sidebar": True,
|
||||
"require_admin": False,
|
||||
"mode": "storage",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY.format("lovelace")] = {
|
||||
"version": 1,
|
||||
"key": dashboard.CONFIG_STORAGE_KEY.format("lovelace"),
|
||||
"data": {"config": {"views": [{"title": "Home"}]}},
|
||||
}
|
||||
# Also have old file (should be ignored since dashboard exists)
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"version": 1,
|
||||
"key": dashboard.CONFIG_STORAGE_KEY_DEFAULT,
|
||||
"data": {"config": {"views": [{"title": "Old"}]}},
|
||||
}
|
||||
|
||||
# Delete config
|
||||
await client.send_json({"id": 7, "type": "lovelace/config/delete"})
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
|
||||
# Only the pre-existing dashboard, no duplicate
|
||||
assert len(response["result"]) == 1
|
||||
assert response["result"][0]["url_path"] == "lovelace"
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 8, "type": "lovelace/config"})
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "config_not_found"
|
||||
# Old storage should still exist (not touched)
|
||||
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT in hass_storage
|
||||
|
||||
|
||||
async def test_lovelace_from_yaml(
|
||||
@@ -226,6 +306,24 @@ async def test_lovelace_from_yaml(
|
||||
assert len(events) == 2
|
||||
|
||||
|
||||
async def test_lovelace_from_yaml_creates_repair_issue(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test YAML mode creates a repair issue."""
|
||||
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
|
||||
|
||||
# Panel should still be registered for backwards compatibility
|
||||
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"}
|
||||
|
||||
# Repair issue should be created with 6-month deadline
|
||||
issue_registry = ir.async_get(hass)
|
||||
issue = issue_registry.async_get_issue("lovelace", "yaml_mode_deprecated")
|
||||
assert issue is not None
|
||||
assert issue.severity == ir.IssueSeverity.WARNING
|
||||
assert issue.is_fixable is False
|
||||
assert issue.breaks_in_ha_version == "2026.6.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url_path", ["test-panel", "test-panel-no-sidebar"])
|
||||
async def test_dashboard_from_yaml(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, url_path
|
||||
@@ -364,7 +462,9 @@ async def test_storage_dashboards(
|
||||
) -> None:
|
||||
"""Test we load lovelace config from storage."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
|
||||
|
||||
# New installations don't have default lovelace panel
|
||||
assert "lovelace" not in hass.data.get(frontend.DATA_PANELS, {})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
|
||||
@@ -34,11 +34,12 @@ async def test_system_health_info_autogen(hass: HomeAssistant) -> None:
|
||||
assert info == {"dashboards": 1, "mode": "auto-gen", "resources": 0}
|
||||
|
||||
|
||||
async def test_system_health_info_storage(
|
||||
async def test_system_health_info_storage_migration(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test system health info endpoint."""
|
||||
"""Test system health info endpoint after migration from old storage."""
|
||||
assert await async_setup_component(hass, "system_health", {})
|
||||
# Pre-populate old storage format (triggers migration)
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"key": "lovelace",
|
||||
"version": 1,
|
||||
@@ -47,7 +48,8 @@ async def test_system_health_info_storage(
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
await hass.async_block_till_done()
|
||||
info = await get_system_health_info(hass, "lovelace")
|
||||
assert info == {"dashboards": 1, "mode": "storage", "resources": 0, "views": 0}
|
||||
# After migration: default dashboard (auto-gen) + migrated "lovelace" dashboard (storage with data)
|
||||
assert info == {"dashboards": 2, "mode": "storage", "resources": 0, "views": 0}
|
||||
|
||||
|
||||
async def test_system_health_info_yaml(hass: HomeAssistant) -> None:
|
||||
|
||||
Reference in New Issue
Block a user