diff --git a/CODEOWNERS b/CODEOWNERS index dd321761fac1b..85bfabb1f1f6c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -840,6 +840,8 @@ CLAUDE.md @home-assistant/core /tests/components/imgw_pib/ @bieniu /homeassistant/components/immich/ @mib1185 /tests/components/immich/ @mib1185 +/homeassistant/components/imou/ @Imou-OpenPlatform +/tests/components/imou/ @Imou-OpenPlatform /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 9c16d4b39c776..9c6c8c642eb3a 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,7 +1,6 @@ """Support for entities of the Evohome integration.""" from collections.abc import Mapping -from datetime import UTC, datetime import logging from typing import Any @@ -14,6 +13,7 @@ from homeassistant.core import callback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .coordinator import EvoDataUpdateCoordinator @@ -161,7 +161,7 @@ async def get_schedule() -> None: or self._schedule is None or ( (until := self._setpoints.get("next_sp_from")) is not None - and until < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow + and until < dt_util.utcnow() ) ): # must use self._setpoints, not self.setpoints await get_schedule() diff --git a/homeassistant/components/evohome/storage.py b/homeassistant/components/evohome/storage.py index 57b20bc76306e..da3eff9dc456b 100644 --- a/homeassistant/components/evohome/storage.py +++ b/homeassistant/components/evohome/storage.py @@ -1,6 +1,6 @@ """Support for (EMEA/EU-based) Honeywell TCC systems.""" -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from typing import Any, NotRequired, TypedDict from evohomeasync.auth import ( @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util from .const import STORAGE_KEY, STORAGE_VER @@ -91,8 +92,7 @@ def _import_session_id(self, session: _SessionIdEntryT) -> None: # type: ignore session_id_expires = session.get(SZ_SESSION_ID_EXPIRES) if session_id_expires is None: - # pylint: disable-next=home-assistant-enforce-utcnow - self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15) + self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15) else: self._session_id_expires = datetime.fromisoformat(session_id_expires) diff --git a/homeassistant/components/imou/__init__.py b/homeassistant/components/imou/__init__.py new file mode 100644 index 0000000000000..1e7e12fd6c6ef --- /dev/null +++ b/homeassistant/components/imou/__init__.py @@ -0,0 +1,42 @@ +"""Support for Imou devices.""" + +from pyimouapi.device import ImouDeviceManager +from pyimouapi.ha_device import ImouHaDeviceManager +from pyimouapi.openapi import ImouOpenApiClient + +from homeassistant.core import HomeAssistant, callback + +from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, PLATFORMS +from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool: + """Set up Imou integration from a config entry.""" + imou_client = ImouOpenApiClient( + entry.data[CONF_APP_ID], + entry.data[CONF_APP_SECRET], + API_URLS[entry.data[CONF_API_URL]], + ) + device_manager = ImouDeviceManager(imou_client) + imou_device_manager = ImouHaDeviceManager(device_manager) + imou_coordinator = ImouDataUpdateCoordinator(hass, imou_device_manager, entry) + await imou_coordinator.async_config_entry_first_refresh() + entry.runtime_data = imou_coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # DataUpdateCoordinator schedules periodic refreshes only when it has + # listeners. With zero entities (e.g. an empty account at setup), register a + # no-op listener so polling continues and later devices are discovered via + # new_device_callbacks. + @callback + def _async_keep_polling() -> None: + """Keep periodic polling when no entities are registered yet.""" + + entry.async_on_unload(imou_coordinator.async_add_listener(_async_keep_polling)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool: + """Handle removal of an entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py new file mode 100644 index 0000000000000..31ef0c96bea21 --- /dev/null +++ b/homeassistant/components/imou/button.py @@ -0,0 +1,109 @@ +"""Support for Imou button controls.""" + +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import ImouHaDevice + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier +from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator +from .entity import ImouEntity + +PARALLEL_UPDATES = 1 +# Button types +PARAM_RESTART_DEVICE = "restart_device" +PARAM_MUTE = "mute" +PARAM_PTZ_UP = "ptz_up" +PARAM_PTZ_DOWN = "ptz_down" +PARAM_PTZ_LEFT = "ptz_left" +PARAM_PTZ_RIGHT = "ptz_right" + +BUTTON_TYPES = ( + PARAM_RESTART_DEVICE, + PARAM_MUTE, + PARAM_PTZ_UP, + PARAM_PTZ_DOWN, + PARAM_PTZ_LEFT, + PARAM_PTZ_RIGHT, +) + +PTZ_BUTTON_TYPES = ( + PARAM_PTZ_UP, + PARAM_PTZ_DOWN, + PARAM_PTZ_LEFT, + PARAM_PTZ_RIGHT, +) + +BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = { + PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART, +} + + +def _iter_buttons( + coordinator: ImouDataUpdateCoordinator, +) -> list[tuple[str, ImouHaDevice]]: + """Return (button_type, device) pairs for supported buttons.""" + return [ + (button_type, device) + for device in coordinator.devices + for button_type in device.buttons + if button_type in BUTTON_TYPES + ] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImouConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Imou button entities.""" + coordinator = entry.runtime_data + + def _add_buttons(new_devices: list[ImouHaDevice]) -> None: + device_keys = {imou_device_identifier(device) for device in new_devices} + async_add_entities( + ImouButton(coordinator, button_type, device) + for button_type, device in _iter_buttons(coordinator) + if imou_device_identifier(device) in device_keys + ) + + coordinator.new_device_callbacks.append(_add_buttons) + + @callback + def _remove_new_device_callback() -> None: + if _add_buttons in coordinator.new_device_callbacks: + coordinator.new_device_callbacks.remove(_add_buttons) + + entry.async_on_unload(_remove_new_device_callback) + _add_buttons(coordinator.devices) + + +class ImouButton(ImouEntity, ButtonEntity): + """Imou button entity.""" + + def __init__( + self, + coordinator: ImouDataUpdateCoordinator, + entity_type: str, + device: ImouHaDevice, + ) -> None: + """Initialize the Imou button entity.""" + super().__init__(coordinator, entity_type, device) + if device_class := BUTTON_DEVICE_CLASS.get(entity_type): + self._attr_device_class = device_class + self._attr_translation_key = None + + async def async_press(self) -> None: + """Handle button press.""" + duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0 + try: + await self.coordinator.device_manager.async_press_button( + self.device, + self._entity_type, + duration, + ) + except ImouException as e: + raise HomeAssistantError(str(e)) from e diff --git a/homeassistant/components/imou/config_flow.py b/homeassistant/components/imou/config_flow.py new file mode 100644 index 0000000000000..49d43a13b380b --- /dev/null +++ b/homeassistant/components/imou/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow for Imou.""" + +import logging +from typing import Any + +from pyimouapi.exceptions import ( + ConnectFailedException, + ImouException, + InvalidAppIdOrSecretException, + RequestFailedException, +) +from pyimouapi.openapi import ImouOpenApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImouConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Imou integration.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the config flow.""" + errors: dict[str, str] = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_APP_ID]) + self._abort_if_unique_id_configured() + api_client = ImouOpenApiClient( + user_input[CONF_APP_ID], + user_input[CONF_APP_SECRET], + API_URLS[user_input[CONF_API_URL]], + ) + try: + await api_client.async_get_token() + except InvalidAppIdOrSecretException: + errors["base"] = "invalid_auth" + except ConnectFailedException, RequestFailedException: + errors["base"] = "cannot_connect" + except ImouException as exception: + _LOGGER.debug("Imou error during config flow: %s", exception) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Imou", + data={ + CONF_APP_ID: user_input[CONF_APP_ID], + CONF_APP_SECRET: user_input[CONF_APP_SECRET], + CONF_API_URL: user_input[CONF_API_URL], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_APP_ID): str, + vol.Required(CONF_APP_SECRET): str, + vol.Required(CONF_API_URL, default="sg"): SelectSelector( + SelectSelectorConfig( + options=list(API_URLS), + translation_key="api_url", + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/imou/const.py b/homeassistant/components/imou/const.py new file mode 100644 index 0000000000000..d315aa6b1c2fa --- /dev/null +++ b/homeassistant/components/imou/const.py @@ -0,0 +1,39 @@ +"""Constants.""" + +from pyimouapi.ha_device import ImouHaDevice + +from homeassistant.const import Platform + +DOMAIN = "imou" + + +def imou_device_identifier(device: ImouHaDevice) -> str: + """Return a device registry identifier (device_id + channel when present).""" + if device.channel_id is not None: + return f"{device.device_id}_{device.channel_id}" + return device.device_id + + +# API URL region mapping +API_URLS: dict[str, str] = { + "sg": "openapi-sg.easy4ip.com", + "eu": "openapi-or.easy4ip.com", + "na": "openapi-fk.easy4ip.com", + "cn": "openapi.lechange.cn", +} + +CONF_API_URL = "api_url" +CONF_APP_ID = "app_id" +CONF_APP_SECRET = "app_secret" + +PARAM_STATUS = "status" +PARAM_STATE = "state" + + +# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API). +PTZ_MOVE_DURATION_MS = 500 + +# Upper bound for a full coordinator refresh (device list + status for all devices). +UPDATE_TIMEOUT = 300 + +PLATFORMS = [Platform.BUTTON] diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py new file mode 100644 index 0000000000000..d25f2d4e6137c --- /dev/null +++ b/homeassistant/components/imou/coordinator.py @@ -0,0 +1,152 @@ +"""Provides the Imou DataUpdateCoordinator.""" + +import asyncio +from collections.abc import Callable +from datetime import timedelta +import logging + +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_TIMEOUT, imou_device_identifier + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=120) + +type ImouConfigEntry = ConfigEntry[ImouDataUpdateCoordinator] + + +class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Data update coordinator for Imou devices.""" + + config_entry: ImouConfigEntry + + def __init__( + self, + hass: HomeAssistant, + device_manager: ImouHaDeviceManager, + config_entry: ImouConfigEntry, + ) -> None: + """Initialize the Imou data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ImouDataUpdateCoordinator", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + always_update=True, + ) + self._device_manager = device_manager + self.devices_by_key: dict[str, ImouHaDevice] = {} + self._devices_initialized = False + self.new_device_callbacks: list[Callable[[list[ImouHaDevice]], None]] = [] + + @property + def devices(self) -> list[ImouHaDevice]: + """Return the list of devices.""" + return list(self.devices_by_key.values()) + + @property + def device_manager(self) -> ImouHaDeviceManager: + """Return the device manager.""" + return self._device_manager + + def get_device(self, device_key: str) -> ImouHaDevice | None: + """Return the current device for device_key, if still on the account.""" + return self.devices_by_key.get(device_key) + + async def _async_update_data(self) -> None: + """Update coordinator data.""" + try: + async with asyncio.timeout(UPDATE_TIMEOUT): + fresh_devices = await self._device_manager.async_get_devices() + except TimeoutError as err: + raise UpdateFailed(f"Timeout while fetching data: {err}") from err + except ImouException as err: + raise UpdateFailed(f"Error fetching Imou devices: {err}") from err + + fresh_by_key = { + imou_device_identifier(device): device for device in fresh_devices + } + self._async_add_remove_devices(fresh_by_key) + devices = list(self.devices_by_key.values()) + if not devices: + return + + try: + async with asyncio.timeout(UPDATE_TIMEOUT): + results = await asyncio.gather( + *( + self._device_manager.async_update_device_status(device) + for device in devices + ), + return_exceptions=True, + ) + except TimeoutError as err: + raise UpdateFailed(f"Timeout while fetching data: {err}") from err + + failures: list[Exception] = [] + for device, result in zip(devices, results, strict=True): + if isinstance(result, BaseException) and not isinstance(result, Exception): + # Propagate CancelledError and other BaseExceptions instead of + # swallowing them as a regular device failure. + raise result + if not isinstance(result, Exception): + continue + device_key = imou_device_identifier(device) + _LOGGER.warning( + "Error updating status for Imou device %s: %s", + device_key, + result, + ) + failures.append(result) + if failures and len(failures) == len(devices): + raise UpdateFailed( + f"Error updating Imou devices: {failures[0]}" + ) from failures[0] + + def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> None: + """Add new devices, remove devices no longer in the account. + + This only tracks which devices exist on the account; per-device state + is updated in place by `async_update_device_status`, so devices that + remain on the account keep their existing object and are not replaced. + """ + if not self._devices_initialized: + self.devices_by_key = fresh_by_key + self._devices_initialized = True + return + + current_keys = set(fresh_by_key) + known_keys = set(self.devices_by_key) + + if current_keys == known_keys: + return + + if removed_keys := known_keys - current_keys: + _LOGGER.debug("Removed Imou device(s): %s", ", ".join(removed_keys)) + device_registry = dr.async_get(self.hass) + for device_key in removed_keys: + del self.devices_by_key[device_key] + if device := device_registry.async_get_device( + identifiers={(DOMAIN, device_key)} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if new_keys := current_keys - known_keys: + _LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys)) + new_devices = [] + for device_key in new_keys: + self.devices_by_key[device_key] = fresh_by_key[device_key] + new_devices.append(fresh_by_key[device_key]) + for callback in self.new_device_callbacks: + callback(new_devices) diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py new file mode 100644 index 0000000000000..f3fd4257e96bd --- /dev/null +++ b/homeassistant/components/imou/entity.py @@ -0,0 +1,59 @@ +"""An abstract class common to all Imou entities.""" + +from pyimouapi.ha_device import DeviceStatus, ImouHaDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier +from .coordinator import ImouDataUpdateCoordinator + + +class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]): + """Base class for all Imou entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ImouDataUpdateCoordinator, + entity_type: str, + device: ImouHaDevice, + ) -> None: + """Initialize the Imou entity.""" + super().__init__(coordinator) + self._entity_type = entity_type + self._device_key = imou_device_identifier(device) + self._attr_unique_id = f"{self._device_key}${entity_type}" + self._attr_translation_key = entity_type + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_key)}, + name=device.channel_name or device.device_name, + manufacturer=device.manufacturer, + model=device.model, + sw_version=device.swversion, + serial_number=device.device_id, + ) + + @property + def device(self) -> ImouHaDevice: + """Return the live device from the coordinator. + + Callers must guard with `available` first; accessing this for a device + that has left the account raises `KeyError`. + """ + return self.coordinator.devices_by_key[self._device_key] + + @property + def available(self) -> bool: + """Return if the entity is available.""" + if ( + not super().available + or self._device_key not in self.coordinator.devices_by_key + ): + return False + if PARAM_STATUS not in self.device.sensors: + return False + return ( + self.device.sensors[PARAM_STATUS][PARAM_STATE] != DeviceStatus.OFFLINE.value + ) diff --git a/homeassistant/components/imou/icons.json b/homeassistant/components/imou/icons.json new file mode 100644 index 0000000000000..8b2cd6c05621d --- /dev/null +++ b/homeassistant/components/imou/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "button": { + "ptz_down": { + "default": "mdi:arrow-down-bold" + }, + "ptz_left": { + "default": "mdi:arrow-left-bold" + }, + "ptz_right": { + "default": "mdi:arrow-right-bold" + }, + "ptz_up": { + "default": "mdi:arrow-up-bold" + } + } + } +} diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json new file mode 100644 index 0000000000000..8f034a6638965 --- /dev/null +++ b/homeassistant/components/imou/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "imou", + "name": "Imou", + "codeowners": ["@Imou-OpenPlatform"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imou", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pyimouapi==1.2.7"] +} diff --git a/homeassistant/components/imou/quality_scale.yaml b/homeassistant/components/imou/quality_scale.yaml new file mode 100644 index 0000000000000..4442f4764331f --- /dev/null +++ b/homeassistant/components/imou/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Cloud service integration, does not support discovery. + discovery: + status: exempt + comment: >- + Devices are reached via Imou Open Platform cloud APIs (App ID / secret). No + supported local discovery flow today; example cues if investigated later: + hostname `IPC-ABCD.imou.local`, MAC `aa:bb:cc:dd:ee:ff`. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json new file mode 100644 index 0000000000000..ea7bed1bc65f6 --- /dev/null +++ b/homeassistant/components/imou/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_url": "Server region", + "app_id": "App ID", + "app_secret": "App secret" + }, + "data_description": { + "api_url": "Select the server region closest to your location", + "app_id": "The app ID obtained from the Imou cloud platform", + "app_secret": "The app secret obtained from the Imou cloud platform" + }, + "title": "Log in to Imou cloud" + } + } + }, + "entity": { + "button": { + "mute": { + "name": "Mute" + }, + "ptz_down": { + "name": "PTZ down" + }, + "ptz_left": { + "name": "PTZ left" + }, + "ptz_right": { + "name": "PTZ right" + }, + "ptz_up": { + "name": "PTZ up" + } + } + }, + "selector": { + "api_url": { + "options": { + "cn": "China", + "eu": "Europe", + "na": "North America", + "sg": "Singapore (Asia-Pacific)" + } + } + } +} diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 5afe1b4a12e70..55ed04b513c1f 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -8,6 +8,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator PLATFORMS = [Platform.NUMBER, Platform.SENSOR] @@ -25,9 +26,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> try: await charger.test_and_get() except TimeoutError as ex: - raise ConfigEntryNotReady("Unable to connect to charger") from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from ex except AuthenticationError as ex: - raise ConfigEntryAuthFailed("Invalid credentials for charger") from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from ex coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/openevse/coordinator.py b/homeassistant/components/openevse/coordinator.py index 22d3a9bbeb131..dceb61573f65b 100644 --- a/homeassistant/components/openevse/coordinator.py +++ b/homeassistant/components/openevse/coordinator.py @@ -63,7 +63,11 @@ async def _async_update_data(self) -> None: await self.charger.update() except TimeoutError as error: raise UpdateFailed( - f"Timeout communicating with charger: {error}" + translation_domain=DOMAIN, + translation_key="communication_error", ) from error except AuthenticationError as error: - raise ConfigEntryAuthFailed("Invalid credentials for charger") from error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from error diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index 3eeaf700a88bd..a585e6b821eaf 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -168,10 +168,10 @@ }, "exceptions": { "authentication_error": { - "message": "Authentication failed while communicating with the charger." + "message": "Authentication failed" }, "communication_error": { - "message": "Failed to communicate with the charger." + "message": "Failed to communicate with the charger" }, "invalid_value": { "message": "Value {value} is invalid for the charger." diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 61750efbb4ace..4c1b7c2e71e1d 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -218,8 +218,8 @@ def _handle_coordinator_update(self) -> None: self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or ( _tilt > self.CLOSED_UP_THRESHOLD ) - self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] - self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 5bae9bfb462cf..09c1770fcbfd6 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -139,12 +139,14 @@ "device_tracker": { "data": { "device_id": "[%key:common::config_flow::data::device%]", + "in_zones": "Zones", "latitude": "Latitude", "longitude": "Longitude", "name": "[%key:common::config_flow::data::name%]" }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", + "in_zones": "Defines a template that returns a list of zones the device tracker is currently in. The template should return a list of zone entity IDs. If the device tracker is not in any zone, the template should return an empty list.", "latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.", "longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.", "name": "[%key:common::config_flow::data::name%]" @@ -715,11 +717,13 @@ "device_tracker": { "data": { "device_id": "[%key:common::config_flow::data::device%]", + "in_zones": "[%key:component::template::config::step::device_tracker::data::in_zones%]", "latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]", "longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]" }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", + "in_zones": "[%key:component::template::config::step::device_tracker::data_description::in_zones%]", "latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]", "longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]" }, diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index f325ac8b1422b..876cb7bd86cac 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -29,7 +29,7 @@ rules: docs-configuration-parameters: status: exempt comment: No options flow - docs-troubleshooting: todo + docs-troubleshooting: done entity-category: done entity-disabled-by-default: done integration-owner: done @@ -42,13 +42,15 @@ rules: devices: done diagnostics: todo discovery: done - discovery-update-info: todo - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-use-cases: todo + discovery-update-info: + status: exempt + comment: Integration is cloud-only; no local host info is stored on the config entry. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-use-cases: done dynamic-devices: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a1c466b9400fd..5343d696a9756 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -39,6 +39,7 @@ entity, target as target_helpers, template, + trace, ) from homeassistant.helpers.condition import ( async_from_config as async_condition_from_config, @@ -1050,9 +1051,23 @@ async def handle_subscribe_condition( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle subscribe condition command.""" - condition_config = await async_validate_condition_config(hass, msg["condition"]) + try: + condition_config = await async_validate_condition_config(hass, msg["condition"]) + condition = await async_condition_from_config(hass, condition_config) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return - condition = await async_condition_from_config(hass, condition_config) event_data: dict[str, Any] = {} @callback @@ -1061,10 +1076,24 @@ def evaluate_condition(now: datetime | None) -> None: nonlocal event_data new_event_data: dict[str, Any] + condition_trace = trace.trace_get() try: - new_event_data = {"result": condition.async_check()} + with trace.record_template_errors(): + new_event_data = {"result": condition.async_check()} except HomeAssistantError as err: new_event_data = {"error": str(err)} + + # Template errors (e.g. undefined variables) are recorded in the trace + # instead of being logged. Forward them to the client so they are not + # lost, even when the condition still evaluated to a result. + if template_errors := [ + template_error + for elements in condition_trace.values() + for element in elements + for template_error in element.template_errors + ]: + new_event_data["template_errors"] = template_errors + if new_event_data == event_data: return event_data = new_event_data diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 716a2292e896c..de04e085f3812 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -92,7 +92,9 @@ def __init__(self, hass: HomeAssistant, request: web.Request) -> None: self._hass = hass self._loop = hass.loop self._request: web.Request = request - self._wsock = web.WebSocketResponse(heartbeat=55) + # decode_text=False so orjson decodes the raw TEXT bytes directly + # instead of decoding to str first and re-scanning. + self._wsock = web.WebSocketResponse(heartbeat=55, decode_text=False) self._handle_task: asyncio.Task | None = None self._writer_task: asyncio.Task | None = None self._closing: bool = False diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 130648f5a2796..14f95166f05bc 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -4,12 +4,15 @@ import voluptuous as vol +from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -17,15 +20,23 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.automation import ( + DomainSpec, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.condition import ( + ATTR_BEHAVIOR, + BEHAVIOR_ANY, + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionCheckParams, ConditionConfig, + EntityConditionBase, ) from homeassistant.helpers.typing import ConfigType from . import in_zone +from .const import DOMAIN _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, @@ -149,11 +160,126 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: return all_ok +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(value_source=ATTR_IN_ZONES), + "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES), +} + +_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + }, + } +) + + +class _ZoneTargetConditionBase(EntityConditionBase): + """Base for zone-target conditions on person and device_tracker entities.""" + + _domain_specs = _DOMAIN_SPECS + _schema = _ZONE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the condition.""" + super().__init__(hass, config) + assert config.options is not None + self._zone: str = config.options[CONF_ZONE] + + def _in_target_zone(self, entity_state: State) -> bool: + """Check if the entity is currently in the selected zone.""" + in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or () + return self._zone in in_zones + + +class InZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is in the selected zone.""" + return self._in_target_zone(entity_state) + + +class NotInZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is not in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is not in the selected zone.""" + return not self._in_target_zone(entity_state) + + +_OCCUPANCY_CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyConditionBase(EntityConditionBase): + """Base for zone occupancy conditions (single zone, no behavior).""" + + _domain_specs = {"zone": DomainSpec()} + _schema = _OCCUPANCY_CONDITION_SCHEMA + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config and synthesize a target from the zone option. + + We synthesize a target because we allow users to pick a single zone + to monitor, not a target. + """ + config = cast(ConfigType, cls._schema(config)) + zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE] + config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]} + # `behavior` is needed by `EntityConditionBase.__init__` + config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY + return config + + @staticmethod + def _occupancy_count(entity_state: State) -> int | None: + """Return the zone's persons-in-zone count; None if unparsable.""" + try: + return int(entity_state.state) + except TypeError, ValueError: + return None + + @classmethod + def _is_occupied(cls, entity_state: State) -> bool: + """Return True if the zone has at least one occupant.""" + count = cls._occupancy_count(entity_state) + return count is not None and count >= 1 + + +class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase): + """Condition: the selected zone is occupied.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the zone is occupied.""" + return self._is_occupied(entity_state) + + +class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase): + """Condition: the selected zone is empty.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the zone is empty (count == 0).""" + return self._occupancy_count(entity_state) == 0 + + CONDITIONS: dict[str, type[Condition]] = { "_": ZoneCondition, + "in_zone": InZoneCondition, + "not_in_zone": NotInZoneCondition, + "occupancy_is_detected": OccupancyIsDetectedCondition, + "occupancy_is_not_detected": OccupancyIsNotDetectedCondition, } async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: - """Return the sun conditions.""" + """Return the zone conditions.""" return CONDITIONS diff --git a/homeassistant/components/zone/conditions.yaml b/homeassistant/components/zone/conditions.yaml new file mode 100644 index 0000000000000..2294ecd2c2b4b --- /dev/null +++ b/homeassistant/components/zone/conditions.yaml @@ -0,0 +1,42 @@ +.condition_zone: &condition_zone + target: + entity: + domain: + - person + - device_tracker + fields: + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +in_zone: *condition_zone +not_in_zone: *condition_zone + +.condition_occupancy: &condition_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_is_detected: *condition_occupancy +occupancy_is_not_detected: *condition_occupancy diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index f582e6b65a495..5ff8e4944319c 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -1,4 +1,18 @@ { + "conditions": { + "in_zone": { + "condition": "mdi:map-marker-check" + }, + "not_in_zone": { + "condition": "mdi:map-marker-remove" + }, + "occupancy_is_detected": { + "condition": "mdi:account-group" + }, + "occupancy_is_not_detected": { + "condition": "mdi:account-off" + } + }, "services": { "reload": { "service": "mdi:reload" diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 43d0c2986c2e9..912cbff16e768 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -1,10 +1,74 @@ { "common": { + "condition_behavior_name": "Check when", + "condition_for_name": "For at least", + "condition_zone_description": "The zone to test against.", + "condition_zone_name": "Zone", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least", "trigger_zone_description": "The zone to trigger on.", "trigger_zone_name": "Zone" }, + "conditions": { + "in_zone": { + "description": "Tests if one or more persons or device trackers are in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is in zone" + }, + "not_in_zone": { + "description": "Tests if one or more persons or device trackers are not in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is not in zone" + }, + "occupancy_is_detected": { + "description": "Tests if a zone is occupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is detected" + }, + "occupancy_is_not_detected": { + "description": "Tests if a zone is empty.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is not detected" + } + }, "services": { "reload": { "description": "Reloads zones from the YAML-configuration.", diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 13d067b0ff276..b4b82fcf879ab 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -43,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType from . import condition +from .const import DOMAIN EVENT_ENTER = "enter" EVENT_LEAVE = "leave" @@ -68,7 +69,7 @@ _ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( { vol.Required(CONF_OPTIONS): { - vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), }, } ) @@ -208,7 +209,7 @@ def is_valid_state(self, state: State) -> bool: _OCCUPANCY_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_OPTIONS, default={}): { - vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), vol.Optional(CONF_FOR): cv.positive_time_period, }, } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d750973a20a43..cd9bca877ad33 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -348,6 +348,7 @@ "imeon_inverter", "imgw_pib", "immich", + "imou", "improv_ble", "incomfort", "indevolt", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 427d40cf7343e..faea7ffac554e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3229,6 +3229,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "imou": { + "name": "Imou", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "improv_ble": { "name": "Improv via BLE", "integration_type": "device", diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 46096f6f2b87a..6034ede444981 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1953,6 +1953,7 @@ def _base_trigger_validator(value: Any) -> Any: [ { vol.Optional(CONF_ALIAS): string, + vol.Remove(CONF_NOTE): str, # Is only used in frontend vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, } diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index ba1fe3b1fc126..7bd5c16000f42 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -24,6 +24,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.trace import ( + record_template_errors_cv, + trace_stack_cv, + trace_stack_top, +) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey @@ -627,6 +632,15 @@ def make_logging_undefined( return jinja2.StrictUndefined def _log_with_logger(level: int, msg: str) -> None: + # When a consumer such as the subscribe_condition websocket command has + # opted in, record the error on the active trace element instead of + # logging it, so repeated evaluations don't spam the log. + if record_template_errors_cv.get() and ( + node := trace_stack_top(trace_stack_cv) + ): + node.add_template_error(msg) + return + template, action = template_cv.get() or ("", "rendering or compiling") _LOGGER.log( level, diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index e16bf0882233b..8b21a87fb66c0 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -5,7 +5,7 @@ from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any +from typing import Any, Literal, overload from homeassistant.core import ServiceResponse from homeassistant.util import dt as dt_util @@ -22,6 +22,7 @@ class TraceElement: "_error", "_last_variables", "_result", + "_template_errors", "_timestamp", "_variables", "path", @@ -35,6 +36,7 @@ def __init__(self, variables: TemplateVarsType, path: str) -> None: self._error: BaseException | None = None self.path: str = path self._result: dict[str, Any] | None = None + self._template_errors: list[str] | None = None self.reuse_by_child = False self._timestamp = dt_util.utcnow() @@ -54,6 +56,23 @@ def set_error(self, ex: BaseException | None) -> None: """Set error.""" self._error = ex + def add_template_error(self, msg: str) -> None: + """Record a template error message. + + Used to record template variable errors which would otherwise be logged + directly, so they are surfaced in the trace instead of spamming the log. + A single template render can emit more than one message, so they are + accumulated in a list. + """ + if self._template_errors is None: + self._template_errors = [] + self._template_errors.append(msg) + + @property + def template_errors(self) -> list[str]: + """Return the recorded template error messages.""" + return self._template_errors or [] + def set_result(self, **kwargs: Any) -> None: """Set result.""" self._result = {**kwargs} @@ -90,6 +109,8 @@ def as_dict(self) -> dict[str, Any]: result["changed_variables"] = self._variables if self._error is not None: result["error"] = str(self._error) or self._error.__class__.__name__ + if self._template_errors: + result["template_errors"] = self._template_errors if self._result is not None: result["result"] = self._result return result @@ -118,6 +139,26 @@ def as_dict(self) -> dict[str, Any]: script_execution_cv: ContextVar[StopReason | None] = ContextVar( "script_execution_cv", default=None ) +# When set, template errors are recorded on the active TraceElement instead of +# being logged directly +record_template_errors_cv: ContextVar[bool] = ContextVar( + "record_template_errors_cv", default=False +) + + +@contextmanager +def record_template_errors() -> Generator[None]: + """Record template errors in the active trace instead of logging them. + + Used by consumers such as the subscribe_condition websocket command, which + re-evaluate a condition repeatedly and forward template errors to the client + via the trace, so the errors don't spam the log. + """ + token = record_template_errors_cv.set(True) + try: + yield + finally: + record_template_errors_cv.reset(token) def trace_id_set(trace_id: tuple[str, str]) -> None: @@ -189,8 +230,23 @@ def trace_append_element( trace[path].append(trace_element) +@overload +def trace_get(clear: Literal[True] = True) -> dict[str, deque[TraceElement]]: ... + + +@overload +def trace_get(clear: Literal[False]) -> dict[str, deque[TraceElement]] | None: ... + + def trace_get(clear: bool = True) -> dict[str, deque[TraceElement]] | None: - """Return the current trace.""" + """Return the current trace. + + When clear is True the trace is reset and a fresh (empty) trace is + unconditionally returned. + + When clear is False, the current trace is returned without modification + if it exists, otherwise None is returned. + """ if clear: trace_clear() return trace_cv.get() diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 222de95d497ce..9312e58e8912c 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260527.2" +FRONTEND_VERSION: Final[str] = "20260527.3" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index 6705b10c4f95c..d9bca6550b088 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2228,6 +2228,9 @@ pyialarm==2.2.0 # homeassistant.components.icloud pyicloud==2.4.1 +# homeassistant.components.imou +pyimouapi==1.2.7 + # homeassistant.components.insteon pyinsteon==1.6.4 diff --git a/requirements_test.txt b/requirements_test.txt index a9d74ab17af5b..4012cd70b61ac 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,7 +10,7 @@ # ast-serialize is an internal mypy dependency ast-serialize==0.3.0 astroid==4.0.4 -coverage==7.14.0 +coverage==7.14.1 freezegun==1.5.5 # librt is an internal mypy dependency librt==0.11.0 @@ -22,7 +22,7 @@ pydantic==2.13.4 pylint==4.0.5 pylint-per-file-ignores==3.2.1 pipdeptree==2.26.1 -pytest-asyncio==1.3.0 +pytest-asyncio==1.4.0 pytest-aiohttp==1.1.0 pytest-cov==7.1.0 pytest-freezer==0.4.9 diff --git a/tests/components/common.py b/tests/components/common.py index 4b6bf9609ab87..7b54c89f67227 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -1485,12 +1485,12 @@ async def _validate_condition_options( options: dict[str, Any] | None, *, valid: bool, + supports_target: bool = True, ) -> None: """Assert that a condition accepts or rejects the given options.""" - config: dict[str, Any] = { - CONF_CONDITION: condition, - CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, - } + config: dict[str, Any] = {CONF_CONDITION: condition} + if supports_target: + config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"} if options is not None: config[CONF_OPTIONS] = options if valid: @@ -1536,6 +1536,7 @@ async def assert_condition_options_supported( *, supports_behavior: bool, supports_duration: bool, + supports_target: bool = True, ) -> None: """Assert which options a condition supports. @@ -1555,9 +1556,15 @@ async def assert_condition_options_supported( # Minimal config should always be valid # If there are no base options, also test that options can be omitted or be empty supports_empty = not bool(base_options) - await _validate_condition_options(hass, condition, None, valid=supports_empty) - await _validate_condition_options(hass, condition, {}, valid=supports_empty) - await _validate_condition_options(hass, condition, base_options, valid=True) + await _validate_condition_options( + hass, condition, None, valid=supports_empty, supports_target=supports_target + ) + await _validate_condition_options( + hass, condition, {}, valid=supports_empty, supports_target=supports_target + ) + await _validate_condition_options( + hass, condition, base_options, valid=True, supports_target=supports_target + ) def _merge(extra: dict[str, Any]) -> dict[str, Any]: return {**(base_options or {}), **extra} @@ -1565,18 +1572,30 @@ def _merge(extra: dict[str, Any]) -> dict[str, Any]: # Behavior for behavior in ("any", "all"): await _validate_condition_options( - hass, condition, _merge({"behavior": behavior}), valid=supports_behavior + hass, + condition, + _merge({"behavior": behavior}), + valid=supports_behavior, + supports_target=supports_target, ) # Duration for for_value in ({"seconds": 5}, "00:00:05", 5): await _validate_condition_options( - hass, condition, _merge({"for": for_value}), valid=supports_duration + hass, + condition, + _merge({"for": for_value}), + valid=supports_duration, + supports_target=supports_target, ) # Unknown option should always be rejected await _validate_condition_options( - hass, condition, _merge({"unknown_option": True}), valid=False + hass, + condition, + _merge({"unknown_option": True}), + valid=False, + supports_target=supports_target, ) diff --git a/tests/components/imou/__init__.py b/tests/components/imou/__init__.py new file mode 100644 index 0000000000000..47fe6f311d139 --- /dev/null +++ b/tests/components/imou/__init__.py @@ -0,0 +1 @@ +"""Tests for the Imou integration.""" diff --git a/tests/components/imou/conftest.py b/tests/components/imou/conftest.py new file mode 100644 index 0000000000000..b3ec48685c1df --- /dev/null +++ b/tests/components/imou/conftest.py @@ -0,0 +1,89 @@ +"""Test configuration and fixtures for Imou integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pyimouapi.ha_device import ImouHaDevice +import pytest + +from homeassistant.components.imou.const import CONF_APP_ID, DOMAIN +from homeassistant.core import HomeAssistant + +from .const import CONFIG_ENTRY_DATA, DEFAULT_MOCK_DEVICES + +from tests.common import MockConfigEntry + +PATCH_IMOU_OPENAPI_CLIENT = "homeassistant.components.imou.ImouOpenApiClient" +PATCH_CONFIG_FLOW_IMOU_OPENAPI_CLIENT = ( + "homeassistant.components.imou.config_flow.ImouOpenApiClient" +) +PATCH_IMOU_HA_DEVICE_MANAGER = "homeassistant.components.imou.ImouHaDeviceManager" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Imou", + domain=DOMAIN, + data=CONFIG_ENTRY_DATA, + unique_id=CONFIG_ENTRY_DATA[CONF_APP_ID], + entry_id="test_entry_id", + ) + + +@pytest.fixture +def mock_imou_openapi_client() -> Generator[AsyncMock]: + """Mock ImouOpenApiClient for config flow and setup entry.""" + with ( + patch( + PATCH_IMOU_OPENAPI_CLIENT, + autospec=True, + ) as mock_client, + patch( + PATCH_CONFIG_FLOW_IMOU_OPENAPI_CLIENT, + new=mock_client, + ), + ): + yield mock_client.return_value + + +@pytest.fixture +def imou_mock_devices(request: pytest.FixtureRequest) -> list[ImouHaDevice]: + """Devices returned by ImouHaDeviceManager.async_get_devices (override via indirect).""" + return getattr(request, "param", DEFAULT_MOCK_DEVICES) + + +@pytest.fixture +def mock_imou_ha_device_manager( + imou_mock_devices: list[ImouHaDevice], +) -> Generator[MagicMock]: + """Mock ImouHaDeviceManager with a default device list.""" + with patch(PATCH_IMOU_HA_DEVICE_MANAGER, autospec=True) as mock_manager: + device_manager = mock_manager.return_value + device_manager.async_get_devices.return_value = imou_mock_devices + yield device_manager + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry so config flow tests do not load the full integration.""" + with patch( + "homeassistant.components.imou.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_openapi_client: AsyncMock, + mock_imou_ha_device_manager: MagicMock, +) -> MagicMock: + """Set up Imou with mocked library clients; returns the HA device manager mock.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_imou_ha_device_manager diff --git a/tests/components/imou/const.py b/tests/components/imou/const.py new file mode 100644 index 0000000000000..dc222129e0034 --- /dev/null +++ b/tests/components/imou/const.py @@ -0,0 +1,95 @@ +"""Constants for the Imou tests.""" + +from pyimouapi.ha_device import DeviceStatus, ImouHaDevice + +from homeassistant.components.imou.button import ( + PARAM_MUTE, + PARAM_PTZ_UP, + PARAM_RESTART_DEVICE, +) +from homeassistant.components.imou.const import ( + CONF_API_URL, + CONF_APP_ID, + CONF_APP_SECRET, + PARAM_STATE, + PARAM_STATUS, +) + +TEST_APP_ID = "test_app_id" +TEST_APP_SECRET = "test_app_secret" +TEST_API_URL = "sg" + +USER_INPUT = { + CONF_APP_ID: TEST_APP_ID, + CONF_APP_SECRET: TEST_APP_SECRET, + CONF_API_URL: TEST_API_URL, +} + +CONFIG_ENTRY_DATA = { + CONF_APP_ID: TEST_APP_ID, + CONF_APP_SECRET: TEST_APP_SECRET, + CONF_API_URL: TEST_API_URL, +} + +UNKNOWN_BUTTON_KEY = "legacy_unknown_button" + + +def create_online_device( + device_id: str, + name: str, + *, + channel_id: str | None = None, + button_keys: tuple[str, ...] = (), +) -> ImouHaDevice: + """Build an online ImouHaDevice for tests.""" + return create_device( + device_id, + name, + channel_id=channel_id, + button_keys=button_keys, + status=DeviceStatus.ONLINE, + ) + + +def create_offline_device( + device_id: str, + name: str, + *, + channel_id: str | None = None, + button_keys: tuple[str, ...] = (), +) -> ImouHaDevice: + """Build an offline ImouHaDevice for tests.""" + return create_device( + device_id, + name, + channel_id=channel_id, + button_keys=button_keys, + status=DeviceStatus.OFFLINE, + ) + + +def create_device( + device_id: str, + name: str, + *, + channel_id: str | None = None, + button_keys: tuple[str, ...] = (), + status: DeviceStatus = DeviceStatus.ONLINE, +) -> ImouHaDevice: + """Build an ImouHaDevice for tests.""" + device = ImouHaDevice(device_id, name, "Imou", "m1", "1.0") + if channel_id is not None: + device.set_channel_id(channel_id) + for key in button_keys: + device._buttons[key] = {} + device._sensors[PARAM_STATUS] = {PARAM_STATE: status.value} + return device + + +DEFAULT_MOCK_DEVICES = [ + create_online_device( + "d1", + "Device 1", + button_keys=(PARAM_MUTE, PARAM_PTZ_UP, PARAM_RESTART_DEVICE), + ), +] diff --git a/tests/components/imou/snapshots/test_button.ambr b/tests/components/imou/snapshots/test_button.ambr new file mode 100644 index 0000000000000..28748ca0b2b32 --- /dev/null +++ b/tests/components/imou/snapshots/test_button.ambr @@ -0,0 +1,152 @@ +# serializer version: 1 +# name: test_button_entities_snapshot[button.device_1_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.device_1_mute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mute', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mute', + 'platform': 'imou', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mute', + 'unique_id': 'd1$mute', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities_snapshot[button.device_1_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device 1 Mute', + }), + 'context': , + 'entity_id': 'button.device_1_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities_snapshot[button.device_1_ptz_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.device_1_ptz_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ up', + 'platform': 'imou', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_up', + 'unique_id': 'd1$ptz_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities_snapshot[button.device_1_ptz_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device 1 PTZ up', + }), + 'context': , + 'entity_id': 'button.device_1_ptz_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities_snapshot[button.device_1_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.device_1_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'imou', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd1$restart_device', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities_snapshot[button.device_1_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Device 1 Restart', + }), + 'context': , + 'entity_id': 'button.device_1_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/imou/test_button.py b/tests/components/imou/test_button.py new file mode 100644 index 0000000000000..1c74ce0191613 --- /dev/null +++ b/tests/components/imou/test_button.py @@ -0,0 +1,220 @@ +"""Tests for Imou button platform.""" + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import DeviceStatus, ImouHaDevice +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.imou.button import PARAM_MUTE, PARAM_PTZ_UP +from homeassistant.components.imou.const import ( + PARAM_STATE, + PARAM_STATUS, + PTZ_MOVE_DURATION_MS, +) +from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .const import UNKNOWN_BUTTON_KEY, create_online_device + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("init_integration") +async def test_button_entities_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot button entities created from the default mock device list.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + button_keys=(UNKNOWN_BUTTON_KEY, PARAM_MUTE), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_setup_ignores_unknown_button_types( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Unknown button keys from the API are not turned into entities.""" + registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(registry, mock_config_entry.entry_id) + assert len(entries) == 1 + assert entries[0].translation_key == PARAM_MUTE + + +@pytest.mark.usefixtures("init_integration") +async def test_press_button_via_service( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + init_integration: MagicMock, +) -> None: + """Pressing a button calls the vendor library through the coordinator.""" + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + mute_entry = next(e for e in entries if e.translation_key == PARAM_MUTE) + entity_id = mute_entry.entity_id + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + init_integration.async_press_button.assert_awaited_once() + call = init_integration.async_press_button.await_args + assert call is not None + assert call.args[1] == PARAM_MUTE + assert call.args[2] == 0 + + +@pytest.mark.usefixtures("init_integration") +async def test_press_ptz_button_passes_move_duration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """PTZ buttons pass the configured move duration to the vendor library.""" + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ptz_entry = next(e for e in entries if e.translation_key == PARAM_PTZ_UP) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ptz_entry.entity_id}, + blocking=True, + ) + + init_integration.async_press_button.assert_awaited_once() + call = init_integration.async_press_button.await_args + assert call is not None + assert call.args[1] == PARAM_PTZ_UP + assert call.args[2] == PTZ_MOVE_DURATION_MS + + +@pytest.mark.usefixtures("init_integration") +async def test_press_button_service_propagates_api_error( + hass: HomeAssistant, + init_integration: MagicMock, +) -> None: + """Imou API errors from async_press_button surface to the service call.""" + init_integration.async_press_button.side_effect = ImouException("cloud failure") + + entity_id = hass.states.async_all("button")[0].entity_id + + with pytest.raises(HomeAssistantError, match="cloud failure"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + button_keys=(PARAM_MUTE,), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_press_unavailable_offline_device_via_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + init_integration: MagicMock, +) -> None: + """Pressing an offline device does not call the vendor library.""" + mute_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$mute" + ) + + async def set_device_offline(device: ImouHaDevice) -> None: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + + mock_imou_ha_device_manager.async_update_device_status.side_effect = ( + set_device_offline + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(mute_entry.entity_id).state == STATE_UNAVAILABLE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: mute_entry.entity_id}, + blocking=True, + ) + + init_integration.async_press_button.assert_not_called() + + +@pytest.mark.usefixtures("init_integration") +async def test_entities_removed_when_device_leaves_account( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Button entities are removed when the device is no longer on the account.""" + mute_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$mute" + ) + assert hass.states.get(mute_entry.entity_id).state != STATE_UNAVAILABLE + + mock_imou_ha_device_manager.async_get_devices.return_value = [] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + == [] + ) + assert hass.states.get(mute_entry.entity_id) is None diff --git a/tests/components/imou/test_config_flow.py b/tests/components/imou/test_config_flow.py new file mode 100644 index 0000000000000..d8d475d760630 --- /dev/null +++ b/tests/components/imou/test_config_flow.py @@ -0,0 +1,152 @@ +"""Tests for the Imou config flow.""" + +from unittest.mock import AsyncMock + +from pyimouapi.exceptions import ( + ConnectFailedException, + ImouException, + InvalidAppIdOrSecretException, + RequestFailedException, +) +import pytest + +from homeassistant.components.imou.const import ( + CONF_API_URL, + CONF_APP_ID, + CONF_APP_SECRET, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_APP_ID, TEST_APP_SECRET, USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_imou_openapi_client: AsyncMock, +) -> None: + """Test successful user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Imou" + assert result["data"] == USER_INPUT + assert result["result"].unique_id == USER_INPUT[CONF_APP_ID] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_duplicate_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry is aborted.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ConnectFailedException("fail"), "cannot_connect"), + (RequestFailedException("fail"), "cannot_connect"), + (InvalidAppIdOrSecretException("fail"), "invalid_auth"), + (ImouException("fail"), "unknown"), + ], +) +async def test_user_flow_exception_then_recover( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_imou_openapi_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Errors map to stable keys; clearing the failure allows completing the flow.""" + mock_imou_openapi_client.async_get_token.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "errors" in result + assert result["errors"]["base"] == expected_error + + mock_imou_openapi_client.async_get_token.reset_mock(side_effect=True) + + recover = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert recover["type"] is FlowResultType.CREATE_ENTRY + assert recover["title"] == "Imou" + assert recover["data"] == USER_INPUT + assert recover["result"].unique_id == USER_INPUT[CONF_APP_ID] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("region", ["sg", "eu", "na", "cn"]) +async def test_user_flow_success_per_region( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_imou_openapi_client: AsyncMock, + region: str, +) -> None: + """Each supported API region can complete the config flow.""" + user_input = { + CONF_APP_ID: f"{TEST_APP_ID}_{region}", + CONF_APP_SECRET: TEST_APP_SECRET, + CONF_API_URL: region, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Imou" + assert result["data"] == user_input + assert result["result"].unique_id == user_input[CONF_APP_ID] diff --git a/tests/components/imou/test_init.py b/tests/components/imou/test_init.py new file mode 100644 index 0000000000000..15697913ab03d --- /dev/null +++ b/tests/components/imou/test_init.py @@ -0,0 +1,354 @@ +"""Tests for the Imou init.""" + +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import DeviceStatus, ImouHaDevice +import pytest + +from homeassistant.components.imou.button import PARAM_MUTE, PARAM_PTZ_UP +from homeassistant.components.imou.const import DOMAIN, PARAM_STATE, PARAM_STATUS +from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DEFAULT_MOCK_DEVICES, create_offline_device, create_online_device + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("mock_imou_openapi_client", "mock_imou_ha_device_manager") +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + init_integration: MagicMock, +) -> None: + """Test loading and unloading the config entry.""" + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_imou_openapi_client", "mock_imou_ha_device_manager") +async def test_setup_entry_failed_on_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: AsyncMock, +) -> None: + """Device fetch failure during coordinator setup surfaces as setup retry.""" + mock_imou_ha_device_manager.async_get_devices.side_effect = RuntimeError( + "Setup failed" + ) + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("init_integration") +async def test_device_registry_identifiers( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Device registry uses channel-aware identifiers from the default mock devices.""" + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, mock_config_entry.entry_id) + assert len(devices) == 1 + assert (DOMAIN, "d1") in devices[0].identifiers + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "dev-1", + "Cam", + channel_id="ch9", + button_keys=(PARAM_MUTE,), + ), + create_online_device( + "dev-1", + "Cam", + channel_id="ch10", + button_keys=(PARAM_MUTE,), + ), + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_multiple_channels_create_separate_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Each channel gets its own device and button entities in the registries.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + device_ids_by_key = { + next(iter(device.identifiers))[1]: device.id for device in devices + } + assert set(device_ids_by_key) == {"dev-1_ch9", "dev-1_ch10"} + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entries) == 2 + assert {entry.unique_id for entry in entries} == { + "dev-1_ch9$mute", + "dev-1_ch10$mute", + } + for entry in entries: + assert entry.translation_key == PARAM_MUTE + device_key = entry.unique_id.split("$", 1)[0] + assert entry.device_id == device_ids_by_key[device_key] + state = hass.states.get(entry.entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imou_mock_devices", [[]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_adds_entities_after_initial_empty_device_list( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Devices added after an empty first refresh still get entities via callbacks.""" + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 0 + ) + + mock_imou_ha_device_manager.async_get_devices.return_value = DEFAULT_MOCK_DEVICES + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entries) == 3 + assert {entry.unique_id for entry in entries} == { + "d1$mute", + "d1$ptz_up", + "d1$restart_device", + } + + +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_adds_entities_for_new_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """A device added to the Imou account is discovered on the next coordinator refresh.""" + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 3 + ) + + mock_imou_ha_device_manager.async_get_devices.return_value = [ + *DEFAULT_MOCK_DEVICES, + create_online_device("d2", "Device 2", button_keys=(PARAM_PTZ_UP,)), + ] + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entries) == 4 + assert "d2$ptz_up" in {entry.unique_id for entry in entries} + ptz_entry = next(entry for entry in entries if entry.unique_id == "d2$ptz_up") + assert hass.states.get(ptz_entry.entity_id).state != STATE_UNAVAILABLE + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 2 + device_keys = {next(iter(device.identifiers))[1] for device in devices} + assert device_keys == {"d1", "d2"} + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device("d1", "Device 1", button_keys=(PARAM_MUTE,)), + create_online_device("d2", "Device 2", button_keys=(PARAM_PTZ_UP,)), + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_removes_device_updates_registries( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """A removed device is dropped from the device and entity registries.""" + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + ) + == 2 + ) + entries_before = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert {entry.unique_id for entry in entries_before} == { + "d1$mute", + "d2$ptz_up", + } + + mock_imou_ha_device_manager.async_get_devices.return_value = DEFAULT_MOCK_DEVICES + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 1 + assert (DOMAIN, "d1") in devices[0].identifiers + + entries_after = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert {entry.unique_id for entry in entries_after} == {"d1$mute"} + mute_entry = next(entry for entry in entries_after if entry.unique_id == "d1$mute") + assert hass.states.get(mute_entry.entity_id).state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + button_keys=(PARAM_MUTE,), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_offline_device_marked_unavailable_after_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """An offline device reported on refresh marks button entities unavailable.""" + mute_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$mute" + ) + assert hass.states.get(mute_entry.entity_id).state != STATE_UNAVAILABLE + + async def set_device_offline(device: ImouHaDevice) -> None: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + + mock_imou_ha_device_manager.async_update_device_status.side_effect = ( + set_device_offline + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(mute_entry.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_update_fails_when_all_devices_fail( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """When every device status update fails, the coordinator update fails.""" + mute_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$mute" + ) + assert hass.states.get(mute_entry.entity_id).state != STATE_UNAVAILABLE + + mock_imou_ha_device_manager.async_update_device_status.side_effect = ImouException( + "cloud failure" + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.runtime_data.last_update_success is False + assert hass.states.get(mute_entry.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_offline_device( + "d1", + "Device 1", + button_keys=(PARAM_MUTE,), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_offline_device_unavailable_at_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """An offline device marks button entities unavailable via the state machine.""" + mute_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$mute" + ) + assert hass.states.get(mute_entry.entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/openevse/test_number.py b/tests/components/openevse/test_number.py index 495e163e7b69c..09c518a3e375d 100644 --- a/tests/components/openevse/test_number.py +++ b/tests/components/openevse/test_number.py @@ -79,14 +79,24 @@ async def test_set_value( "authentication_error", None, ), - (TimeoutError("timed out"), HomeAssistantError, "communication_error", None), + ( + TimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + ), ( ServerTimeoutError("timed out"), HomeAssistantError, "communication_error", None, ), - (ParseJSONError("bad json"), HomeAssistantError, "communication_error", None), + ( + ParseJSONError("bad json"), + HomeAssistantError, + "communication_error", + None, + ), ( UnsupportedFeature("old firmware"), HomeAssistantError, diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index fd696f67c0ef4..716759acdf578 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -392,6 +392,37 @@ async def test_blindtilt_controlling( assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 +async def test_blindtilt_idle_advertisement( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test blindtilt handles BLE advertisement without motionDirection.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + new=AsyncMock(return_value={}), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"x\x00*" + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%F\x12\x85" + + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + # Should not crash; entity should still exist + state = hass.states.get(entity_id) + assert state is not None + + async def test_roller_shade_setup( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] ) -> None: diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 33c2b250c3cd0..a20a2cc9b79c1 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -1,6 +1,5 @@ """The tests for the Template button platform.""" -import datetime as dt from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -24,6 +23,7 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from .conftest import ( ConfigurationStyle, @@ -145,7 +145,7 @@ async def test_missing_emtpy_press_action_config( """Test: missing optional template is ok.""" _verify(hass, STATE_UNKNOWN) - now = dt.datetime.now(dt.UTC) # pylint: disable=home-assistant-enforce-utcnow + now = dt_util.utcnow() freezer.move_to(now) await hass.services.async_call( BUTTON_DOMAIN, @@ -196,7 +196,7 @@ async def test_device_class_option( TEST_BUTTON.entity_id, ) - now = dt.datetime.now(dt.UTC) # pylint: disable=home-assistant-enforce-utcnow + now = dt_util.utcnow() freezer.move_to(now) await hass.services.async_call( BUTTON_DOMAIN, @@ -252,7 +252,7 @@ async def test_options_that_are_templates( _verify(hass, STATE_UNKNOWN, expected_attributes) - now = dt.datetime.now(dt.UTC) # pylint: disable=home-assistant-enforce-utcnow + now = dt_util.utcnow() freezer.move_to(now) await hass.services.async_call( BUTTON_DOMAIN, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index e2f76d330f12b..0b66d62139d2d 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2868,6 +2868,83 @@ async def test_subscribe_condition( } +@pytest.mark.parametrize( + ("value_template", "expected_event"), + [ + # Undefined variable used in a way that raises: forwarded as an error, + # with the underlying template error included. + ( + "{{ trigger.to_state.attributes.event_type == 'double_press' }}", + { + "error": "In 'template' condition: UndefinedError: 'trigger' is undefined", + "template_errors": ["'trigger' is undefined"], + }, + ), + # Undefined variable used in a way that only warns: the condition still + # evaluates to a result, but the template error is forwarded alongside it. + ( + "{{ no_such_variable }}", + {"result": False, "template_errors": ["'no_such_variable' is undefined"]}, + ), + # A single render emitting multiple errors forwards all of them. + ( + "{{ foo }}{{ bar }}", + { + "result": False, + "template_errors": ["'foo' is undefined", "'bar' is undefined"], + }, + ), + ], +) +async def test_subscribe_condition_template_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + value_template: str, + expected_event: dict[str, Any], +) -> None: + """Test template errors are forwarded as events and don't spam the log.""" + caplog.set_level(logging.WARNING) + + await websocket_client.send_json_auto_id( + { + "type": "subscribe_condition", + "condition": { + "condition": "template", + "value_template": value_template, + }, + } + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + subscription_id = msg["id"] + + msg = await websocket_client.receive_json() + assert msg == { + "id": subscription_id, + "type": "event", + "event": expected_event, + } + + # Let the condition be evaluated a few more times + for _ in range(5): + freezer.tick(1.1) + await hass.async_block_till_done() + + # The unchanged result/error is not re-sent; a ping is the next message + await websocket_client.send_json_auto_id({"type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["type"] == "pong" + + # The template error is forwarded, not logged + assert "Template variable warning" not in caplog.text + assert "Template variable error" not in caplog.text + + @pytest.mark.parametrize( ("condition", "expected_error"), [ @@ -2892,27 +2969,67 @@ async def test_subscribe_condition( ), }, ), - # Validated by async_validate_condition_config + ], +) +async def test_subscribe_condition_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + condition: dict, + expected_error: dict, +) -> None: + """Test subscribing to a condition.""" + hass.states.async_set("hello.world", "paulus") + + await websocket_client.send_json_auto_id( + {"type": "subscribe_condition", "condition": condition} + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"] == expected_error + + +@pytest.mark.parametrize( + ("condition", "expected_error"), + [ + # Missing mandatory config, raised by async_validate_condition_config ( {"condition": "sun"}, { "code": "invalid_format", "message": ( "must contain at least one of before, after. for dictionary value " - "@ data['options']. Got None" + "@ data['options']" + ), + }, + ), + # Failing enabled template, raised by async_condition_from_config + ( + { + "condition": "template", + "value_template": "{{ true }}", + "enabled": "{{ 1 / 0 }}", + }, + { + "code": "home_assistant_error", + "message": ( + "Error rendering condition enabled template: " + "ZeroDivisionError: division by zero" ), }, ), ], ) -async def test_subscribe_condition_error( +async def test_subscribe_condition_config_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, condition: dict, expected_error: dict, ) -> None: - """Test subscribing to a condition.""" - hass.states.async_set("hello.world", "paulus") + """Test condition config errors are reported to the client without logging.""" + caplog.set_level(logging.ERROR) await websocket_client.send_json_auto_id( {"type": "subscribe_condition", "condition": condition} @@ -2923,6 +3040,9 @@ async def test_subscribe_condition_error( assert not msg["success"] assert msg["error"] == expected_error + # The expected error is not logged by the default websocket error handler + assert "Error handling message" not in caplog.text + async def test_execute_script( hass: HomeAssistant, websocket_client: MockHAClientWebSocket diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index 391593019b33d..d2b944b8862be 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -1,12 +1,29 @@ """The tests for the location condition.""" +from datetime import timedelta +from typing import Any + +from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol from homeassistant.components.zone import condition as zone_condition +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + async def test_zone_raises(hass: HomeAssistant) -> None: """Test that zone raises ConditionError on errors.""" @@ -206,3 +223,323 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, ) assert not test.async_check() + + +# --- New-style zone condition tests --- + +ZONE_HOME = "zone.home" +ZONE_WORK = "zone.work" +IN_ZONES_HOME = {"in_zones": [ZONE_HOME]} +IN_ZONES_WORK = {"in_zones": [ZONE_WORK]} +IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []} +TARGET_ZONE = ZONE_HOME + + +@pytest.mark.parametrize( + ( + "condition_key", + "base_options", + "supports_behavior", + "supports_duration", + "supports_target", + ), + [ + ("zone.in_zone", {"zone": TARGET_ZONE}, True, True, True), + ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True, True), + ("zone.occupancy_is_detected", {"zone": ZONE_HOME}, False, True, False), + ("zone.occupancy_is_not_detected", {"zone": ZONE_HOME}, False, True, False), + ], +) +async def test_zone_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, + supports_target: bool, +) -> None: + """Test that zone conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + supports_target=supports_target, + ) + + +@pytest.mark.parametrize( + ("condition_key", "config"), + [ + ( + "zone.in_zone", + {"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}}, + ), + ( + "zone.not_in_zone", + {"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}}, + ), + ( + "zone.occupancy_is_detected", + {"options": {"zone": "light.x"}}, + ), + ( + "zone.occupancy_is_not_detected", + {"options": {"zone": "light.x"}}, + ), + ], +) +async def test_zone_condition_rejects_non_zone_entity_id( + hass: HomeAssistant, condition_key: str, config: dict[str, Any] +) -> None: + """Test that the zone option must reference entities in the zone domain.""" + with pytest.raises(vol.Invalid): + await condition.async_validate_condition_config( + hass, + {"condition": condition_key, **config}, + ) + + +@pytest.fixture +async def target_zone_entities( + hass: HomeAssistant, domain: str +) -> dict[str, list[str]]: + """Create multiple zone-trackable entities associated with different targets.""" + return await target_entities(hass, domain, domain_excluded="sensor") + + +# `in_zone` is True for states where the entity carries the target zone in +# `in_zones`; `not_in_zone` flips the relation. +_ZONE_CONDITION_STATES_ANY = [ + *parametrize_condition_states_any( + condition="zone.in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + excluded_entities_from_other_domain=True, + ), + *parametrize_condition_states_any( + condition="zone.not_in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + excluded_entities_from_other_domain=True, + ), +] + + +_ZONE_CONDITION_STATES_ALL = [ + *parametrize_condition_states_all( + condition="zone.in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + excluded_entities_from_other_domain=True, + ), + *parametrize_condition_states_all( + condition="zone.not_in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + excluded_entities_from_other_domain=True, + ), +] + + +def _parametrize_zone_target_entities() -> list[tuple[dict[str, Any], str, int, str]]: + """Parametrize target entities for all supported zone condition domains.""" + return [ + (*params, domain) + for domain in ("person", "device_tracker") + for params in parametrize_target_entities(domain) + ] + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + _ZONE_CONDITION_STATES_ANY, +) +async def test_zone_condition_behavior_any( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test zone conditions under behavior=any.""" + await assert_condition_behavior_any( + hass, + target_entities=target_zone_entities, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + _ZONE_CONDITION_STATES_ALL, +) +async def test_zone_condition_behavior_all( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test zone conditions under behavior=all.""" + await assert_condition_behavior_all( + hass, + target_entities=target_zone_entities, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +async def test_in_zone_condition_for_attribute_only_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test `for:` anchors to in_zones updates, not state.state changes. + + A person already "home" who enters an overlapping zone (e.g. zone.coffee) + keeps state.state == "home" while in_zones grows. `for: 5m` on + in_zone(zone.coffee) must start counting from when in_zones changed, not + from the (older) last state.state transition. + """ + coffee_zone = "zone.coffee" + + # Person at home but not yet in the coffee zone. + hass.states.async_set( + "person.alice", + "home", + {"in_zones": [ZONE_HOME]}, + ) + await hass.async_block_till_done() + + # Time passes — state.state's last_changed sits 10 minutes in the past. + freezer.tick(timedelta(minutes=10)) + + config = await condition.async_validate_condition_config( + hass, + { + "condition": "zone.in_zone", + "target": {"entity_id": "person.alice"}, + "options": {"zone": coffee_zone, "for": {"minutes": 5}}, + }, + ) + test = await condition.async_from_config(hass, config) + + # in_zones gains the coffee zone; state.state stays "home", so last_changed + # is untouched and only last_updated advances. + hass.states.async_set( + "person.alice", + "home", + {"in_zones": [ZONE_HOME, coffee_zone]}, + ) + await hass.async_block_till_done() + + # Just entered; `for: 5m` must not be satisfied yet. (Without value_source + # set on the DomainSpec, the anchor would be last_changed from 10 minutes + # ago and this would incorrectly evaluate to True.) + assert test.async_check() is False + + # After the duration elapses, the condition is satisfied. + freezer.tick(timedelta(minutes=6)) + assert test.async_check() is True + + +# --- Zone occupancy condition tests --- + + +@pytest.mark.parametrize( + ("condition_key", "zone_state", "expected"), + [ + # occupancy_is_detected — true when count >= 1 + pytest.param("zone.occupancy_is_detected", "1", True, id="detected_1"), + pytest.param("zone.occupancy_is_detected", "3", True, id="detected_3"), + pytest.param("zone.occupancy_is_detected", "0", False, id="detected_0"), + pytest.param( + "zone.occupancy_is_detected", + STATE_UNAVAILABLE, + False, + id="detected_unavailable", + ), + pytest.param( + "zone.occupancy_is_detected", STATE_UNKNOWN, False, id="detected_unknown" + ), + # occupancy_is_not_detected — true only when count == 0 + pytest.param("zone.occupancy_is_not_detected", "0", True, id="empty_0"), + pytest.param("zone.occupancy_is_not_detected", "1", False, id="empty_1"), + pytest.param("zone.occupancy_is_not_detected", "3", False, id="empty_3"), + # Unavailable / unknown are not "empty" — they're indeterminate. + pytest.param( + "zone.occupancy_is_not_detected", + STATE_UNAVAILABLE, + False, + id="empty_unavailable", + ), + pytest.param( + "zone.occupancy_is_not_detected", + STATE_UNKNOWN, + False, + id="empty_unknown", + ), + ], +) +async def test_zone_occupancy_condition_evaluates( + hass: HomeAssistant, + condition_key: str, + zone_state: str, + expected: bool, +) -> None: + """Test occupancy conditions evaluate against the zone's integer state.""" + hass.states.async_set(ZONE_HOME, zone_state) + await hass.async_block_till_done() + + config = await condition.async_validate_condition_config( + hass, {"condition": condition_key, "options": {"zone": ZONE_HOME}} + ) + test = await condition.async_from_config(hass, config) + assert test.async_check() is expected diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2e4146213ac49..6fd2c9b117307 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2205,6 +2205,90 @@ async def test_condition_template_error(hass: HomeAssistant) -> None: test.async_check() +@pytest.mark.parametrize( + ("value_template", "expectation", "expected_template_errors", "expected_result"), + [ + # Undefined variable used in a way that raises (e.g. attribute access) + ( + "{{ trigger.to_state.attributes.event_type == 'double_press' }}", + pytest.raises(ConditionError), + ["'trigger' is undefined"], + {}, + ), + # Undefined variable used in a way that only warns + ( + "{{ no_such_variable }}", + does_not_raise(), + ["'no_such_variable' is undefined"], + {"result": False, "entities": []}, + ), + # A single render can emit more than one message + ( + "{{ foo }}{{ bar }}", + does_not_raise(), + ["'foo' is undefined", "'bar' is undefined"], + {"result": False, "entities": []}, + ), + ], +) +async def test_condition_template_error_traced_not_logged( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + value_template: str, + expectation: AbstractContextManager, + expected_template_errors: list[str], + expected_result: dict[str, Any], +) -> None: + """Test template errors are added to the trace and not logged when opted in. + + The subscribe_condition websocket command re-evaluates a condition every + second and opts in via trace.record_template_errors(). Template variable + errors must then be recorded in the trace instead of being logged repeatedly. + """ + caplog.set_level(logging.WARNING) + config = {"condition": "template", "value_template": value_template} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + with expectation, trace.record_template_errors(): + test.async_check() + + # The template errors are recorded in the trace... + condition_trace = trace.trace_get(clear=False) + trace.trace_clear() + trace_element = condition_trace[""][0] + assert trace_element.template_errors == expected_template_errors + assert (trace_element._result or {}) == expected_result + + # ...and not logged + assert "Template variable" not in caplog.text + + +async def test_condition_template_error_logged_without_opt_in( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test template errors are logged when recording is not opted in. + + An active trace is not enough to suppress logging; the consumer must opt in + via trace.record_template_errors(). Without it, the error is logged as usual + and not recorded in the trace. + """ + caplog.set_level(logging.WARNING) + config = {"condition": "template", "value_template": "{{ no_such_variable }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + assert test.async_check() is False + + assert "Template variable warning: 'no_such_variable' is undefined" in caplog.text + condition_trace = trace.trace_get(clear=False) + trace.trace_clear() + assert condition_trace[""][0].template_errors == [] + + async def test_condition_template_invalid_results(hass: HomeAssistant) -> None: """Test template condition render false with invalid results.""" config = {"condition": "template", "value_template": "{{ 'string' }}"} diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e6a9c1bf5ddbd..f30b9d2b838c5 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -2084,3 +2084,39 @@ def test_base_schemas_reject_invalid_note( """Test that script, condition, trigger base schemas reject non-string notes.""" with pytest.raises(vol.Invalid): validator({**base_config, "note": invalid_note}) + + +_CHOOSE_OPTION_BASE_CONFIG = { + "conditions": [ + {"condition": "state", "entity_id": "sun.sun", "state": "above_horizon"} + ], + "sequence": [{"action": "test.foo"}], +} + + +@pytest.mark.usefixtures("hass") +def test_choose_option_accepts_note() -> None: + """Test that the note field is accepted and stripped from a choose option.""" + validated = cv.script_action( + {"choose": [{**_CHOOSE_OPTION_BASE_CONFIG, "note": "Single line"}]} + ) + assert "note" not in validated["choose"][0] + + +@pytest.mark.parametrize( + "invalid_note", + [ + pytest.param(None, id="none"), + pytest.param(42, id="int"), + pytest.param(True, id="bool"), + pytest.param([], id="list"), + pytest.param({}, id="dict"), + ], +) +@pytest.mark.usefixtures("hass") +def test_choose_option_rejects_invalid_note(invalid_note: Any) -> None: + """Test that choose option schemas reject non-string notes.""" + with pytest.raises(vol.Invalid): + cv.script_action( + {"choose": [{**_CHOOSE_OPTION_BASE_CONFIG, "note": invalid_note}]} + )