mirror of
https://github.com/home-assistant/core.git
synced 2025-11-21 00:36:54 +00:00
Compare commits
2 Commits
test_comma
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e572f8d48f | ||
|
|
482b5d49a3 |
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
310
homeassistant/components/labs/__init__.py
Normal file
310
homeassistant/components/labs/__init__.py
Normal 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"])
|
||||
77
homeassistant/components/labs/const.py
Normal file
77
homeassistant/components/labs/const.py
Normal 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)
|
||||
9
homeassistant/components/labs/manifest.json
Normal file
9
homeassistant/components/labs/manifest.json
Normal 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"
|
||||
}
|
||||
3
homeassistant/components/labs/strings.json
Normal file
3
homeassistant/components/labs/strings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Home Assistant Labs"
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "Shelly*"
|
||||
},
|
||||
{
|
||||
"manufacturer_id": 2985
|
||||
}
|
||||
],
|
||||
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
|
||||
|
||||
4
homeassistant/generated/bluetooth.py
generated
4
homeassistant/generated/bluetooth.py
generated
@@ -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
14
homeassistant/generated/labs.py
generated
Normal 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",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
79
script/hassfest/labs.py
Normal 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"])
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2189,6 +2189,7 @@ NO_QUALITY_SCALE = [
|
||||
"input_text",
|
||||
"intent_script",
|
||||
"intent",
|
||||
"labs",
|
||||
"logbook",
|
||||
"logger",
|
||||
"lovelace",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
tests/components/labs/__init__.py
Normal file
1
tests/components/labs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Home Assistant Labs integration."""
|
||||
423
tests/components/labs/test_init.py
Normal file
423
tests/components/labs/test_init.py
Normal 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
|
||||
654
tests/components/labs/test_websocket_api.py
Normal file
654
tests/components/labs/test_websocket_api.py
Normal 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"
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user