Compare commits

...

2 Commits

Author SHA1 Message Date
J. Nick Koston
e572f8d48f Fix Shelly Bluetooth discovery for Gen3/Gen4 devices without advertised names (#156883) 2025-11-20 15:11:17 -06:00
Franck Nijhof
482b5d49a3 Introduce Home Assistant Labs (#156840) 2025-11-20 21:22:37 +01:00
26 changed files with 1971 additions and 7 deletions

2
CODEOWNERS generated
View File

@@ -848,6 +848,8 @@ build.json @home-assistant/supervisor
/tests/components/kraken/ @eifinger
/homeassistant/components/kulersky/ @emlove
/tests/components/kulersky/ @emlove
/homeassistant/components/labs/ @home-assistant/core
/tests/components/labs/ @home-assistant/core
/homeassistant/components/lacrosse_view/ @IceBotYT
/tests/components/lacrosse_view/ @IceBotYT
/homeassistant/components/lamarzocco/ @zweckj

View File

@@ -176,6 +176,8 @@ FRONTEND_INTEGRATIONS = {
STAGE_0_INTEGRATIONS = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
# Setup labs for preview features
("labs", {"labs"}, STAGE_0_SUBSTAGE_TIMEOUT),
# Setup frontend
("frontend", FRONTEND_INTEGRATIONS, None),
# Setup recorder
@@ -212,6 +214,7 @@ DEFAULT_INTEGRATIONS = {
"backup",
"frontend",
"hardware",
"labs",
"logger",
"network",
"system_health",

View File

@@ -11,6 +11,11 @@ from random import random
import voluptuous as vol
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
EventLabsUpdatedData,
async_is_preview_feature_enabled,
)
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import (
StatisticData,
@@ -30,10 +35,14 @@ from homeassistant.const import (
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 (
@@ -110,6 +119,23 @@ 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 preview repair
@callback
def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == "kitchen_sink"
and event.data["preview_feature"] == "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
@@ -137,6 +163,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_preview_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()

View File

@@ -5,6 +5,13 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/kitchen_sink",
"iot_class": "calculated",
"preview_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
}

View File

@@ -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"
@@ -103,6 +107,14 @@
}
}
},
"preview_features": {
"special_repair": {
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.",
"disable_confirmation": "This will remove the special repair issue. Don't worry, this is just a demonstration feature.",
"enable_confirmation": "This will create a special repair issue to demonstrate Labs preview features. This is just an example and won't affect your actual system.",
"name": "Special repair"
}
},
"services": {
"test_service_1": {
"description": "Fake action for testing",

View File

@@ -0,0 +1,310 @@
"""The Home Assistant Labs integration.
This integration provides preview features that can be toggled on/off by users.
Integrations can register lab preview 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.components.backup import async_get_manager
from homeassistant.core import HomeAssistant, callback
from homeassistant.generated.labs import LABS_PREVIEW_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 async_get_custom_components
from .const import (
DOMAIN,
EVENT_LABS_UPDATED,
LABS_DATA,
STORAGE_KEY,
STORAGE_VERSION,
EventLabsUpdatedData,
LabPreviewFeature,
LabsData,
LabsStoreData,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
__all__ = [
"EVENT_LABS_UPDATED",
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
]
class LabsStorage(Store[LabsStoreData]):
"""Custom Store for Labs that converts between runtime and storage formats.
Runtime format: {"preview_feature_status": {(domain, preview_feature)}}
Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]}
Only enabled features are saved to storage - if stored, it's enabled.
"""
async def _async_load_data(self) -> LabsStoreData | None:
"""Load data and convert from storage format to runtime format."""
raw_data = await super()._async_load_data()
if raw_data is None:
return None
status_list = raw_data.get("preview_feature_status", [])
# Convert list of objects to runtime set - if stored, it's enabled
return {
"preview_feature_status": {
(item["domain"], item["preview_feature"]) for item in status_list
}
}
def _write_data(self, path: str, data: dict) -> None:
"""Convert from runtime format to storage format and write.
Only saves enabled features - disabled is the default.
"""
# Extract the actual data (has version/key wrapper)
actual_data = data.get("data", data)
# Check if this is Labs data (has preview_feature_status key)
if "preview_feature_status" not in actual_data:
# Not Labs data, write as-is
super()._write_data(path, data)
return
preview_status = actual_data["preview_feature_status"]
# Convert from runtime format (set of tuples) to storage format (list of dicts)
status_list = [
{"domain": domain, "preview_feature": preview_feature}
for domain, preview_feature in preview_status
]
# Build the final data structure with converted format
data_copy = data.copy()
data_copy["data"] = {"preview_feature_status": status_list}
super()._write_data(path, data_copy)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Labs component."""
store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
data = await store.async_load()
if data is None:
data = {"preview_feature_status": set()}
# Scan ALL integrations for lab preview features (loaded or not)
lab_preview_features = await _async_scan_all_preview_features(hass)
# Clean up preview features that no longer exist
if lab_preview_features:
valid_keys = {
(pf.domain, pf.preview_feature) for pf in lab_preview_features.values()
}
stale_keys = data["preview_feature_status"] - valid_keys
if stale_keys:
_LOGGER.debug(
"Removing %d stale preview features: %s",
len(stale_keys),
stale_keys,
)
data["preview_feature_status"] -= stale_keys
await store.async_save(data)
hass.data[LABS_DATA] = LabsData(
store=store,
data=data,
preview_features=lab_preview_features,
)
websocket_api.async_register_command(hass, websocket_list_preview_features)
websocket_api.async_register_command(hass, websocket_update_preview_feature)
return True
def _populate_preview_features(
preview_features: dict[str, LabPreviewFeature],
domain: str,
labs_preview_features: dict[str, dict[str, str]],
is_built_in: bool = True,
) -> None:
"""Populate preview features dictionary from integration preview_features.
Args:
preview_features: Dictionary to populate
domain: Integration domain
labs_preview_features: Dictionary of preview feature definitions from manifest
is_built_in: Whether this is a built-in integration
"""
for preview_feature_key, preview_feature_data in labs_preview_features.items():
preview_feature = LabPreviewFeature(
domain=domain,
preview_feature=preview_feature_key,
is_built_in=is_built_in,
feedback_url=preview_feature_data.get("feedback_url"),
learn_more_url=preview_feature_data.get("learn_more_url"),
report_issue_url=preview_feature_data.get("report_issue_url"),
)
preview_features[preview_feature.full_key] = preview_feature
async def _async_scan_all_preview_features(
hass: HomeAssistant,
) -> dict[str, LabPreviewFeature]:
"""Scan ALL available integrations for lab preview features (loaded or not)."""
preview_features: dict[str, LabPreviewFeature] = {}
# Load pre-generated built-in lab preview features (already includes all data)
for domain, domain_preview_features in LABS_PREVIEW_FEATURES.items():
_populate_preview_features(
preview_features, domain, domain_preview_features, is_built_in=True
)
# Scan custom components
custom_integrations = await async_get_custom_components(hass)
_LOGGER.debug(
"Loaded %d built-in + scanning %d custom integrations for lab preview features",
len(preview_features),
len(custom_integrations),
)
for integration in custom_integrations.values():
if labs_preview_features := integration.preview_features:
_populate_preview_features(
preview_features,
integration.domain,
labs_preview_features,
is_built_in=False,
)
_LOGGER.debug("Loaded %d total lab preview features", len(preview_features))
return preview_features
@callback
def async_is_preview_feature_enabled(
hass: HomeAssistant, domain: str, preview_feature: str
) -> bool:
"""Check if a lab preview feature is enabled.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
Returns:
True if the preview feature is enabled, False otherwise
"""
if LABS_DATA not in hass.data:
return False
labs_data = hass.data[LABS_DATA]
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
def websocket_list_preview_features(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List all lab preview features filtered by loaded integrations."""
labs_data = hass.data[LABS_DATA]
loaded_components = hass.config.components
preview_features: list[dict[str, Any]] = [
preview_feature.to_dict(
(preview_feature.domain, preview_feature.preview_feature)
in labs_data.data["preview_feature_status"]
)
for preview_feature_key, preview_feature in labs_data.preview_features.items()
if preview_feature.domain in loaded_components
]
connection.send_result(msg["id"], {"features": preview_features})
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "labs/update",
vol.Required("domain"): str,
vol.Required("preview_feature"): str,
vol.Required("enabled"): bool,
vol.Optional("create_backup", default=False): bool,
}
)
@websocket_api.async_response
async def websocket_update_preview_feature(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update a lab preview feature state."""
domain = msg["domain"]
preview_feature = msg["preview_feature"]
enabled = msg["enabled"]
create_backup = msg["create_backup"]
labs_data = hass.data[LABS_DATA]
# Build preview_feature_id for lookup
preview_feature_id = f"{domain}.{preview_feature}"
# Validate preview feature exists
if preview_feature_id not in labs_data.preview_features:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
f"Preview feature {preview_feature_id} not found",
)
return
# Create backup if requested and enabling
if create_backup and enabled:
try:
backup_manager = async_get_manager(hass)
await backup_manager.async_create_automatic_backup()
except Exception as err: # noqa: BLE001 - websocket handlers can catch broad exceptions
connection.send_error(
msg["id"],
websocket_api.ERR_UNKNOWN_ERROR,
f"Error creating backup: {err}",
)
return
# Update storage (only store enabled features, remove if disabled)
if enabled:
labs_data.data["preview_feature_status"].add((domain, preview_feature))
else:
labs_data.data["preview_feature_status"].discard((domain, preview_feature))
# Save changes immediately
await labs_data.store.async_save(labs_data.data)
# Fire event
event_data: EventLabsUpdatedData = {
"domain": domain,
"preview_feature": preview_feature,
"enabled": enabled,
}
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
connection.send_result(msg["id"])

View File

@@ -0,0 +1,77 @@
"""Constants for the Home Assistant Labs integration."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypedDict
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.helpers.storage import Store
DOMAIN = "labs"
STORAGE_KEY = "core.labs"
STORAGE_VERSION = 1
EVENT_LABS_UPDATED = "labs_updated"
class EventLabsUpdatedData(TypedDict):
"""Event data for labs_updated event."""
domain: str
preview_feature: str
enabled: bool
@dataclass(frozen=True, kw_only=True, slots=True)
class LabPreviewFeature:
"""Lab preview feature definition."""
domain: str
preview_feature: str
is_built_in: bool = True
feedback_url: str | None = None
learn_more_url: str | None = None
report_issue_url: str | None = None
@property
def full_key(self) -> str:
"""Return the full key for the preview feature (domain.preview_feature)."""
return f"{self.domain}.{self.preview_feature}"
def to_dict(self, enabled: bool) -> dict[str, str | bool | None]:
"""Return a serialized version of the preview feature.
Args:
enabled: Whether the preview feature is currently enabled
Returns:
Dictionary with preview feature data including enabled status
"""
return {
"preview_feature": self.preview_feature,
"domain": self.domain,
"enabled": enabled,
"is_built_in": self.is_built_in,
"feedback_url": self.feedback_url,
"learn_more_url": self.learn_more_url,
"report_issue_url": self.report_issue_url,
}
type LabsStoreData = dict[str, set[tuple[str, str]]]
@dataclass
class LabsData:
"""Storage class for Labs global data."""
store: Store[LabsStoreData]
data: LabsStoreData
preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict)
LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN)

View File

@@ -0,0 +1,9 @@
{
"domain": "labs",
"name": "Home Assistant Labs",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/labs",
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,3 @@
{
"title": "Home Assistant Labs"
}

View File

@@ -7,7 +7,11 @@ from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Final
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
from aioshelly.ble import get_name_from_model_id
from aioshelly.ble.manufacturer_data import (
has_rpc_over_ble,
parse_shelly_manufacturer_data,
)
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
@@ -358,8 +362,35 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle bluetooth discovery."""
# Parse MAC address from the Bluetooth device name
if not (mac := mac_address_from_name(discovery_info.name)):
# Try to parse MAC address from the Bluetooth device name
# If not found, try to get it from manufacturer data
device_name = discovery_info.name
if (
not (mac := mac_address_from_name(device_name))
and (
parsed := parse_shelly_manufacturer_data(
discovery_info.manufacturer_data
)
)
and (mac_with_colons := parsed.get("mac"))
and isinstance(mac_with_colons, str)
):
# parse_shelly_manufacturer_data returns MAC with colons (e.g., "CC:BA:97:C2:D6:72")
# Convert to format without colons to match mac_address_from_name output
mac = mac_with_colons.replace(":", "")
# For devices without a Shelly name, use model name from model ID if available
# Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR"
if (
(model_id := parsed.get("model_id"))
and isinstance(model_id, int)
and (model_name := get_name_from_model_id(model_id))
):
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
device_name = f"{model_name.replace(' ', '')}-{mac}"
else:
device_name = f"Shelly-{mac}"
if not mac:
return self.async_abort(reason="invalid_discovery_info")
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
@@ -381,10 +412,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if not self.ble_device:
return self.async_abort(reason="cannot_connect")
self.device_name = discovery_info.name
self.device_name = device_name
self.context.update(
{
"title_placeholders": {"name": discovery_info.name},
"title_placeholders": {"name": device_name},
}
)

View File

@@ -4,6 +4,9 @@
"bluetooth": [
{
"local_name": "Shelly*"
},
{
"manufacturer_id": 2985
}
],
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],

View File

@@ -706,6 +706,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "shelly",
"local_name": "Shelly*",
},
{
"domain": "shelly",
"manufacturer_id": 2985,
},
{
"domain": "snooz",
"local_name": "Snooz*",

14
homeassistant/generated/labs.py generated Normal file
View File

@@ -0,0 +1,14 @@
"""Automatically generated file.
To update, run python3 -m script.hassfest
"""
LABS_PREVIEW_FEATURES = {
"kitchen_sink": {
"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",
},
},
}

