diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 4e9442801df..4d8ec18c992 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -33,6 +33,7 @@ from .const import ( # noqa: F401 CONF_ALLOW_SINGLE_WORD, CONF_ICON, CONF_REQUIRE_ADMIN, + CONF_RESOURCE_MODE, CONF_SHOW_IN_SIDEBAR, CONF_TITLE, CONF_URL_PATH, @@ -61,7 +62,7 @@ def _validate_url_slug(value: Any) -> str: """Validate value is a valid url slug.""" if value is None: raise vol.Invalid("Slug should not be None") - if "-" not in value: + if value != "lovelace" and "-" not in value: raise vol.Invalid("Url path needs to contain a hyphen (-)") str_value = str(value) slg = slugify(str_value, separator="-") @@ -84,9 +85,13 @@ CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default={}): vol.Schema( { + # Deprecated - Remove in 2026.8 vol.Optional(CONF_MODE, default=MODE_STORAGE): vol.All( vol.Lower, vol.In([MODE_YAML, MODE_STORAGE]) ), + vol.Optional(CONF_RESOURCE_MODE): vol.All( + vol.Lower, vol.In([MODE_YAML, MODE_STORAGE]) + ), vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys( YAML_DASHBOARD_SCHEMA, slug_validator=_validate_url_slug, @@ -103,7 +108,7 @@ CONFIG_SCHEMA = vol.Schema( class LovelaceData: """Dataclass to store information in hass.data.""" - mode: str + resource_mode: str # The mode used for resources (yaml or storage) dashboards: dict[str | None, dashboard.LovelaceConfig] resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection yaml_dashboards: dict[str | None, ConfigType] @@ -114,18 +119,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: mode = config[DOMAIN][CONF_MODE] yaml_resources = config[DOMAIN].get(CONF_RESOURCES) - # Deprecated - Remove in 2026.8 - # For YAML mode, register the default panel in yaml mode (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) + # resource_mode controls how resources are loaded (yaml vs storage) + # Deprecated - Remove mode fallback in 2026.8 + resource_mode = config[DOMAIN].get(CONF_RESOURCE_MODE, mode) async def reload_resources_service_handler(service_call: ServiceCall) -> None: """Reload yaml resources.""" @@ -149,12 +145,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data[LOVELACE_DATA].resources = resource_collection - default_config: dashboard.LovelaceConfig resource_collection: ( resources.ResourceYAMLCollection | resources.ResourceStorageCollection ) - if mode == MODE_YAML: - default_config = dashboard.LovelaceYAML(hass, None, None) + default_config = dashboard.LovelaceStorage(hass, None) + + # Load resources based on resource_mode + if resource_mode == MODE_YAML: resource_collection = await create_yaml_resource_col(hass, yaml_resources) async_register_admin_service( @@ -177,8 +174,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) else: - default_config = dashboard.LovelaceStorage(hass, None) - if yaml_resources is not None: _LOGGER.warning( "Lovelace is running in storage mode. Define resources via user" @@ -195,18 +190,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: RESOURCE_UPDATE_FIELDS, ).async_setup(hass) + websocket_api.async_register_command(hass, websocket.websocket_lovelace_info) websocket_api.async_register_command(hass, websocket.websocket_lovelace_config) websocket_api.async_register_command(hass, websocket.websocket_lovelace_save_config) websocket_api.async_register_command( hass, websocket.websocket_lovelace_delete_config ) + yaml_dashboards = config[DOMAIN].get(CONF_DASHBOARDS, {}) + + # Deprecated - Remove in 2026.8 + # For YAML mode, add the default "lovelace" dashboard if not already defined + # This migrates the legacy yaml mode to a proper yaml dashboard entry + if mode == MODE_YAML and DOMAIN not in yaml_dashboards: + translations = await async_get_translations( + hass, hass.config.language, "dashboard", {onboarding.DOMAIN} + ) + title = translations.get( + "component.onboarding.dashboard.overview.title", "Overview" + ) + yaml_dashboards = { + DOMAIN: { + CONF_TITLE: title, + CONF_ICON: DEFAULT_ICON, + CONF_SHOW_IN_SIDEBAR: True, + CONF_REQUIRE_ADMIN: False, + CONF_MODE: MODE_YAML, + CONF_FILENAME: LOVELACE_CONFIG_FILE, + }, + **yaml_dashboards, + } + _async_create_yaml_mode_repair(hass) + hass.data[LOVELACE_DATA] = LovelaceData( - mode=mode, + resource_mode=resource_mode, # We store a dictionary mapping url_path: config. None is the default. dashboards={None: default_config}, resources=resource_collection, - yaml_dashboards=config[DOMAIN].get(CONF_DASHBOARDS, {}), + yaml_dashboards=yaml_dashboards, ) if hass.config.recovery_mode: @@ -450,7 +471,7 @@ async def _async_migrate_default_config( # Deprecated - Remove in 2026.8 @callback def _async_create_yaml_mode_repair(hass: HomeAssistant) -> None: - """Create repair issue for YAML mode migration.""" + """Create repair issue for YAML mode deprecation.""" ir.async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index a22e94b9b1f..85c10e76cde 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -158,7 +158,15 @@ async def _get_dashboard_info( """Load a dashboard and return info on views.""" if url_path == DEFAULT_DASHBOARD: url_path = None - dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path) + + # When url_path is None, prefer "lovelace" dashboard if it exists (for YAML mode) + # Otherwise fall back to dashboards[None] (storage mode default) + if url_path is None: + dashboard = hass.data[LOVELACE_DATA].dashboards.get(DOMAIN) or hass.data[ + LOVELACE_DATA + ].dashboards.get(None) + else: + dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path) if dashboard is None: raise ValueError("Invalid dashboard specified") diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index ac1c9c5abff..1102aef02a8 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -57,6 +57,7 @@ RESOURCE_UPDATE_FIELDS: VolDictType = { SERVICE_RELOAD_RESOURCES = "reload_resources" RESOURCE_RELOAD_SERVICE_SCHEMA = vol.Schema({}) +CONF_RESOURCE_MODE = "resource_mode" CONF_TITLE = "title" CONF_REQUIRE_ADMIN = "require_admin" CONF_SHOW_IN_SIDEBAR = "show_in_sidebar" diff --git a/homeassistant/components/lovelace/strings.json b/homeassistant/components/lovelace/strings.json index edb574ffd78..60e939717ad 100644 --- a/homeassistant/components/lovelace/strings.json +++ b/homeassistant/components/lovelace/strings.json @@ -6,8 +6,8 @@ }, "issues": { "yaml_mode_deprecated": { - "description": "Starting with Home Assistant 2026.8, 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" + "description": "The `mode` option in `lovelace:` configuration is deprecated and will be removed in Home Assistant 2026.8.\n\nTo migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. If you have `resources:` declared in your lovelace configuration, add `resource_mode: yaml` to keep loading resources from YAML\n3. Add a dashboard entry in your `configuration.yaml`:\n\n ```yaml\n lovelace:\n resource_mode: yaml # Add this if you have resources declared\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n4. Restart Home Assistant", + "title": "Lovelace YAML mode deprecated" } }, "services": { diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index b629614d10d..6878a91c436 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -42,9 +42,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: else: health_info[key] = dashboard[key] - if hass.data[LOVELACE_DATA].mode == MODE_YAML: - health_info[CONF_MODE] = MODE_YAML - elif MODE_STORAGE in modes: + if MODE_STORAGE in modes: health_info[CONF_MODE] = MODE_STORAGE elif MODE_YAML in modes: health_info[CONF_MODE] = MODE_YAML diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 5feb7deb449..f8eb7772c78 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -14,7 +14,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_fragment -from .const import CONF_URL_PATH, LOVELACE_DATA, ConfigNotFound +from .const import ( + CONF_RESOURCE_MODE, + CONF_URL_PATH, + DOMAIN, + LOVELACE_DATA, + ConfigNotFound, +) from .dashboard import LovelaceConfig if TYPE_CHECKING: @@ -38,7 +44,15 @@ def _handle_errors[_R]( msg: dict[str, Any], ) -> None: url_path = msg.get(CONF_URL_PATH) - config = hass.data[LOVELACE_DATA].dashboards.get(url_path) + + # When url_path is None, prefer "lovelace" dashboard if it exists (for YAML mode) + # Otherwise fall back to dashboards[None] (storage mode default) + if url_path is None: + config = hass.data[LOVELACE_DATA].dashboards.get(DOMAIN) or hass.data[ + LOVELACE_DATA + ].dashboards.get(None) + else: + config = hass.data[LOVELACE_DATA].dashboards.get(url_path) if config is None: connection.send_error( @@ -100,6 +114,20 @@ async def websocket_lovelace_resources_impl( connection.send_result(msg["id"], resources.async_items()) +@websocket_api.websocket_command({"type": "lovelace/info"}) +@websocket_api.async_response +async def websocket_lovelace_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send Lovelace UI info over WebSocket connection.""" + connection.send_result( + msg["id"], + {CONF_RESOURCE_MODE: hass.data[LOVELACE_DATA].resource_mode}, + ) + + @websocket_api.websocket_command( { "type": "lovelace/config", diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 5679a8865bf..2aa88f6547b 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -368,7 +368,7 @@ async def test_lovelace_from_yaml_creates_repair_issue( """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 + # Panel should be registered as a YAML dashboard assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"} # Repair issue should be created @@ -803,3 +803,47 @@ async def test_lovelace_no_migration_no_default_panel_set( response = await client.receive_json() assert response["success"] assert response["result"]["value"] is None + + +async def test_lovelace_info_default( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test lovelace/info returns default resource_mode.""" + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "lovelace/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"resource_mode": "storage"} + + +async def test_lovelace_info_yaml_resource_mode( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test lovelace/info returns yaml resource_mode.""" + assert await async_setup_component( + hass, "lovelace", {"lovelace": {"resource_mode": "yaml"}} + ) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "lovelace/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"resource_mode": "yaml"} + + +async def test_lovelace_info_yaml_mode_fallback( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test lovelace/info returns yaml resource_mode when mode is yaml.""" + assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "yaml"}}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "lovelace/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"resource_mode": "yaml"} diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 14d93d8302f..14df32c21c2 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -5,7 +5,9 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest +import voluptuous as vol +from homeassistant.components.lovelace import _validate_url_slug from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -96,3 +98,30 @@ async def test_create_dashboards_when_not_onboarded( response = await client.receive_json() assert response["success"] assert response["result"] == {"strategy": {"type": "map"}} + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("lovelace", "lovelace"), + ("my-dashboard", "my-dashboard"), + ("my-cool-dashboard", "my-cool-dashboard"), + ], +) +def test_validate_url_slug_valid(value: str, expected: str) -> None: + """Test _validate_url_slug with valid values.""" + assert _validate_url_slug(value) == expected + + +@pytest.mark.parametrize( + ("value", "error_message"), + [ + (None, r"Slug should not be None"), + ("nodash", r"Url path needs to contain a hyphen \(-\)"), + ("my-dash board", r"invalid slug my-dash board \(try my-dash-board\)"), + ], +) +def test_validate_url_slug_invalid(value: Any, error_message: str) -> None: + """Test _validate_url_slug with invalid values.""" + with pytest.raises(vol.Invalid, match=error_message): + _validate_url_slug(value) diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index f05e948f41a..f467ef89a44 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -62,7 +62,8 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") - assert info == {"dashboards": 1, "mode": "yaml", "resources": 0, "views": 1} + # 2 dashboards: default storage (None) + yaml "lovelace" dashboard + assert info == {"dashboards": 2, "mode": "yaml", "resources": 0, "views": 1} async def test_system_health_info_yaml_not_found(hass: HomeAssistant) -> None: @@ -71,8 +72,9 @@ async def test_system_health_info_yaml_not_found(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() info = await get_system_health_info(hass, "lovelace") + # 2 dashboards: default storage (None) + yaml "lovelace" dashboard assert info == { - "dashboards": 1, + "dashboards": 2, "mode": "yaml", "error": f"{hass.config.path('ui-lovelace.yaml')} not found", "resources": 0,