Skip to content
2 changes: 2 additions & 0 deletions CODEOWNERS

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

2 changes: 1 addition & 1 deletion homeassistant/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
COOLDOWN_TIME = 60

# Core integrations are unconditionally loaded
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification", "labs"}
Comment thread
frenck marked this conversation as resolved.
Outdated

# Integrations that are loaded right after the core is set up
LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
Expand Down
47 changes: 45 additions & 2 deletions homeassistant/components/kitchen_sink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

import voluptuous as vol

from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
async_is_experimental_feature_enabled,
)
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import (
StatisticData,
Expand All @@ -30,10 +34,14 @@
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
Expand Down Expand Up @@ -110,6 +118,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)

# Subscribe to labs feature updates for kitchen_sink experimental repair
@callback
def _async_labs_updated(event: Event) -> None:
"""Handle labs feature update event."""
if event.data.get("feature_id") == "kitchen_sink.special_repair":
_async_update_special_repair(hass)

entry.async_on_unload(
hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated)
)

# Check if lab feature is currently enabled and create repair if so
_async_update_special_repair(hass)

return True


Expand Down Expand Up @@ -137,6 +159,27 @@ async def async_remove_config_entry_device(
return True


@callback
def _async_update_special_repair(hass: HomeAssistant) -> None:
"""Create or delete the special repair issue.

Creates a repair issue when the special_repair lab feature is enabled,
and deletes it when disabled. This demonstrates how lab features can interact
with Home Assistant's repair system.
"""
if async_is_experimental_feature_enabled(hass, DOMAIN, "special_repair"):
async_create_issue(
hass,
DOMAIN,
"kitchen_sink_special_repair_issue",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="special_repair",
)
else:
async_delete_issue(hass, DOMAIN, "kitchen_sink_special_repair_issue")


async def _notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/kitchen_sink/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/kitchen_sink",
"iot_class": "calculated",
"labs_features": {
"special_repair": {
"feedback_url": "https://community.home-assistant.io",
"learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink",
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink"
}
},
"quality_scale": "internal",
"single_config_entry": true
}
10 changes: 10 additions & 0 deletions homeassistant/components/kitchen_sink/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
},
"title": "The blinker fluid is empty and needs to be refilled"
},
"special_repair": {
"description": "This is a special repair created by a preview feature! This demonstrates how lab features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.",
"title": "Special repair feature preview"
},
"transmogrifier_deprecated": {
"description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API",
"title": "The transmogrifier component is deprecated"
Expand All @@ -80,6 +84,12 @@
"title": "This is not a fixable problem"
}
},
"labs_features": {
"special_repair": {
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.",
"name": "Special repair"
}
},
"options": {
"step": {
"init": {
Expand Down
253 changes: 253 additions & 0 deletions homeassistant/components/labs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
"""The Home Assistant Labs integration.

This integration provides experimental features that can be toggled on/off by users.
Integrations can register lab features in their manifest.json which will appear
in the Home Assistant Labs UI for users to enable or disable.
"""

from __future__ import annotations

import logging
from typing import Any

import voluptuous as vol

from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.generated.labs import LABS_FEATURES
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_custom_components

from .const import (
DOMAIN,
EVENT_LABS_UPDATED,
LABS_DATA,
STORAGE_KEY,
STORAGE_VERSION,
LabFeature,
LabsData,
LabsStoreData,
)

_LOGGER = logging.getLogger(__name__)

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

__all__ = [
"DOMAIN",
Comment thread
frenck marked this conversation as resolved.
Outdated
"EVENT_LABS_UPDATED",
"async_is_experimental_feature_enabled",
"async_setup",
Comment thread
frenck marked this conversation as resolved.
Outdated
]


async def _async_scan_all_lab_features(hass: HomeAssistant) -> dict[str, LabFeature]:
Comment thread
frenck marked this conversation as resolved.
Outdated
"""Scan ALL available integrations for lab features (loaded or not)."""
features: dict[str, LabFeature] = {}

# Load pre-generated built-in labs features
def _load_builtin_features() -> dict[str, LabFeature]:
"""Load built-in labs features from pre-generated data."""
from homeassistant import components # noqa: PLC0415

builtin_features: dict[str, LabFeature] = {}

# Iterate through pre-generated LABS_FEATURES
for domain, feature_ids in LABS_FEATURES.items():
try:
integration = Integration.resolve_from_root(hass, components, domain)
if not integration:
continue

labs_features = integration.manifest.get("labs_features", {})

for feature_id in feature_ids:
if (
not isinstance(labs_features, dict)
or feature_id not in labs_features
):
continue

feature_data = labs_features[feature_id]
if not isinstance(feature_data, dict):
continue

feature = LabFeature(
domain=domain,
feature=feature_id,
feedback_url=feature_data.get("feedback_url"),
learn_more_url=feature_data.get("learn_more_url"),
report_issue_url=feature_data.get("report_issue_url"),
)
builtin_features[feature.full_key] = feature

except Exception: # noqa: BLE001
_LOGGER.debug("Error loading integration %s for labs features", domain)
continue
Comment thread
frenck marked this conversation as resolved.
Outdated

return builtin_features

# Load built-in features in executor (only loads manifests for integrations with features)
builtin_features = await hass.async_add_executor_job(_load_builtin_features)
features.update(builtin_features)

# Scan custom components
custom_integrations = await async_get_custom_components(hass)
_LOGGER.debug(
"Loaded %d built-in + scanning %d custom integrations for lab features",
len(builtin_features),
len(custom_integrations),
)

for integration in custom_integrations.values():
labs_features = integration.manifest.get("labs_features")
if not labs_features or not isinstance(labs_features, dict):
Comment thread
frenck marked this conversation as resolved.
Outdated
continue

# labs_features is a dict[str, dict[str, str]]
for feature_key, feature_data in labs_features.items():
Comment thread
frenck marked this conversation as resolved.
Outdated
if not isinstance(feature_data, dict):
continue
feature = LabFeature(
domain=integration.domain,
feature=feature_key,
feedback_url=feature_data.get("feedback_url"),
learn_more_url=feature_data.get("learn_more_url"),
report_issue_url=feature_data.get("report_issue_url"),
)
features[feature.full_key] = feature

_LOGGER.debug("Loaded %d total lab features", len(features))
return features


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Labs component."""
store = Store[LabsStoreData](hass, STORAGE_VERSION, STORAGE_KEY, private=True)
data = await store.async_load()

if data is None:
data = {"features": {}}
Comment thread
frenck marked this conversation as resolved.
Outdated

# Scan ALL integrations for lab features (loaded or not)
lab_features = await _async_scan_all_lab_features(hass)

# Clean up features that no longer exist
if lab_features:
current_features = set(data["features"].keys())
valid_features = set(lab_features.keys())
Comment thread
frenck marked this conversation as resolved.
Outdated
stale_features = current_features - valid_features

if stale_features:
_LOGGER.debug(
"Removing %d stale lab features: %s",
len(stale_features),
stale_features,
)
for feature_id in stale_features:
del data["features"][feature_id]
await store.async_save(data)

hass.data[LABS_DATA] = LabsData(
store=store,
data=data,
features=lab_features,
)

websocket_api.async_register_command(hass, websocket_list_features)
websocket_api.async_register_command(hass, websocket_update_feature)

return True


@callback
def async_is_experimental_feature_enabled(
hass: HomeAssistant, domain: str, feature: str
) -> bool:
"""Check if an experimental lab feature is enabled.

Args:
hass: HomeAssistant instance
domain: Integration domain
feature: Feature name

Returns:
True if the feature is enabled, False otherwise
"""
if LABS_DATA not in hass.data:
return False

labs_data = hass.data[LABS_DATA]
return labs_data.data["features"].get(f"{domain}.{feature}", False)


@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
def websocket_list_features(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List all lab features filtered by loaded integrations."""
labs_data = hass.data[LABS_DATA]
loaded_components = hass.config.components

features: list[dict[str, Any]] = [
{
"feature": feature.feature,
Comment thread
frenck marked this conversation as resolved.
Outdated
"domain": feature.domain,
"enabled": labs_data.data["features"].get(feature_key, False),
"feedback_url": feature.feedback_url,
"learn_more_url": feature.learn_more_url,
"report_issue_url": feature.report_issue_url,
}
for feature_key, feature in labs_data.features.items()
if feature.domain in loaded_components
]

connection.send_result(msg["id"], {"features": features})


@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "labs/update",
vol.Required("feature_id"): str,
vol.Required("enabled"): bool,
}
)
@websocket_api.async_response
async def websocket_update_feature(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update a lab feature state."""
feature_id = msg["feature_id"]
enabled = msg["enabled"]

labs_data = hass.data[LABS_DATA]

# Validate feature exists
if feature_id not in labs_data.features:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
f"Feature {feature_id} not found",
)
return

# Update storage
labs_data.data["features"][feature_id] = enabled
await labs_data.store.async_save(labs_data.data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the delayed save feature on the Store class async_delay_save? The user could click around causing multiple calls to this function in short succession.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've considered. It doesn't change as much + there is a process involved of confirmation and possible backups. We don't have many options and quick toggles anymore.

However, integration can respond to a change, so saving the state directly might avoid things like migrations issues or any other changes integration make on turning on/off the labs feature.

I've therefore opted to leave it as an immediate save.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would the migration issues or other issues be? That would be a bug in the base storage class.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not about the migration of the storage of Labs itself, it is about the integration that can do thing based on the change of this flag. It thus may have repercussions beyond this integration.

@MartinHjelmare MartinHjelmare Nov 21, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this has to do with the Store. The Store is used to save data on file. The source of truth during runtime is the labs_data.data attribute.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, but it are "experimental" features one is enabling; I feel that a direct write is more warranted, as we don't know or control what is happening next as a result out of this. It might be a full crash, migrations, many other things.

Besides that, considering this is not a high volume feature (at least not right now) and not a usual toggle to make (need confirmation, backups completion in between) it is less likely to need debouncing.


# Fire event
hass.bus.async_fire(
EVENT_LABS_UPDATED,
{"feature_id": feature_id, "enabled": enabled},
Comment thread
frenck marked this conversation as resolved.
Outdated
)

connection.send_result(msg["id"])
Loading
Loading