View File

@@ -266,6 +266,7 @@ class Manifest(TypedDict, total=False):
loggers: list[str]
import_executor: bool
single_config_entry: bool
preview_features: dict[str, dict[str, str]]
def async_setup(hass: HomeAssistant) -> None:
@@ -900,6 +901,11 @@ class Integration:
"""Return Integration bluetooth entries."""
return self.manifest.get("bluetooth")
@property
def preview_features(self) -> dict[str, dict[str, str]] | None:
"""Return Integration preview features entries."""
return self.manifest.get("preview_features")
@property
def dhcp(self) -> list[dict[str, str | bool]] | None:
"""Return Integration dhcp entries."""

View File

@@ -21,6 +21,7 @@ from . import (
icons,
integration_info,
json,
labs,
manifest,
metadata,
mqtt,
@@ -47,6 +48,7 @@ INTEGRATION_PLUGINS = [
icons,
integration_info,
json,
labs,
manifest,
mqtt,
quality_scale,

View File

@@ -102,6 +102,7 @@ ALLOWED_USED_COMPONENTS = {
"input_number",
"input_select",
"input_text",
"labs",
"media_source",
"onboarding",
"panel_custom",
@@ -130,6 +131,7 @@ IGNORE_VIOLATIONS = {
# This would be a circular dep
("http", "network"),
("http", "cloud"),
("labs", "backup"),
# This would be a circular dep
("zha", "homeassistant_hardware"),
("zha", "homeassistant_sky_connect"),

79
script/hassfest/labs.py Normal file
View File

@@ -0,0 +1,79 @@
"""Generate lab preview features file."""
from __future__ import annotations
from .model import Config, Integration
from .serializer import format_python_namespace
def generate_and_validate(integrations: dict[str, Integration]) -> str:
"""Validate and generate lab preview features data."""
labs_dict: dict[str, dict[str, dict[str, str]]] = {}
for domain in sorted(integrations):
integration = integrations[domain]
preview_features = integration.manifest.get("preview_features")
if not preview_features:
continue
if not isinstance(preview_features, dict):
integration.add_error(
"labs",
f"preview_features must be a dict, got {type(preview_features).__name__}",
)
continue
# Extract features with full data
domain_preview_features: dict[str, dict[str, str]] = {}
for preview_feature_id, preview_feature_config in preview_features.items():
if not isinstance(preview_feature_id, str):
integration.add_error(
"labs",
f"preview_features keys must be strings, got {type(preview_feature_id).__name__}",
)
break
if not isinstance(preview_feature_config, dict):
integration.add_error(
"labs",
f"preview_features[{preview_feature_id}] must be a dict, got {type(preview_feature_config).__name__}",
)
break
# Include the full feature configuration
domain_preview_features[preview_feature_id] = {
"feedback_url": preview_feature_config.get("feedback_url", ""),
"learn_more_url": preview_feature_config.get("learn_more_url", ""),
"report_issue_url": preview_feature_config.get("report_issue_url", ""),
}
else:
# Only add if all features are valid
if domain_preview_features:
labs_dict[domain] = domain_preview_features
return format_python_namespace(
{
"LABS_PREVIEW_FEATURES": labs_dict,
}
)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate lab preview features file."""
labs_path = config.root / "homeassistant/generated/labs.py"
config.cache["labs"] = content = generate_and_validate(integrations)
if config.specific_integrations:
return
if not labs_path.exists() or labs_path.read_text() != content:
config.add_error(
"labs",
"File labs.py is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Generate lab preview features file."""
labs_path = config.root / "homeassistant/generated/labs.py"
labs_path.write_text(config.cache["labs"])

View File

@@ -279,6 +279,17 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
vol.Optional("disabled"): str,
vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES),
vol.Optional("single_config_entry"): bool,
vol.Optional("preview_features"): vol.Schema(
{
cv.slug: vol.Schema(
{
vol.Optional("feedback_url"): vol.Url(),
vol.Optional("learn_more_url"): vol.Url(),
vol.Optional("report_issue_url"): vol.Url(),
}
)
}
),
}
)

View File

@@ -2189,6 +2189,7 @@ NO_QUALITY_SCALE = [
"input_text",
"intent_script",
"intent",
"labs",
"logbook",
"logger",
"lovelace",

View File

@@ -329,6 +329,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
flow_title=UNDEFINED,
require_step_title=False,
),
vol.Optional("preview_features"): cv.schema_with_slug_keys(
{
vol.Required("name"): translation_value_validator,
vol.Required("description"): translation_value_validator,
vol.Optional("enable_confirmation"): translation_value_validator,
vol.Optional("disable_confirmation"): translation_value_validator,
},
slug_validator=translation_key_validator,
),
vol.Optional("selector"): cv.schema_with_slug_keys(
{
vol.Optional("options"): cv.schema_with_slug_keys(

View File

@@ -356,6 +356,32 @@
},
"uniqueItems": true
},
"preview_features": {
"description": "Preview features that can be enabled/disabled by users via the Labs UI.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#preview-features",
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"properties": {
"feedback_url": {
"description": "URL where users can provide feedback about the feature.",
"type": "string",
"format": "uri"
},
"learn_more_url": {
"description": "URL where users can learn more about the feature.",
"type": "string",
"format": "uri"
},
"report_issue_url": {
"description": "URL where users can report issues with the feature.",
"type": "string",
"format": "uri"
}
},
"additionalProperties": false
}
},
"disabled": {
"description": "The reason for the integration being disabled.",
"type": "string"

View File

@@ -9,6 +9,7 @@ from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.components.labs import EVENT_LABS_UPDATED
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.statistics import (
StatisticMeanType,
@@ -18,6 +19,7 @@ from homeassistant.components.recorder.statistics import (
)
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
@@ -370,3 +372,133 @@ async def test_service(
{"field_1": 1, "field_2": "auto", "field_3": 1, "field_4": "forwards"},
blocking=True,
)
@pytest.mark.parametrize(
("preview_feature_enabled", "should_create_issue"),
[
(False, False),
(True, True),
],
ids=["preview_feature_disabled", "preview_feature_enabled"],
)
async def test_special_repair_preview_feature_state(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
hass_ws_client: WebSocketGenerator,
preview_feature_enabled: bool,
should_create_issue: bool,
) -> None:
"""Test that special repair issue is created/removed based on preview feature state."""
assert await async_setup_component(hass, "labs", {})
assert await async_setup_component(hass, "repairs", {})
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
if preview_feature_enabled:
ws_client = await hass_ws_client(hass)
# Enable the special repair preview feature
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
# Wait for event handling
await hass.async_block_till_done()
# Check if issue exists based on preview feature state
issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue")
if should_create_issue:
assert issue is not None
assert issue.domain == DOMAIN
assert issue.translation_key == "special_repair"
assert issue.is_fixable is False
assert issue.severity == ir.IssueSeverity.WARNING
else:
assert issue is None
async def test_special_repair_preview_feature_toggle(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that special repair issue is created/deleted when preview feature is toggled."""
# Setup repairs and kitchen_sink first
assert await async_setup_component(hass, "labs", {})
assert await async_setup_component(hass, "repairs", {})
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
# Enable the special repair preview feature
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
# Check issue exists
issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue")
assert issue is not None
# Disable the special repair preview feature
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": False,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
# Check issue is removed
issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue")
assert issue is None
async def test_preview_feature_event_handler_registered(
hass: HomeAssistant,
) -> None:
"""Test that preview feature event handler is registered on setup."""
# Setup kitchen_sink
assert await async_setup_component(hass, "labs", {})
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
# Track if event is handled
events_received = []
def track_event(event):
events_received.append(event)
hass.bus.async_listen(EVENT_LABS_UPDATED, track_event)
await hass.async_block_till_done()
# Fire a labs updated event for kitchen_sink preview feature
hass.bus.async_fire(
EVENT_LABS_UPDATED,
{"feature_id": "kitchen_sink.special_repair", "enabled": True},
)
await hass.async_block_till_done()
# Verify event was received by our tracker
assert len(events_received) == 1
assert events_received[0].data["feature_id"] == "kitchen_sink.special_repair"

View File

@@ -0,0 +1 @@
"""Tests for the Home Assistant Labs integration."""

View File

@@ -0,0 +1,423 @@
"""Tests for the Home Assistant Labs integration setup."""
from __future__ import annotations
from typing import Any
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
LabsStorage,
async_is_preview_feature_enabled,
async_setup,
)
from homeassistant.components.labs.const import LABS_DATA, LabPreviewFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.loader import Integration
async def test_async_setup(hass: HomeAssistant) -> None:
"""Test the Labs integration setup."""
assert await async_setup(hass, {})
await hass.async_block_till_done()
# Verify WebSocket commands are registered
assert "labs/list" in hass.data["websocket_api"]
assert "labs/update" in hass.data["websocket_api"]
async def test_async_is_preview_feature_enabled_not_setup(hass: HomeAssistant) -> None:
"""Test checking if preview feature is enabled before setup returns False."""
# Don't set up labs integration
result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
assert result is False
async def test_async_is_preview_feature_enabled_nonexistent(
hass: HomeAssistant,
) -> None:
"""Test checking if non-existent preview feature is enabled."""
assert await async_setup(hass, {})
await hass.async_block_till_done()
result = async_is_preview_feature_enabled(
hass, "kitchen_sink", "nonexistent_feature"
)
assert result is False
async def test_async_is_preview_feature_enabled_when_enabled(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test checking if preview feature is enabled."""
# Load kitchen_sink integration so preview feature exists
hass.config.components.add("kitchen_sink")
# Enable a preview feature via storage
hass_storage["core.labs"] = {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {
"preview_feature_status": [
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
]
},
}
assert await async_setup(hass, {})
await hass.async_block_till_done()
result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
assert result is True
async def test_async_is_preview_feature_enabled_when_disabled(
hass: HomeAssistant,
) -> None:
"""Test checking if preview feature is disabled (not in storage)."""
# Load kitchen_sink integration so preview feature exists
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
assert result is False
@pytest.mark.parametrize(
("features_to_store", "expected_enabled", "expected_cleaned"),
[
# Single stale feature cleanup
(
[
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
{"domain": "nonexistent_domain", "preview_feature": "fake_feature"},
],
[("kitchen_sink", "special_repair")],
[("nonexistent_domain", "fake_feature")],
),
# Multiple stale features cleanup
(
[
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
{"domain": "stale_domain_1", "preview_feature": "old_feature"},
{"domain": "stale_domain_2", "preview_feature": "another_old"},
{"domain": "stale_domain_3", "preview_feature": "yet_another"},
],
[("kitchen_sink", "special_repair")],
[
("stale_domain_1", "old_feature"),
("stale_domain_2", "another_old"),
("stale_domain_3", "yet_another"),
],
),
# All features cleaned (no integrations loaded)
(
[{"domain": "nonexistent", "preview_feature": "fake"}],
[],
[("nonexistent", "fake")],
),
],
)
async def test_storage_cleanup_stale_features(
hass: HomeAssistant,
hass_storage: dict[str, Any],
features_to_store: list[dict[str, str]],
expected_enabled: list[tuple[str, str]],
expected_cleaned: list[tuple[str, str]],
) -> None:
"""Test that stale preview features are removed from storage on setup."""
# Load kitchen_sink only if we expect any features to remain
if expected_enabled:
hass.config.components.add("kitchen_sink")
hass_storage["core.labs"] = {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {"preview_feature_status": features_to_store},
}
assert await async_setup(hass, {})
await hass.async_block_till_done()
# Verify expected features are preserved
for domain, feature in expected_enabled:
assert async_is_preview_feature_enabled(hass, domain, feature)
# Verify stale features were cleaned up
for domain, feature in expected_cleaned:
assert not async_is_preview_feature_enabled(hass, domain, feature)
async def test_event_fired_on_preview_feature_update(hass: HomeAssistant) -> None:
"""Test that labs_updated event is fired when preview feature is toggled."""
# Load kitchen_sink integration
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
events = []
def event_listener(event):
events.append(event)
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
# Fire event manually to test listener (websocket handler does this)
hass.bus.async_fire(
EVENT_LABS_UPDATED,
{
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
},
)
await hass.async_block_till_done()
assert len(events) == 1
assert events[0].data["domain"] == "kitchen_sink"
assert events[0].data["preview_feature"] == "special_repair"
assert events[0].data["enabled"] is True
@pytest.mark.parametrize(
("domain", "preview_feature", "expected"),
[
("kitchen_sink", "special_repair", True),
("other", "nonexistent", False),
("kitchen_sink", "nonexistent", False),
],
)
async def test_async_is_preview_feature_enabled(
hass: HomeAssistant,
hass_storage: dict[str, Any],
domain: str,
preview_feature: str,
expected: bool,
) -> None:
"""Test async_is_preview_feature_enabled."""
# Enable the kitchen_sink.special_repair preview feature via storage
hass_storage["core.labs"] = {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {
"preview_feature_status": [
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
]
},
}
await async_setup(hass, {})
await hass.async_block_till_done()
result = async_is_preview_feature_enabled(hass, domain, preview_feature)
assert result is expected
async def test_multiple_setups_idempotent(hass: HomeAssistant) -> None:
"""Test that calling async_setup multiple times is safe."""
result1 = await async_setup(hass, {})
assert result1 is True
result2 = await async_setup(hass, {})
assert result2 is True
# Verify store is still accessible
assert LABS_DATA in hass.data
async def test_storage_load_missing_preview_feature_status_key(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test loading storage when preview_feature_status key is missing."""
# Storage data without preview_feature_status key
hass_storage["core.labs"] = {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {}, # Missing preview_feature_status
}
assert await async_setup(hass, {})
await hass.async_block_till_done()
# Should initialize correctly - verify no feature is enabled
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
async def test_preview_feature_full_key(hass: HomeAssistant) -> None:
"""Test that preview feature full_key property returns correct format."""
feature = LabPreviewFeature(
domain="test_domain",
preview_feature="test_feature",
feedback_url="https://feedback.example.com",
)
assert feature.full_key == "test_domain.test_feature"
async def test_preview_feature_to_dict_with_all_urls(hass: HomeAssistant) -> None:
"""Test LabPreviewFeature.to_dict with all URLs populated."""
feature = LabPreviewFeature(
domain="test_domain",
preview_feature="test_feature",
feedback_url="https://feedback.example.com",
learn_more_url="https://learn.example.com",
report_issue_url="https://issue.example.com",
)
result = feature.to_dict(enabled=True)
assert result == {
"preview_feature": "test_feature",
"domain": "test_domain",
"enabled": True,
"is_built_in": True,
"feedback_url": "https://feedback.example.com",
"learn_more_url": "https://learn.example.com",
"report_issue_url": "https://issue.example.com",
}
async def test_preview_feature_to_dict_with_no_urls(hass: HomeAssistant) -> None:
"""Test LabPreviewFeature.to_dict with no URLs (all None)."""
feature = LabPreviewFeature(
domain="test_domain",
preview_feature="test_feature",
)
result = feature.to_dict(enabled=False)
assert result == {
"preview_feature": "test_feature",
"domain": "test_domain",
"enabled": False,
"is_built_in": True,
"feedback_url": None,
"learn_more_url": None,
"report_issue_url": None,
}
async def test_storage_load_returns_none_when_no_file(
hass: HomeAssistant,
) -> None:
"""Test storage load when no file exists (returns None)."""
# Create a storage instance but don't write any data
store = LabsStorage(hass, 1, "test_labs_none.json")
# Mock the parent Store's _async_load_data to return None
# This simulates the edge case where Store._async_load_data returns None
# This tests line 60: return None
async def mock_load_none():
return None
with patch.object(Store, "_async_load_data", new=mock_load_none):
result = await store.async_load()
assert result is None
async def test_custom_integration_with_preview_features(
hass: HomeAssistant,
) -> None:
"""Test that custom integrations with preview features are loaded."""
# Create a mock custom integration with preview features
mock_integration = Mock(spec=Integration)
mock_integration.domain = "custom_test"
mock_integration.preview_features = {
"test_feature": {
"feedback_url": "https://feedback.test",
"learn_more_url": "https://learn.test",
}
}
with patch(
"homeassistant.components.labs.async_get_custom_components",
return_value={"custom_test": mock_integration},
):
assert await async_setup(hass, {})
await hass.async_block_till_done()
# Verify the custom integration's preview feature can be checked
# (This confirms it was loaded properly)
assert not async_is_preview_feature_enabled(hass, "custom_test", "test_feature")
@pytest.mark.parametrize(
("is_custom", "expected_is_built_in"),
[
(False, True), # Built-in integration
(True, False), # Custom integration
],
)
async def test_preview_feature_is_built_in_flag(
hass: HomeAssistant,
is_custom: bool,
expected_is_built_in: bool,
) -> None:
"""Test that preview features have correct is_built_in flag."""
if is_custom:
# Create a mock custom integration
mock_integration = Mock(spec=Integration)
mock_integration.domain = "custom_test"
mock_integration.preview_features = {
"custom_feature": {"feedback_url": "https://feedback.test"}
}
with patch(
"homeassistant.components.labs.async_get_custom_components",
return_value={"custom_test": mock_integration},
):
assert await async_setup(hass, {})
await hass.async_block_till_done()
feature_key = "custom_test.custom_feature"
else:
# Load built-in kitchen_sink integration
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
feature_key = "kitchen_sink.special_repair"
labs_data = hass.data[LABS_DATA]
assert feature_key in labs_data.preview_features
feature = labs_data.preview_features[feature_key]
assert feature.is_built_in is expected_is_built_in
@pytest.mark.parametrize(
("is_built_in", "expected_default"),
[
(True, True),
(False, False),
(None, True), # Default value when not specified
],
)
async def test_preview_feature_to_dict_is_built_in(
hass: HomeAssistant,
is_built_in: bool | None,
expected_default: bool,
) -> None:
"""Test that to_dict correctly handles is_built_in field."""
if is_built_in is None:
# Test default value
feature = LabPreviewFeature(
domain="test_domain",
preview_feature="test_feature",
)
else:
feature = LabPreviewFeature(
domain="test_domain",
preview_feature="test_feature",
is_built_in=is_built_in,
)
assert feature.is_built_in is expected_default
result = feature.to_dict(enabled=True)
assert result["is_built_in"] is expected_default

View File

@@ -0,0 +1,654 @@
"""Tests for the Home Assistant Labs WebSocket API."""
from __future__ import annotations
from typing import Any
from unittest.mock import ANY, AsyncMock, patch
import pytest
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
async_is_preview_feature_enabled,
async_setup,
)
from homeassistant.core import HomeAssistant
from tests.common import MockUser
from tests.typing import WebSocketGenerator
@pytest.mark.parametrize(
("load_integration", "expected_features"),
[
(False, []), # No integration loaded
(
True, # Integration loaded
[
{
"preview_feature": "special_repair",
"domain": "kitchen_sink",
"enabled": False,
"is_built_in": True,
"feedback_url": ANY,
"learn_more_url": ANY,
"report_issue_url": ANY,
}
],
),
],
)
async def test_websocket_list_preview_features(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
load_integration: bool,
expected_features: list,
) -> None:
"""Test listing preview features with different integration states."""
if load_integration:
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {"features": expected_features}
async def test_websocket_update_preview_feature_enable(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test enabling a preview feature via WebSocket."""
# Load kitchen_sink integration
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Track events
events = []
def event_listener(event):
events.append(event)
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
# Enable the preview feature
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
# Verify event was fired
await hass.async_block_till_done()
assert len(events) == 1
assert events[0].data["domain"] == "kitchen_sink"
assert events[0].data["preview_feature"] == "special_repair"
assert events[0].data["enabled"] is True
# Verify feature is now enabled
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
async def test_websocket_update_preview_feature_disable(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test disabling a preview feature via WebSocket."""
# Pre-populate storage with enabled preview feature
hass_storage["core.labs"] = {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {
"preview_feature_status": [
{"domain": "kitchen_sink", "preview_feature": "special_repair"}
]
},
}
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 5,
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": False,
}
)
msg = await client.receive_json()
assert msg["success"]
# Verify feature is disabled
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
async def test_websocket_update_nonexistent_feature(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test updating a preview feature that doesn't exist."""
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "nonexistent",
"preview_feature": "feature",
"enabled": True,
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "not_found"
assert "not found" in msg["error"]["message"].lower()
async def test_websocket_update_unavailable_preview_feature(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test updating a preview feature whose integration is not loaded still works."""
# Don't load kitchen_sink integration
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Preview feature is pre-loaded, so update succeeds even though integration isn't loaded
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
@pytest.mark.parametrize(
"command_type",
["labs/list", "labs/update"],
)
async def test_websocket_requires_admin(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
command_type: str,
) -> None:
"""Test that websocket commands require admin privileges."""
# Remove admin privileges
hass_admin_user.groups = []
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
command = {"type": command_type}
if command_type == "labs/update":
command.update(
{
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
await client.send_json_auto_id(command)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "unauthorized"
async def test_websocket_update_validates_enabled_parameter(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that enabled parameter must be boolean."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Try with string instead of boolean
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": "true",
}
)
msg = await client.receive_json()
assert not msg["success"]
# Validation error from voluptuous
async def test_storage_persists_preview_feature_across_calls(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that storage persists preview feature state across multiple calls."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Enable the preview feature
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg = await client.receive_json()
assert msg["success"]
# List preview features - should show enabled
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["features"][0]["enabled"] is True
# Disable preview feature
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": False,
}
)
msg = await client.receive_json()
assert msg["success"]
# List preview features - should show disabled
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["features"][0]["enabled"] is False
async def test_preview_feature_urls_present(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that preview features include feedback and report URLs."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
feature = msg["result"]["features"][0]
assert "feedback_url" in feature
assert "learn_more_url" in feature
assert "report_issue_url" in feature
assert feature["feedback_url"] is not None
assert feature["learn_more_url"] is not None
assert feature["report_issue_url"] is not None
@pytest.mark.parametrize(
(
"create_backup",
"backup_fails",
"enabled",
"should_call_backup",
"should_succeed",
),
[
# Enable with successful backup
(True, False, True, True, True),
# Enable with failed backup
(True, True, True, True, False),
# Disable ignores backup flag
(True, False, False, False, True),
],
ids=[
"enable_with_backup_success",
"enable_with_backup_failure",
"disable_ignores_backup",
],
)
async def test_websocket_update_preview_feature_backup_scenarios(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
create_backup: bool,
backup_fails: bool,
enabled: bool,
should_call_backup: bool,
should_succeed: bool,
) -> None:
"""Test various backup scenarios when updating preview features."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Mock the backup manager
mock_backup_manager = AsyncMock()
if backup_fails:
mock_backup_manager.async_create_automatic_backup = AsyncMock(
side_effect=Exception("Backup failed")
)
else:
mock_backup_manager.async_create_automatic_backup = AsyncMock()
with patch(
"homeassistant.components.labs.async_get_manager",
return_value=mock_backup_manager,
):
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": enabled,
"create_backup": create_backup,
}
)
msg = await client.receive_json()
if should_succeed:
assert msg["success"]
if should_call_backup:
mock_backup_manager.async_create_automatic_backup.assert_called_once()
else:
mock_backup_manager.async_create_automatic_backup.assert_not_called()
else:
assert not msg["success"]
assert msg["error"]["code"] == "unknown_error"
assert "backup" in msg["error"]["message"].lower()
# Verify preview feature was NOT enabled
assert not async_is_preview_feature_enabled(
hass, "kitchen_sink", "special_repair"
)
async def test_websocket_list_multiple_enabled_features(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test listing when multiple preview features are enabled."""
# Pre-populate with multiple enabled features
hass_storage["core.labs"] = {
"version": 1,
"data": {
"preview_feature_status": [
{"domain": "kitchen_sink", "preview_feature": "special_repair"},
]
},
}
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
features = msg["result"]["features"]
assert len(features) >= 1
# Verify at least one is enabled
enabled_features = [f for f in features if f["enabled"]]
assert len(enabled_features) == 1
async def test_websocket_update_rapid_toggle(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test rapid toggling of a preview feature."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Enable
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg1 = await client.receive_json()
assert msg1["success"]
# Disable immediately
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": False,
}
)
msg2 = await client.receive_json()
assert msg2["success"]
# Enable again
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg3 = await client.receive_json()
assert msg3["success"]
# Final state should be enabled
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
async def test_websocket_update_same_state_idempotent(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that enabling an already-enabled feature is idempotent."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Enable feature
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg1 = await client.receive_json()
assert msg1["success"]
# Enable again (should be idempotent)
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
msg2 = await client.receive_json()
assert msg2["success"]
# Should still be enabled
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
async def test_websocket_list_filtered_by_loaded_components(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that list only shows features from loaded integrations."""
# Don't load kitchen_sink - its preview feature shouldn't appear
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
# Should be empty since kitchen_sink isn't loaded
assert msg["result"]["features"] == []
# Now load kitchen_sink
hass.config.components.add("kitchen_sink")
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
assert msg["success"]
# Now should have kitchen_sink features
assert len(msg["result"]["features"]) >= 1
async def test_websocket_update_with_missing_required_field(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that missing required fields are rejected."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Missing 'enabled' field
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
# enabled is missing
}
)
msg = await client.receive_json()
assert not msg["success"]
# Should get validation error
async def test_websocket_event_data_structure(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test that event data has correct structure."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
events = []
def event_listener(event):
events.append(event)
hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener)
# Enable a feature
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
await client.receive_json()
await hass.async_block_till_done()
assert len(events) == 1
event_data = events[0].data
# Verify all required fields are present
assert "domain" in event_data
assert "preview_feature" in event_data
assert "enabled" in event_data
assert event_data["domain"] == "kitchen_sink"
assert event_data["preview_feature"] == "special_repair"
assert event_data["enabled"] is True
assert isinstance(event_data["enabled"], bool)
async def test_websocket_backup_timeout_handling(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test handling of backup timeout/long-running backup."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
# Mock backup manager with timeout
mock_backup_manager = AsyncMock()
mock_backup_manager.async_create_automatic_backup = AsyncMock(
side_effect=TimeoutError("Backup timed out")
)
with patch(
"homeassistant.components.labs.async_get_manager",
return_value=mock_backup_manager,
):
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
"create_backup": True,
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "unknown_error"

View File

@@ -90,6 +90,15 @@ BLE_MANUFACTURER_DATA_RPC = {
BLE_MANUFACTURER_DATA_NO_RPC = {
0x0BA9: bytes([0x01, 0x02, 0x00])
} # Flags without RPC bit
BLE_MANUFACTURER_DATA_WITH_MAC = {
0x0BA9: bytes.fromhex("0105000b30100a70d6c297bacc")
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x30, 0x10), MAC (0x0a, 0x70, 0xd6, 0xc2, 0x97, 0xba, 0xcc)
# Device WiFi MAC: 70d6c297bacc (little-endian) -> CCBA97C2D670 (reversed to big-endian)
# BLE MAC is typically device MAC + 2: CCBA97C2D670 + 2 = CC:BA:97:C2:D6:72
BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL = {
0x0BA9: bytes.fromhex("0105000b99990a70d6c297bacc")
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x99, 0x99) - unknown model ID, MAC (0x0a, 0x70, 0xd6, 0xc2, 0x97, 0xba, 0xcc)
BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="ShellyPlus2PM-C049EF8873E8",
@@ -151,6 +160,46 @@ BLE_DISCOVERY_INFO_INVALID_NAME = BluetoothServiceInfoBleak(
tx_power=-127,
)
BLE_DISCOVERY_INFO_MAC_IN_MANUFACTURER_DATA = BluetoothServiceInfoBleak(
name="CC:BA:97:C2:D6:72", # BLE address as name (newer devices)
address="CC:BA:97:C2:D6:72", # BLE address may differ from device MAC
rssi=-32,
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC,
service_uuids=[],
service_data={},
source="local",
device=generate_ble_device(
address="CC:BA:97:C2:D6:72",
name="CC:BA:97:C2:D6:72",
),
advertisement=generate_advertisement_data(
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC,
),
time=0,
connectable=True,
tx_power=-127,
)
BLE_DISCOVERY_INFO_MAC_UNKNOWN_MODEL = BluetoothServiceInfoBleak(
name="CC:BA:97:C2:D6:72", # BLE address as name (newer devices)
address="CC:BA:97:C2:D6:72", # BLE address may differ from device MAC
rssi=-32,
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL,
service_uuids=[],
service_data={},
source="local",
device=generate_ble_device(
address="CC:BA:97:C2:D6:72",
name="CC:BA:97:C2:D6:72",
),
advertisement=generate_advertisement_data(
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL,
),
time=0,
connectable=True,
tx_power=-127,
)
BLE_DISCOVERY_INFO_NO_DEVICE = BluetoothServiceInfoBleak(
name="ShellyPlus2PM-C049EF8873E8",
address="00:00:00:00:00:00", # Invalid address that won't be found
@@ -2057,6 +2106,53 @@ async def test_bluetooth_discovery_invalid_name(
assert result["reason"] == "invalid_discovery_info"
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_discovery_mac_in_manufacturer_data(
hass: HomeAssistant,
) -> None:
"""Test bluetooth discovery with MAC in manufacturer data (newer devices)."""
# Inject BLE device so it's available in the bluetooth scanner
inject_bluetooth_service_info_bleak(
hass, BLE_DISCOVERY_INFO_MAC_IN_MANUFACTURER_DATA
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO_MAC_IN_MANUFACTURER_DATA,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
# Should successfully extract MAC from manufacturer data
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
# MAC from manufacturer data: 70d6c297bacc (reversed) = CC:BA:97:C2:D6:70 = CCBA97C2D670
# Model ID 0x1030 = Shelly 1 Mini Gen4
# Device name should use model name from model ID: Shelly1MiniGen4-<MAC>
assert result["description_placeholders"]["name"] == "Shelly1MiniGen4-CCBA97C2D670"
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_discovery_mac_unknown_model(
hass: HomeAssistant,
) -> None:
"""Test bluetooth discovery with MAC but unknown model ID."""
# Inject BLE device so it's available in the bluetooth scanner
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_MAC_UNKNOWN_MODEL)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO_MAC_UNKNOWN_MODEL,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
# Should successfully extract MAC from manufacturer data
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
# MAC from manufacturer data: 70d6c297bacc (reversed) = CC:BA:97:C2:D6:70 = CCBA97C2D670
# Model ID 0x9999 is unknown - should fall back to generic "Shelly-<MAC>"
assert result["description_placeholders"]["name"] == "Shelly-CCBA97C2D670"
@pytest.mark.usefixtures("mock_rpc_device", "mock_zeroconf")
async def test_bluetooth_discovery_already_configured(
hass: HomeAssistant,