Compare commits

..

28 Commits

Author SHA1 Message Date
Erik 01e17ae2bd Always include template errors in trace 2026-06-03 16:17:40 +02:00
Erik Montnemery 37b4bcaa39 Don't log condition errors when executing WS test_condition (#172897) 2026-06-03 16:11:54 +02:00
Bram Kragten 6bda3ea3a5 Update frontend to 20260527.4 (#172907) 2026-06-03 14:17:30 +02:00
Sören f4db5fb346 Add Avea Bluetooth reachability diagnostics (#172898) 2026-06-03 13:43:33 +02:00
Heikki Henriksen f04b0ee2c6 prusalink: guard non-string original in config_flow workaround (#172375) 2026-06-03 11:47:19 +02:00
Erik Montnemery 52c3e17de9 Add zone occupancy conditions (#172896) 2026-06-03 11:20:13 +02:00
Imou-OpenPlatform 96c286f2e0 Add Imou integration (#161412)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-03 11:18:31 +02:00
renovate[bot] 3e356de4e1 Update pytest-asyncio to 1.4.0 (#172886) 2026-06-03 11:17:32 +02:00
Erik Montnemery 90a874d81b Use zone DOMAIN constant in zone triggers (#172894) 2026-06-03 11:15:50 +02:00
Erik Montnemery 165024c6c9 Catch errors when setting up condition in WS subscribe_condition (#172895) 2026-06-03 11:06:18 +02:00
Erik Montnemery 66e4db3c0e Add zone conditions in / not in zone (#172810) 2026-06-03 10:40:43 +02:00
Franck Nijhof 0e6128c657 Fix SwitchBot Blind Tilt KeyError on idle BLE advertisements (#172816) 2026-06-03 10:37:16 +02:00
Wendelin 16febb36ba Automation choose: Add optional note to options (#172837) 2026-06-03 10:12:52 +02:00
Erik Montnemery dd7bd0c8a4 Prevent log spam when WS subscribe_condition is active (#172832) 2026-06-03 10:10:30 +02:00
Petro31 c462a1c188 Add translations for template device trackers in_zones option (#172850) 2026-06-03 08:38:18 +02:00
fdebrus 96c5110b7e Vistapool: flip docs-related quality-scale rules to done (#172827)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 08:04:52 +02:00
Colin 64e8ed2737 Add missing translation keys to openevse (#172802) 2026-06-03 08:03:30 +02:00
renovate[bot] 4171d092f7 Update coverage to 7.14.1 (#172878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 07:59:49 +02:00
J. Nick Koston 7af867ad4d Avoid double-decoding websocket_api TEXT frames with decode_text (#172891) 2026-06-03 07:52:02 +02:00
Paulus Schoutsen 907fe40304 Regenerate mdi_icons.py for frontend 20260527.3 (#172887) 2026-06-03 05:56:21 +02:00
Petro31 261914c592 Use dt_util.utcnow() instead of datetime.now(UTC) in template tests (#172852) 2026-06-03 05:32:12 +02:00
David Bonnes 09637c1a3a Use dt_util.utcnow() instead of datetime.now(UTC) in evohome (#172868) 2026-06-03 05:31:07 +02:00
Bram Kragten 0816385185 Bump frontend to 20260527.3 (#172873) 2026-06-03 05:20:42 +02:00
renovate[bot] 4b64b26870 Update infrared-protocols to 5.8.1 (#172870) 2026-06-02 22:43:22 +01:00
Joost Lekkerkerker b20f9ad40a Bump pySmartThings to 4.0.0 (#172858) 2026-06-02 22:36:15 +02:00
Ronald van der Meer 99d279bdd8 Simplify Duco sensor tests (#172501) 2026-06-02 22:25:34 +02:00
jameson_uk 69e0e11077 Bump aioamazondevices to 14.0.0 (#172857) 2026-06-02 20:45:55 +01:00
Pete Sage 9e3c143bd0 Log warning on unsupported announce media formats for Sonos (#172614)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-02 21:27:54 +02:00
69 changed files with 3109 additions and 212 deletions
Generated
+2
View File
@@ -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
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.2"]
"requirements": ["aioamazondevices==14.0.0"]
}
+19 -5
View File
@@ -2,12 +2,18 @@
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -15,12 +21,20 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
address = entry.data[CONF_ADDRESS]
ble_device = async_ble_device_from_address(hass, address, connectable=True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
entry.runtime_data = avea.Bulb(ble_device)
@@ -22,6 +22,11 @@
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Avea device with address {address}: {reason}"
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
+2 -2
View File
@@ -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 evohomeasync2.schemas.typedefs import DayOfWeekDhwT
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 @@ class EvoChild(EvoEntity):
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()
+3 -3
View File
@@ -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 evohomeasync2.auth import AbstractTokenManager
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 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
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)
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.2"]
"requirements": ["home-assistant-frontend==20260527.4"]
}
+42
View File
@@ -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)
+109
View File
@@ -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
@@ -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,
)
+39
View File
@@ -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]
@@ -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)
+59
View File
@@ -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
)
+18
View File
@@ -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"
}
}
}
}
@@ -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"]
}
@@ -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
@@ -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)"
}
}
}
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==5.8.0"]
"requirements": ["infrared-protocols==5.8.1"]
}
@@ -273,7 +273,6 @@ ABBREVIATIONS = {
"l_ver_t": "latest_version_topic",
"l_ver_tpl": "latest_version_template",
"pl_inst": "payload_install",
"vis": "visible_by_default",
}
DEVICE_ABBREVIATIONS = {
-1
View File
@@ -246,7 +246,6 @@ CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
CONF_TRANSITION = "transition"
CONF_URL_TEMPLATE = "url_template"
CONF_URL_TOPIC = "url_topic"
CONF_VISIBLE_BY_DEFAULT = "visible_by_default"
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
CONF_XY_STATE_TOPIC = "xy_state_topic"
+1 -12
View File
@@ -95,7 +95,6 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DOMAIN,
MQTT_CONNECTION_STATE,
)
@@ -1442,9 +1441,6 @@ class MqttEntity(
entity_registry.async_update_entity(
recreated_entry.entity_id,
disabled_by=None,
hidden_by=None
if self._config[CONF_VISIBLE_BY_DEFAULT]
else er.RegistryEntryHider.INTEGRATION,
)
if discovery_data is None:
@@ -1475,11 +1471,7 @@ class MqttEntity(
if self._update_registry_entity_id is not None:
entity_registry = er.async_get(self.hass)
entity_registry.async_update_entity(
self.entity_id,
new_entity_id=self._update_registry_entity_id,
hidden_by=None
if self._config[CONF_VISIBLE_BY_DEFAULT]
else er.RegistryEntryHider.INTEGRATION,
self.entity_id, new_entity_id=self._update_registry_entity_id
)
self._update_registry_entity_id = None
@@ -1597,9 +1589,6 @@ class MqttEntity(
self._attr_entity_registry_enabled_default = bool(
config.get(CONF_ENABLED_BY_DEFAULT, True)
)
self._attr_entity_registry_visible_default = bool(
config.get(CONF_VISIBLE_BY_DEFAULT, True)
)
self._attr_icon = config.get(CONF_ICON)
self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE)
# Set the entity name if needed
-2
View File
@@ -52,7 +52,6 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
ENTITY_PLATFORMS,
@@ -185,7 +184,6 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_VISIBLE_BY_DEFAULT, default=True): cv.boolean,
}
)
@@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant
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()
@@ -63,7 +63,11 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[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
@@ -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."
@@ -2,7 +2,7 @@
import asyncio
import logging
from typing import Any, cast
from typing import Any
from awesomeversion import AwesomeVersion, AwesomeVersionException
from httpx import HTTPError, InvalidURL
@@ -41,7 +41,8 @@ def ensure_printer_is_supported(version: VersionInfo) -> None:
# Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports
# the 2.0.0 API, but doesn't advertise it yet
original = cast(str, version.get("original", ""))
original_value = version.get("original")
original = original_value if isinstance(original_value, str) else ""
if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and (
AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"])
):
@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.7.3"]
"requirements": ["pysmartthings==4.0.0"]
}
@@ -3,7 +3,9 @@
import datetime
from functools import partial
import logging
import os
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from soco import SoCo, alarms
from soco.core import (
@@ -90,6 +92,7 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"]
ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"})
async def async_setup_entry(
@@ -460,6 +463,15 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
volume = kwargs.get("extra", {}).get("volume")
ext = os.path.splitext(urlparse(media_id).path)[1].lower()
if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS:
_LOGGER.warning(
"Sonos AudioClip announce only supports MP3 and WAV; "
"%s has extension %s and will be attempted as a clip anyway on %s",
media_id,
ext,
self.speaker.zone_name,
)
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
assert self.speaker.websocket
+2 -2
View File
@@ -218,8 +218,8 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
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()
@@ -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%]"
},
@@ -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
@@ -39,6 +39,7 @@ from homeassistant.helpers import (
entity,
target as target_helpers,
template,
trace,
)
from homeassistant.helpers.condition import (
async_from_config as async_condition_from_config,
@@ -1026,14 +1027,53 @@ async def handle_test_condition(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle test condition command."""
# Do static + dynamic validation of the condition
config = await async_validate_condition_config(hass, msg["condition"])
# Test the condition
condition = await async_condition_from_config(hass, config)
# Validating and instantiating the condition can fail on bad user input.
# Handle those errors here so they are reported to the client without being
# logged as unexpected errors by the default websocket error handler.
try:
connection.send_result(
msg["id"], {"result": condition.async_check(variables=msg.get("variables"))}
# Do static + dynamic validation of the condition
config = await async_validate_condition_config(hass, msg["condition"])
condition = await async_condition_from_config(hass, 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
# Template errors (e.g. undefined variables) are recorded in the trace
# instead of being logged. Capture the trace and forward them to the client
# alongside the result.
condition_trace = trace.trace_get()
try:
with trace.suppress_template_error_logging():
check_result = condition.async_check(variables=msg.get("variables"))
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,
)
else:
result: dict[str, Any] = {"result": check_result}
if template_errors := [
template_error
for elements in condition_trace.values()
for element in elements
for template_error in element.template_errors
]:
result["template_errors"] = template_errors
connection.send_result(msg["id"], result)
finally:
condition.async_unload()
@@ -1050,9 +1090,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 +1115,24 @@ async def handle_subscribe_condition(
nonlocal event_data
new_event_data: dict[str, Any]
condition_trace = trace.trace_get()
try:
new_event_data = {"result": condition.async_check()}
with trace.suppress_template_error_logging():
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
@@ -92,7 +92,9 @@ class WebSocketHandler:
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
+128 -2
View File
@@ -4,12 +4,15 @@ from typing import Any, Unpack, cast
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.const import (
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 @@ class ZoneCondition(Condition):
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
@@ -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
+14
View File
@@ -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"
@@ -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.",
+3 -2
View File
@@ -43,6 +43,7 @@ from homeassistant.helpers.trigger import (
from homeassistant.helpers.typing import ConfigType
from . import condition
from .const import DOMAIN
EVENT_ENTER = "enter"
EVENT_LEAVE = "leave"
@@ -68,7 +69,7 @@ _LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
_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 @@ class LeftZoneTrigger(ZoneTriggerBase):
_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,
},
}
+1
View File
@@ -348,6 +348,7 @@ FLOWS = {
"imeon_inverter",
"imgw_pib",
"immich",
"imou",
"improv_ble",
"incomfort",
"indevolt",
@@ -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",
@@ -1953,6 +1953,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema(
[
{
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,
}
@@ -24,6 +24,11 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.trace import (
suppress_template_error_logging_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,14 @@ def make_logging_undefined(
return jinja2.StrictUndefined
def _log_with_logger(level: int, msg: str) -> None:
# Record the error on the active trace element so it is surfaced in the
# trace. Consumers such as the subscribe_condition websocket command can
# opt in to additionally suppress the (otherwise repeated) log entry.
if node := trace_stack_top(trace_stack_cv):
node.add_template_error(msg)
if suppress_template_error_logging_cv.get():
return
template, action = template_cv.get() or ("", "rendering or compiling")
_LOGGER.log(
level,
+59 -2
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable, Coroutine, Generator
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 @@ class TraceElement:
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 @@ class TraceElement:
"""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 @@ class TraceElement:
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,27 @@ trace_id_cv: ContextVar[tuple[str, str] | None] = ContextVar(
script_execution_cv: ContextVar[StopReason | None] = ContextVar(
"script_execution_cv", default=None
)
# When set, template errors recorded on the active TraceElement are not also
# logged. Template errors are always recorded in the trace regardless.
suppress_template_error_logging_cv: ContextVar[bool] = ContextVar(
"suppress_template_error_logging_cv", default=False
)
@contextmanager
def suppress_template_error_logging() -> Generator[None]:
"""Suppress logging of template errors that are recorded in the trace.
Template errors are always recorded on the active trace element. Consumers
such as the subscribe_condition websocket command, which re-evaluate a
condition repeatedly and forward template errors to the client via the
trace, can use this to also stop the errors from spamming the log.
"""
token = suppress_template_error_logging_cv.set(True)
try:
yield
finally:
suppress_template_error_logging_cv.reset(token)
def trace_id_set(trace_id: tuple[str, str]) -> None:
@@ -189,8 +231,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()
+1 -1
View File
@@ -39,7 +39,7 @@ habluetooth==6.8.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.2
home-assistant-frontend==20260527.4
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260527.2"
FRONTEND_VERSION: Final[str] = "20260527.4"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+1 -1
View File
@@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
infrared-protocols==5.8.0
infrared-protocols==5.8.1
Jinja2==3.1.6
lru-dict==1.4.1
mutagen==1.47.0
+7 -4
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.8.2
aioamazondevices==14.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1269,7 +1269,7 @@ hole==0.9.0
holidays==0.97
# homeassistant.components.frontend
home-assistant-frontend==20260527.2
home-assistant-frontend==20260527.4
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -1362,7 +1362,7 @@ influxdb-client==1.50.0
influxdb==5.3.1
# homeassistant.components.infrared
infrared-protocols==5.8.0
infrared-protocols==5.8.1
# homeassistant.components.inkbird
inkbird-ble==1.4.4
@@ -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
@@ -2549,7 +2552,7 @@ pysmappee==0.2.29
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==3.7.3
pysmartthings==4.0.0
# homeassistant.components.smarty
pysmarty2==0.10.3
+2 -2
View File
@@ -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
+2 -2
View File
@@ -113,7 +113,7 @@ TEST_DEVICE_2 = AmazonDevice(
TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord(
timestamp=1000,
utterance_type="WAKE_WORD_UTTERANCE",
history_type="WAKE_WORD_UTTERANCE",
intent="PlayMusicIntent",
title="Play some music",
sub_title="Echo Test",
@@ -121,7 +121,7 @@ TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord(
TEST_VOCAL_RECORD_EVENT = AmazonVocalRecord(
timestamp=1234567890,
utterance_type="WAKE_WORD_UTTERANCE",
history_type="WAKE_WORD_UTTERANCE",
intent="PlayMusicIntent",
title="Play some music",
sub_title="Echo Test",
+18 -3
View File
@@ -2,8 +2,11 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.avea.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
@@ -50,19 +53,31 @@ async def _setup_yaml_import(hass: HomeAssistant, bulbs: list[MagicMock]) -> Non
async def test_setup_entry_retries_when_ble_device_is_missing(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup retries when the Bluetooth device is unavailable."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=None,
with (
patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=None,
),
patch(
"homeassistant.components.avea.async_address_reachability_diagnostics",
return_value="mock reachability reason",
),
):
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
assert (
"Could not find Avea device with address "
f"{mock_config_entry.data[CONF_ADDRESS]}: mock reachability reason"
in caplog.text
)
async def test_yaml_import_creates_entries_for_discovered_bulbs(
+29 -10
View File
@@ -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 @@ async def assert_condition_options_supported(
# 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,
)
+13 -25
View File
@@ -83,33 +83,22 @@ async def test_diagnostic_sensor_entities_disabled_by_default(
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_update_marks_unavailable(
@pytest.mark.parametrize(
("exception_type", "exception_message"),
[
pytest.param(DucoConnectionError, "offline", id="connection_error"),
pytest.param(DucoError, "api error", id="duco_error"),
],
)
async def test_coordinator_update_failure_marks_unavailable(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
exception_type: type[DucoError],
exception_message: str,
) -> None:
"""Test that sensor entities become unavailable when the coordinator fails."""
mock_duco_client.async_get_nodes = AsyncMock(
side_effect=DucoConnectionError("offline")
)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.office_co2_carbon_dioxide")
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_update_duco_error_marks_unavailable(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensor entities become unavailable when async_get_nodes raises DucoError."""
mock_duco_client.async_get_nodes = AsyncMock(side_effect=DucoError("api error"))
"""Test sensor entities become unavailable when the coordinator update fails."""
mock_duco_client.async_get_nodes.side_effect = exception_type(exception_message)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
@@ -198,10 +187,9 @@ async def test_deregistered_node_removes_device(
mock_sensor_nodes: list[Node],
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test a node disappearing from the API removes its device from the registry."""
device_registry = dr.async_get(hass)
# Verify node 2 (UCCO2 RF sensor) device exists before deregistration.
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{mock_config_entry.unique_id}_2")}
+1
View File
@@ -0,0 +1 @@
"""Tests for the Imou integration."""
+89
View File
@@ -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
+95
View File
@@ -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),
),
]
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.device_1_mute',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.device_1_mute',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'button.device_1_ptz_up',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button_entities_snapshot[button.device_1_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.device_1_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Restart',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.RESTART: 'restart'>,
'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': <ANY>,
'entity_id': 'button.device_1_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+220
View File
@@ -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
+152
View File
@@ -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]
+354
View File
@@ -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
-78
View File
@@ -541,7 +541,6 @@ async def test_registry_enable_not_enabled_by_default_entity(
"name": None,
"state_topic": "state-topic",
"enabled_by_default": False,
"visible_by_default": True,
"unique_id": "very_unique",
"default_entity_id": "sensor.test",
"device": {"identifiers": "very_unique_device", "name": "test"},
@@ -552,7 +551,6 @@ async def test_registry_enable_not_enabled_by_default_entity(
"name": None,
"state_topic": "state-topic",
"enabled_by_default": True,
"visible_by_default": False,
"unique_id": "very_unique",
"default_entity_id": "sensor.test",
"device": {"identifiers": "very_unique_device", "name": "test"},
@@ -563,7 +561,6 @@ async def test_registry_enable_not_enabled_by_default_entity(
"name": None,
"state_topic": "state-topic",
"enabled_by_default": True,
"visible_by_default": True,
"unique_id": "very_unique",
"default_entity_id": "sensor.test_new",
"device": {"identifiers": "very_unique_device", "name": "test"},
@@ -577,7 +574,6 @@ async def test_registry_enable_not_enabled_by_default_entity(
entry = entity_registry.async_get("sensor.test")
assert entry is not None
assert entry.disabled
assert entry.hidden is False
assert (device_id := entry.device_id)
assert device_registry.async_get(device_id) is not None
@@ -591,7 +587,6 @@ async def test_registry_enable_not_enabled_by_default_entity(
assert device_registry.async_get(device_id) is None
# Rediscover the previous deleted entity and allow it to be enabled
# but not visible by default
async_fire_mqtt_message(hass, discovery_topic, config_enabled)
await hass.async_block_till_done()
state = hass.states.get("sensor.test")
@@ -599,12 +594,10 @@ async def test_registry_enable_not_enabled_by_default_entity(
entry = entity_registry.async_get("sensor.test")
assert entry is not None
assert not entry.disabled
assert entry.hidden is True
assert device_registry.async_get(device_id) is not None
# Update entity to not be enabled by default
# The entity should stay available as it was enabled before
# Also it should remain hidden
async_fire_mqtt_message(hass, discovery_topic, config_disabled)
await hass.async_block_till_done()
state = hass.states.get("sensor.test")
@@ -612,7 +605,6 @@ async def test_registry_enable_not_enabled_by_default_entity(
entry = entity_registry.async_get("sensor.test")
assert entry is not None
assert not entry.disabled
assert entry.hidden is True
assert device_registry.async_get(device_id) is not None
# Delete the entity again
@@ -624,86 +616,16 @@ async def test_registry_enable_not_enabled_by_default_entity(
assert device_registry.async_get(device_id) is None
# Repeat the re-discovery, with a new entity name
# The entity should be visible by default now
async_fire_mqtt_message(hass, discovery_topic, config_enabled_new_entity_name)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_new")
assert state is not None
entry = entity_registry.async_get("sensor.test_new")
assert entry is not None
assert entry.hidden is False
assert not entry.disabled
assert device_registry.async_get(device_id) is not None
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
sensor.DOMAIN: {
"name": "test",
"state_topic": "state-topic",
"unique_id": "very_unique",
}
}
},
{
mqtt.DOMAIN: {
sensor.DOMAIN: {
"name": "test",
"state_topic": "state-topic",
"visible_by_default": True,
"unique_id": "very_unique",
}
}
},
],
)
async def test_registry_visible_by_default(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test an entity that is visible by default or not."""
await mqtt_mock_entry()
state = hass.states.get("sensor.test")
assert state is not None
entry = entity_registry.async_get("sensor.test")
assert not entry.disabled
assert entry.hidden is False
assert entry.hidden_by is None
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
sensor.DOMAIN: {
"name": "test",
"state_topic": "state-topic",
"visible_by_default": False,
"unique_id": "very_unique",
}
}
},
],
)
async def test_registry_not_visible_by_default(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test an entity that is not visible by default."""
await mqtt_mock_entry()
state = hass.states.get("sensor.test")
assert state is not None
entry = entity_registry.async_get("sensor.test")
assert entry.hidden is True
assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION
@pytest.mark.parametrize(
"mqtt_config_subentries_data",
[
+12 -2
View File
@@ -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,
@@ -1,6 +1,7 @@
"""Tests for the Sonos Media Player platform."""
from collections.abc import Generator
import logging
from typing import Any
from unittest.mock import MagicMock, patch
@@ -1347,6 +1348,61 @@ async def test_play_media_announce(
soco.play_uri.assert_called_with(content_id, force_radio=False)
@pytest.mark.parametrize(
("content_id", "expect_warning"),
[
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123.mp3",
False,
id="mp3_no_warning",
),
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123.wav",
False,
id="wav_no_warning",
),
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123.flac",
True,
id="flac_warns_and_plays",
),
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123",
False,
id="no_extension_no_warning",
),
],
)
async def test_play_media_announce_format_warning(
hass: HomeAssistant,
soco: MockSoCo,
async_autosetup_sonos,
sonos_websocket,
content_id: str,
expect_warning: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that announce logs a warning for unsupported file formats."""
caplog.clear()
caplog.set_level(
logging.WARNING, logger="homeassistant.components.sonos.media_player"
)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.zone_a",
ATTR_MEDIA_CONTENT_TYPE: "music",
ATTR_MEDIA_CONTENT_ID: content_id,
ATTR_MEDIA_ANNOUNCE: True,
},
blocking=True,
)
assert sonos_websocket.play_clip.call_count == 1
warning_logged = "only supports MP3 and WAV" in caplog.text
assert warning_logged == expect_warning
async def test_media_get_queue(
hass: HomeAssistant,
soco: MockSoCo,
+31
View File
@@ -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:
+4 -4
View File
@@ -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.const import (
)
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,
+256 -11
View File
@@ -2821,6 +2821,131 @@ async def test_test_condition(
assert msg["result"]["result"] is False
@pytest.mark.parametrize(
("value_template", "expected_template_errors"),
[
("{{ no_such_variable }}", ["'no_such_variable' is undefined"]),
# A single render emitting multiple errors forwards all of them
("{{ foo }}{{ bar }}", ["'foo' is undefined", "'bar' is undefined"]),
],
)
async def test_test_condition_template_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
value_template: str,
expected_template_errors: list[str],
) -> None:
"""Test template errors are forwarded in the result without being logged."""
caplog.set_level(logging.WARNING)
await websocket_client.send_json_auto_id(
{
"type": "test_condition",
"condition": {"condition": "template", "value_template": value_template},
}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"result": False,
"template_errors": expected_template_errors,
}
assert "Template variable" not in caplog.text
@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']"
),
},
),
# 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_test_condition_config_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
condition: dict,
expected_error: dict,
) -> None:
"""Test condition config errors are reported to the client without logging."""
caplog.set_level(logging.ERROR)
await websocket_client.send_json_auto_id(
{"type": "test_condition", "condition": condition}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
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_test_condition_check_error_not_logged(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test errors raised while checking the condition are not logged.
The condition is valid and instantiates fine, but checking it raises (here
the entity does not exist). The error is reported to the client without
being logged by the default websocket error handler.
"""
caplog.set_level(logging.ERROR)
await websocket_client.send_json_auto_id(
{
"type": "test_condition",
"condition": {
"condition": "state",
"entity_id": "hello.world",
"state": "paulus",
},
}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"] == {
"code": "home_assistant_error",
"message": "In 'state':\n In 'state' condition: unknown entity hello.world",
}
assert "Error handling message" not in caplog.text
async def test_subscribe_condition(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
@@ -2868,6 +2993,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,17 +3094,6 @@ async def test_subscribe_condition(
),
},
),
# Validated 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"
),
},
),
],
)
async def test_subscribe_condition_error(
@@ -2924,6 +3115,60 @@ async def test_subscribe_condition_error(
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']"
),
},
),
# 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_config_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
condition: dict,
expected_error: dict,
) -> None:
"""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}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
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
) -> None:
+337
View File
@@ -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
+86
View File
@@ -2205,6 +2205,92 @@ 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.suppress_template_error_logging(). Template
variable errors are then recorded in the trace without being logged.
"""
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.suppress_template_error_logging():
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 suppression is not opted in.
The error is always recorded in the trace, but unless the consumer opts in
via trace.suppress_template_error_logging() it is also logged as usual.
"""
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
# Recorded in the trace...
condition_trace = trace.trace_get(clear=False)
trace.trace_clear()
assert condition_trace[""][0].template_errors == ["'no_such_variable' is undefined"]
# ...and also logged
assert "Template variable warning: 'no_such_variable' is undefined" in caplog.text
async def test_condition_template_invalid_results(hass: HomeAssistant) -> None:
"""Test template condition render false with invalid results."""
config = {"condition": "template", "value_template": "{{ 'string' }}"}
+36
View File
@@ -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}]}
)
+20 -4
View File
@@ -868,7 +868,8 @@ async def test_delay_template_invalid(
{
"error": (
"offset should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'"
)
),
"template_errors": ["'invalid_delay' is undefined"],
}
],
},
@@ -933,7 +934,12 @@ async def test_delay_template_complex_invalid(
assert_action_trace(
{
"0": [{"result": {"event": "test_event", "event_data": {}}}],
"1": [{"error": "expected float for dictionary value @ data['seconds']"}],
"1": [
{
"error": "expected float for dictionary value @ data['seconds']",
"template_errors": ["'invalid_delay' is undefined"],
}
],
},
expected_script_execution="aborted",
)
@@ -2646,7 +2652,12 @@ async def test_repeat_for_each_invalid_template(
assert_action_trace(
{
"0": [{"error": "Repeat 'for_each' must be a list of items"}],
"0": [
{
"error": "Repeat 'for_each' must be a list of items",
"template_errors": ["'Muhaha' is undefined"],
}
],
},
expected_script_execution="aborted",
)
@@ -2715,7 +2726,12 @@ async def test_repeat_condition_warning(
expected_trace[f"0/repeat/{condition}/0"] = [
{"error": "In 'numeric_state':\n " + expected_error}
]
expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [{"error": expected_error}]
expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [
{
"error": expected_error,
"template_errors": ["'unassigned_variable' is undefined"],
}
]
assert_action_trace(expected_trace)