Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Bottein
aa3c7578fc Add deprecation 2025-12-05 16:11:31 +01:00
Paul Bottein
4b619a5904 Migrate lovelace panel to dashboard 2025-12-05 16:07:46 +01:00
6 changed files with 277 additions and 62 deletions

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
"living_room": "Living Room"
},
"dashboard": {
"map": { "title": "Map" }
"map": { "title": "Map" },
"overview": { "title": "Overview" }
}
}

View File

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

View File

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