Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions homeassistant/components/frontend/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@
],
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"preview_features": {
"winter_mode": {}
},
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260128.1"]
"requirements": ["home-assistant-frontend==20260128.2"]
}
65 changes: 43 additions & 22 deletions homeassistant/components/lovelace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CONF_ALLOW_SINGLE_WORD,
CONF_ICON,
CONF_REQUIRE_ADMIN,
CONF_RESOURCE_MODE,
CONF_SHOW_IN_SIDEBAR,
CONF_TITLE,
CONF_URL_PATH,
Expand Down Expand Up @@ -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="-")
Expand All @@ -84,9 +85,13 @@ def _validate_url_slug(value: Any) -> str:
{
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,
Expand All @@ -103,7 +108,7 @@ def _validate_url_slug(value: Any) -> str:
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]
Expand All @@ -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."""
Expand All @@ -149,12 +145,13 @@ async def reload_resources_service_handler(service_call: ServiceCall) -> None:
)
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(
Expand All @@ -177,8 +174,6 @@ async def reload_resources_service_handler(service_call: ServiceCall) -> None:
)

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"
Expand All @@ -195,18 +190,44 @@ async def reload_resources_service_handler(service_call: ServiceCall) -> None:
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:
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion homeassistant/components/lovelace/cast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/lovelace/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
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"
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/lovelace/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 1 addition & 3 deletions homeassistant/components/lovelace/system_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions homeassistant/components/lovelace/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
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:
Expand All @@ -38,7 +44,15 @@ async def send_with_error_handling(
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(
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ habluetooth==5.8.0
hass-nabucasa==1.12.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.1
home-assistant-frontend==20260128.2
home-assistant-intents==2026.1.28
httpx==0.28.1
ifaddr==0.2.0
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 45 additions & 1 deletion tests/components/lovelace/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
29 changes: 29 additions & 0 deletions tests/components/lovelace/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
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

Expand Down Expand Up @@ -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)
Loading
Loading