diff --git a/CODEOWNERS b/CODEOWNERS index fe7b662822313..1bc3e39d108b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/Dockerfile b/Dockerfile index 33e8fbbaff97a..aa4de12d3eb09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 95f4c8e333463..425f079e596c0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -176,6 +176,8 @@ 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 @@ "backup", "frontend", "hardware", + "labs", "logger", "network", "system_health", diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 6abb16d36ea01..497c03adbd4da 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -60,35 +60,6 @@ _LOGGER = logging.getLogger(__name__) _FFMPEG = "ffmpeg" -_SUPPORTED_STREAMS = frozenset( - ( - "bubble", - "dvrip", - "expr", - _FFMPEG, - "gopro", - "homekit", - "http", - "https", - "httpx", - "isapi", - "ivideon", - "kasa", - "nest", - "onvif", - "roborock", - "rtmp", - "rtmps", - "rtmpx", - "rtsp", - "rtsps", - "rtspx", - "tapo", - "tcp", - "webrtc", - "webtorrent", - ) -) CONFIG_SCHEMA = vol.Schema( { @@ -197,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo return False provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) + await provider.initialize() entry.async_on_unload(async_register_webrtc_provider(hass, provider)) return True @@ -228,16 +200,21 @@ def __init__( self._session = session self._rest_client = rest_client self._sessions: dict[str, Go2RtcWsClient] = {} + self._supported_schemes: set[str] = set() @property def domain(self) -> str: """Return the integration domain of the provider.""" return DOMAIN + async def initialize(self) -> None: + """Initialize the provider.""" + self._supported_schemes = await self._rest_client.schemes.list() + @callback def async_is_supported(self, stream_source: str) -> bool: """Return if this provider is supports the Camera as source.""" - return stream_source.partition(":")[0] in _SUPPORTED_STREAMS + return stream_source.partition(":")[0] in self._supported_schemes async def async_handle_async_webrtc_offer( self, diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index c020ad79fdeab..0d7d666b284f1 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,4 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -RECOMMENDED_VERSION = "1.9.11" +RECOMMENDED_VERSION = "1.9.12" diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index dd50b4ba07689..4436336094815 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["go2rtc-client==0.2.1"], + "requirements": ["go2rtc-client==0.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 6699ee4d8a29f..37040742aea98 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -29,8 +29,18 @@ _GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant # Do not edit it manually +app: + modules: {app_modules} + api: listen: "{api_ip}:{api_port}" + allow_paths: {api_allow_paths} + +# ffmpeg needs the exec module +# Restrict execution to only ffmpeg binary +exec: + allow_paths: + - ffmpeg rtsp: listen: "127.0.0.1:18554" @@ -40,6 +50,43 @@ ice_servers: [] """ +_APP_MODULES = ( + "api", + "exec", # Execution module for ffmpeg + "ffmpeg", + "http", + "mjpeg", + "onvif", + "rtmp", + "rtsp", + "srtp", + "webrtc", + "ws", +) + +_API_ALLOW_PATHS = ( + "/", # UI static page and version control + "/api", # Main API path + "/api/frame.jpeg", # Snapshot functionality + "/api/schemes", # Supported stream schemes + "/api/streams", # Stream management + "/api/webrtc", # Webrtc functionality + "/api/ws", # Websocket functionality (e.g. webrtc candidates) +) + +# Additional modules when UI is enabled +_UI_APP_MODULES = ( + *_APP_MODULES, + "debug", +) +# Additional api paths when UI is enabled +_UI_API_ALLOW_PATHS = ( + *_API_ALLOW_PATHS, + "/api/config", # UI config view + "/api/log", # UI log view + "/api/streams.dot", # UI network view +) + _LOG_LEVEL_MAP = { "TRC": logging.DEBUG, "DBG": logging.DEBUG, @@ -61,14 +108,34 @@ class Go2RTCWatchdogError(HomeAssistantError): """Raised on watchdog error.""" -def _create_temp_file(api_ip: str) -> str: +def _format_list_for_yaml(items: tuple[str, ...]) -> str: + """Format a list of strings for yaml config.""" + if not items: + return "[]" + formatted_items = ",".join(f'"{item}"' for item in items) + return f"[{formatted_items}]" + + +def _create_temp_file(enable_ui: bool) -> str: """Create temporary config file.""" + app_modules: tuple[str, ...] = _APP_MODULES + api_paths: tuple[str, ...] = _API_ALLOW_PATHS + api_ip = _LOCALHOST_IP + if enable_ui: + app_modules = _UI_APP_MODULES + api_paths = _UI_API_ALLOW_PATHS + # Listen on all interfaces for allowing access from all ips + api_ip = "" + # Set delete=False to prevent the file from being deleted when the file is closed # Linux is clearing tmp folder on reboot, so no need to delete it manually with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: file.write( _GO2RTC_CONFIG_FORMAT.format( - api_ip=api_ip, api_port=HA_MANAGED_API_PORT + api_ip=api_ip, + api_port=HA_MANAGED_API_PORT, + app_modules=_format_list_for_yaml(app_modules), + api_allow_paths=_format_list_for_yaml(api_paths), ).encode() ) return file.name @@ -86,10 +153,7 @@ def __init__( self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._process: asyncio.subprocess.Process | None = None self._startup_complete = asyncio.Event() - self._api_ip = _LOCALHOST_IP - if enable_ui: - # Listen on all interfaces for allowing access from all ips - self._api_ip = "" + self._enable_ui = enable_ui self._watchdog_task: asyncio.Task | None = None self._watchdog_tasks: list[asyncio.Task] = [] @@ -104,7 +168,7 @@ async def _start(self) -> None: """Start the server.""" _LOGGER.debug("Starting go2rtc server") config_file = await self._hass.async_add_executor_job( - _create_temp_file, self._api_ip + _create_temp_file, self._enable_ui ) self._startup_complete.clear() diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index cb782b258d946..f423546b053a3 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -11,6 +11,11 @@ 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 @@ 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() diff --git a/homeassistant/components/kitchen_sink/manifest.json b/homeassistant/components/kitchen_sink/manifest.json index ae2462afbbdae..4cf532d72ff18 100644 --- a/homeassistant/components/kitchen_sink/manifest.json +++ b/homeassistant/components/kitchen_sink/manifest.json @@ -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 } diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index ad490f9c32fe9..0b816675cfc7d 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -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", diff --git a/homeassistant/components/labs/__init__.py b/homeassistant/components/labs/__init__.py new file mode 100644 index 0000000000000..aa89cb225b61e --- /dev/null +++ b/homeassistant/components/labs/__init__.py @@ -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"]) diff --git a/homeassistant/components/labs/const.py b/homeassistant/components/labs/const.py new file mode 100644 index 0000000000000..80a60d19717fe --- /dev/null +++ b/homeassistant/components/labs/const.py @@ -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) diff --git a/homeassistant/components/labs/manifest.json b/homeassistant/components/labs/manifest.json new file mode 100644 index 0000000000000..5a97f72a09f70 --- /dev/null +++ b/homeassistant/components/labs/manifest.json @@ -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" +} diff --git a/homeassistant/components/labs/strings.json b/homeassistant/components/labs/strings.json new file mode 100644 index 0000000000000..23aa06e50fac6 --- /dev/null +++ b/homeassistant/components/labs/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant Labs" +} diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 35d1d3803613a..7c1fb151acbf4 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,7 +7,11 @@ 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 @@ async def async_step_bluetooth( 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 @@ async def async_step_bluetooth( 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}, } ) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 8f7c59c73d2ea..bcaa1e808dcad 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -4,6 +4,9 @@ "bluetooth": [ { "local_name": "Shelly*" + }, + { + "manufacturer_id": 2985 } ], "codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"], diff --git a/homeassistant/components/sleep_as_android/sensor.py b/homeassistant/components/sleep_as_android/sensor.py index 67b52ae9153e8..cd7662104a6db 100644 --- a/homeassistant/components/sleep_as_android/sensor.py +++ b/homeassistant/components/sleep_as_android/sensor.py @@ -66,8 +66,6 @@ def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: if webhook_id == self.webhook_id and data[ATTR_EVENT] in ( "alarm_snooze_clicked", "alarm_snooze_canceled", - "alarm_alert_start", - "alarm_alert_dismiss", "alarm_skip_next", "show_skip_next_alarm", "alarm_rescheduled", diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 5f0e1da796a16..8af2f0b643f3b 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -19,7 +19,6 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper -from .util import get_dpcode TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -80,6 +79,9 @@ def async_discover_device(device_ids: list[str]) -> None: mode_wrapper=DPCodeEnumWrapper.find_dpcode( device, DPCode.MODE, prefer_function=True ), + pause_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, DPCode.PAUSE + ), status_wrapper=DPCodeEnumWrapper.find_dpcode( device, DPCode.STATUS ), @@ -111,6 +113,7 @@ def __init__( fan_speed_wrapper: DPCodeEnumWrapper | None, locate_wrapper: DPCodeBooleanWrapper | None, mode_wrapper: DPCodeEnumWrapper | None, + pause_wrapper: DPCodeBooleanWrapper | None, status_wrapper: DPCodeEnumWrapper | None, switch_wrapper: DPCodeBooleanWrapper | None, ) -> None: @@ -120,6 +123,7 @@ def __init__( self._fan_speed_wrapper = fan_speed_wrapper self._locate_wrapper = locate_wrapper self._mode_wrapper = mode_wrapper + self._pause_wrapper = pause_wrapper self._status_wrapper = status_wrapper self._switch_wrapper = switch_wrapper @@ -127,7 +131,7 @@ def __init__( self._attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE ) - if get_dpcode(self.device, DPCode.PAUSE): + if pause_wrapper: self._attr_supported_features |= VacuumEntityFeature.PAUSE if charge_wrapper or ( @@ -159,7 +163,7 @@ def activity(self) -> VacuumActivity | None: if (status := self._read_wrapper(self._status_wrapper)) is not None: return TUYA_STATUS_TO_HA.get(status) - if self.device.status.get(DPCode.PAUSE): + if self._read_wrapper(self._pause_wrapper): return VacuumActivity.PAUSED return None @@ -171,9 +175,9 @@ async def async_stop(self, **kwargs: Any) -> None: """Stop the device.""" await self._async_send_dpcode_update(self._switch_wrapper, False) - def pause(self, **kwargs: Any) -> None: + async def async_pause(self, **kwargs: Any) -> None: """Pause the device.""" - self._send_command([{"code": DPCode.POWER_GO, "value": False}]) + await self.async_stop(**kwargs) async def async_return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 9541e82aecd5d..305df305d418d 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -706,6 +706,10 @@ "domain": "shelly", "local_name": "Shelly*", }, + { + "domain": "shelly", + "manufacturer_id": 2985, + }, { "domain": "snooz", "local_name": "Snooz*", diff --git a/homeassistant/generated/labs.py b/homeassistant/generated/labs.py new file mode 100644 index 0000000000000..b1e06b0fb1335 --- /dev/null +++ b/homeassistant/generated/labs.py @@ -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", + }, + }, +} diff --git a/homeassistant/loader.py b/homeassistant/loader.py index fc10223a182fc..62382e59d0eca 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -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 @@ def bluetooth(self) -> list[dict[str, str | int]] | None: """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.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 792b576c9fd37..65807a3402350 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ cryptography==46.0.2 dbus-fast==3.0.0 file-read-backwards==2.0.0 fnv-hash-fast==1.6.0 -go2rtc-client==0.2.1 +go2rtc-client==0.3.0 ha-ffmpeg==3.2.2 habluetooth==5.7.0 hass-nabucasa==1.5.1 diff --git a/requirements_all.txt b/requirements_all.txt index ec8d88827bcba..62c9fa379a3be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1049,7 +1049,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.2.1 +go2rtc-client==0.3.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test.txt b/requirements_test.txt index ba5502b4ff8b8..767ebcbd093b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,7 +10,7 @@ astroid==4.0.1 coverage==7.10.6 freezegun==1.5.2 -go2rtc-client==0.2.1 +go2rtc-client==0.3.0 # librt is an internal mypy dependency librt==0.2.1 license-expression==30.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26f247f366fa9..8e5d65155258b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -916,7 +916,7 @@ gios==6.1.2 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.2.1 +go2rtc-client==0.3.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 43a6cc7678b83..0e7c6c83d9b3c 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -21,6 +21,7 @@ icons, integration_info, json, + labs, manifest, metadata, mqtt, @@ -47,6 +48,7 @@ icons, integration_info, json, + labs, manifest, mqtt, quality_scale, diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 447b3ec79b846..fe48e8a8607df 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -102,6 +102,7 @@ def visit_Import(self, node: ast.Import) -> None: "input_number", "input_select", "input_text", + "labs", "media_source", "onboarding", "panel_custom", @@ -130,6 +131,7 @@ def visit_Import(self, node: ast.Import) -> None: # 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"), diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 4fa4c7be1318c..257a60799b4ef 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -29,7 +29,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \ tqdm==4.67.1 \ ruff==0.13.0 \ PyTurboJPEG==1.8.0 \ - go2rtc-client==0.2.1 \ + go2rtc-client==0.3.0 \ ha-ffmpeg==3.2.2 \ hassil==3.4.0 \ home-assistant-intents==2025.11.7 \ diff --git a/script/hassfest/labs.py b/script/hassfest/labs.py new file mode 100644 index 0000000000000..c4cb8b77cf9b1 --- /dev/null +++ b/script/hassfest/labs.py @@ -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"]) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 74aad78dc6a46..5828ed329bcb5 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -279,6 +279,17 @@ def verify_wildcard(value: str) -> str: 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(), + } + ) + } + ), } ) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d8763fe00eb65..80b19dedf97cb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2189,6 +2189,7 @@ class Rule: "input_text", "intent_script", "intent", + "labs", "logbook", "logger", "lovelace", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 6ded948f181d6..ca5983073b10b 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -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( diff --git a/script/json_schemas/manifest_schema.json b/script/json_schemas/manifest_schema.json index 7349f12b55abf..8b0ea255f93a1 100644 --- a/script/json_schemas/manifest_schema.json +++ b/script/json_schemas/manifest_schema.json @@ -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" diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index bd6d3841dadbb..ff5748bffb12a 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, Mock, patch from awesomeversion import AwesomeVersion -from go2rtc_client.rest import _StreamClient, _WebRTCClient +from go2rtc_client.rest import _SchemesClient, _StreamClient, _WebRTCClient import pytest from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -39,6 +39,23 @@ def rest_client() -> Generator[AsyncMock]: patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value + client.schemes = schemes = Mock(spec_set=_SchemesClient) + schemes.list.return_value = { + "onvif", + "exec", + "http", + "rtmps", + "https", + "rtmpx", + "httpx", + "rtsps", + "webrtc", + "rtmp", + "tcp", + "rtsp", + "rtspx", + "ffmpeg", + } client.streams = streams = Mock(spec_set=_StreamClient) streams.list.return_value = {} client.validate_server_version = AsyncMock( diff --git a/tests/components/go2rtc/snapshots/test_server.ambr b/tests/components/go2rtc/snapshots/test_server.ambr new file mode 100644 index 0000000000000..9ae2ef9643948 --- /dev/null +++ b/tests/components/go2rtc/snapshots/test_server.ambr @@ -0,0 +1,23 @@ +# serializer version: 1 +# name: test_server_run_success[False] + _CallList([ + _Call( + tuple( + b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: "127.0.0.1:11984"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', + ), + dict({ + }), + ), + ]) +# --- +# name: test_server_run_success[True] + _CallList([ + _Call( + tuple( + b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', + ), + dict({ + }), + ), + ]) +# --- diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index e4fe3993f3cdd..9ec60d30da4f5 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.go2rtc.server import Server from homeassistant.core import HomeAssistant @@ -75,20 +76,17 @@ def assert_server_output_not_logged( @pytest.mark.parametrize( - ("enable_ui", "api_ip"), - [ - (True, ""), - (False, "127.0.0.1"), - ], + "enable_ui", + [True, False], ) +@pytest.mark.usefixtures("rest_client") async def test_server_run_success( mock_create_subprocess: AsyncMock, - rest_client: AsyncMock, server_stdout: list[str], server: Server, caplog: pytest.LogCaptureFixture, mock_tempfile: Mock, - api_ip: str, + snapshot: SnapshotAssertion, ) -> None: """Test that the server runs successfully.""" await server.start() @@ -104,21 +102,8 @@ async def test_server_run_success( ) # Verify that the config file was written - mock_tempfile.write.assert_called_once_with( - f"""# This file is managed by Home Assistant -# Do not edit it manually - -api: - listen: "{api_ip}:11984" - -rtsp: - listen: "127.0.0.1:18554" - -webrtc: - listen: ":18555/tcp" - ice_servers: [] -""".encode() - ) + calls = mock_tempfile.write.call_args_list + assert calls == snapshot() # Verify go2rtc binary stdout was logged with debug level assert_server_output_logged(server_stdout, caplog, logging.DEBUG) diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 6d7b0af1d5d74..8439f10450133 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -9,6 +9,7 @@ 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.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" diff --git a/tests/components/labs/__init__.py b/tests/components/labs/__init__.py new file mode 100644 index 0000000000000..12eb7f9be974a --- /dev/null +++ b/tests/components/labs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Labs integration.""" diff --git a/tests/components/labs/test_init.py b/tests/components/labs/test_init.py new file mode 100644 index 0000000000000..c8bd0e69d00ce --- /dev/null +++ b/tests/components/labs/test_init.py @@ -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 diff --git a/tests/components/labs/test_websocket_api.py b/tests/components/labs/test_websocket_api.py new file mode 100644 index 0000000000000..a832469dffa64 --- /dev/null +++ b/tests/components/labs/test_websocket_api.py @@ -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" diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index f57af5e075034..908490fe82518 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -90,6 +90,15 @@ 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 @@ 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- + 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-" + assert result["description_placeholders"]["name"] == "Shelly-CCBA97C2D670" + + @pytest.mark.usefixtures("mock_rpc_device", "mock_zeroconf") async def test_bluetooth_discovery_already_configured( hass: HomeAssistant, diff --git a/tests/components/sleep_as_android/test_sensor.py b/tests/components/sleep_as_android/test_sensor.py index f8706b29f6b61..bd559355cbd31 100644 --- a/tests/components/sleep_as_android/test_sensor.py +++ b/tests/components/sleep_as_android/test_sensor.py @@ -78,8 +78,6 @@ async def test_setup( [ "alarm_snooze_clicked", "alarm_snooze_canceled", - "alarm_alert_start", - "alarm_alert_dismiss", "alarm_skip_next", "show_skip_next_alarm", "alarm_rescheduled", diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index c8d495c433972..3b3bd3d3398b6 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -13,6 +13,7 @@ ATTR_FAN_SPEED, DOMAIN as VACUUM_DOMAIN, SERVICE_LOCATE, + SERVICE_PAUSE, SERVICE_RETURN_TO_BASE, SERVICE_SET_FAN_SPEED, SERVICE_START, @@ -88,6 +89,13 @@ async def test_platform_setup_and_discovery( {}, {"code": "power_go", "value": False}, ), + ( + "sd_lr33znaodtyarrrz", + "vacuum.v20", + SERVICE_PAUSE, + {}, + {"code": "power_go", "value": False}, + ), ], ) async def test_action(