From 67b4fc9f312508017ac21409485b08305879e6b3 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:06:31 +0200 Subject: [PATCH 01/15] Bump aioautomower to 3.0.0 (#182237) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 678fe49535a09..4c33071c1c595 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.8.1"] + "requirements": ["aioautomower==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a89f42f353777..69e8de737b27f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ aioaseko==1.0.0 aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower -aioautomower==2.8.1 +aioautomower==3.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.2 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 8fed95f9f8543..4b7694e9f674b 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -151,14 +151,14 @@ 'use_global_cutting_height': True, }), '123456': dict({ - 'current_orientation': 0, + 'current_orientation': 0.0, 'cutting_height': 50, 'enabled': True, 'last_time_abandoned': None, 'last_time_completed': '2024-08-12T07:54:29+02:00', 'name': 'Front lawn', - 'orientation': 1800, - 'orientation_shift': 1800, + 'orientation': 180.0, + 'orientation_shift': 180.0, 'progress': 40, 'type': 'systematic', 'use_global_cutting_height': True, From 47338dc3651bae827ac2d88d2d11e477b0a43edd Mon Sep 17 00:00:00 2001 From: darkrain-nl Date: Tue, 15 Sep 2026 07:11:59 +0200 Subject: [PATCH 02/15] Bump sofar-modbus to 0.14.0 (#182232) --- homeassistant/components/sofar/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sofar/manifest.json b/homeassistant/components/sofar/manifest.json index d4c44803562a6..9b28e1deff392 100644 --- a/homeassistant/components/sofar/manifest.json +++ b/homeassistant/components/sofar/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["sofar-modbus==0.13.0"] + "requirements": ["sofar-modbus==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69e8de737b27f..4e9727892738d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3129,7 +3129,7 @@ snapcast==2.3.8 soco==0.31.2 # homeassistant.components.sofar -sofar-modbus==0.13.0 +sofar-modbus==0.14.0 # homeassistant.components.solaredge_local solaredge-local==0.2.3 From afbd203c004da0813ae2fd9c505a142e3b81c4bb Mon Sep 17 00:00:00 2001 From: "Yanze (David) Wu" <133224895+David-Wu1119@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:43:49 -0700 Subject: [PATCH 03/15] Type the motion_blinds multicast listener with a HassKey (#182233) Co-authored-by: Claude Opus 5 --- .../components/motion_blinds/__init__.py | 55 ++++++++++++------- .../components/motion_blinds/const.py | 3 - 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index aeb96c5a26594..df20087395007 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -1,24 +1,22 @@ """The motion_blinds component.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio +from dataclasses import dataclass, field import logging from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, AsyncMotionMulticast from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.util.hass_dict import HassKey from .const import ( CONF_BLIND_TYPE_LIST, CONF_INTERFACE, DEFAULT_INTERFACE, DOMAIN, - KEY_MULTICAST_LISTENER, - KEY_SETUP_LOCK, - KEY_UNSUB_STOP, PLATFORMS, ) from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry @@ -28,20 +26,34 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class MotionBlindsData: + """Multicast listener shared by every gateway.""" + + setup_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + multicast: AsyncMotionMulticast | None = None + unsub_stop: CALLBACK_TYPE | None = None + + +# One multicast listener serves every gateway, so it is shared between config +# entries rather than owned by any one of them. +MOTION_BLINDS_DATA: HassKey[MotionBlindsData] = HassKey(DOMAIN) + + async def async_setup_entry( hass: HomeAssistant, entry: MotionBlindsConfigEntry ) -> bool: """Set up the motion_blinds components from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock()) + if (motion_data := hass.data.get(MOTION_BLINDS_DATA)) is None: + motion_data = hass.data[MOTION_BLINDS_DATA] = MotionBlindsData() host = entry.data[CONF_HOST] key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener - async with setup_lock: - if KEY_MULTICAST_LISTENER not in hass.data[DOMAIN]: + async with motion_data.setup_lock: + if (multicast := motion_data.multicast) is None: # check multicast interface check_multicast_class = ConnectMotionGateway( hass, interface=multicast_interface @@ -61,8 +73,9 @@ async def async_setup_entry( working_interface, ) - multicast = AsyncMotionMulticast(interface=working_interface) - hass.data[DOMAIN][KEY_MULTICAST_LISTENER] = multicast + multicast = motion_data.multicast = AsyncMotionMulticast( + interface=working_interface + ) # start listening for local pushes (only once) await multicast.Start_listen() @@ -72,13 +85,11 @@ def stop_motion_multicast(event): _LOGGER.debug("Shutting down Motion Listener") multicast.Stop_listen() - unsub = hass.bus.async_listen_once( + motion_data.unsub_stop = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, stop_motion_multicast ) - hass.data[DOMAIN][KEY_UNSUB_STOP] = unsub # Connect to motion gateway - multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) if not await connect_gateway_class.async_connect_gateway( host, key, blind_type_list @@ -130,16 +141,18 @@ async def async_unload_entry( config_entry, PLATFORMS ) - if unload_ok: - multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] - multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) + motion_data = hass.data[MOTION_BLINDS_DATA] + if unload_ok and motion_data.multicast is not None: + motion_data.multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) if not hass.config_entries.async_loaded_entries(DOMAIN): # No motion gateways left, stop Motion multicast - unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) - unsub_stop() + if motion_data.unsub_stop is not None: + motion_data.unsub_stop() + motion_data.unsub_stop = None _LOGGER.debug("Shutting down Motion Listener") - multicast = hass.data[DOMAIN].pop(KEY_MULTICAST_LISTENER) - multicast.Stop_listen() + if motion_data.multicast is not None: + motion_data.multicast.Stop_listen() + motion_data.multicast = None return unload_ok diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index e95a389c8c9df..79ca8e7776b7e 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -16,9 +16,6 @@ KEY_GATEWAY = "gateway" KEY_API_LOCK = "api_lock" -KEY_MULTICAST_LISTENER = "multicast_listener" -KEY_SETUP_LOCK = "setup_lock" -KEY_UNSUB_STOP = "unsub_stop" ATTR_WIDTH = "width" ATTR_ABSOLUTE_POSITION = "absolute_position" From 2de2d6ef0cd4fa0a8c62880ee39dffffb010b0ba Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 15 Sep 2026 11:48:14 +0200 Subject: [PATCH 04/15] Expose active Hue scene applied to grouped lights (#151883) Co-authored-by: Claude Opus 4.6 Co-authored-by: Cursor Co-authored-by: Erik Montnemery --- homeassistant/components/hue/bridge.py | 6 + homeassistant/components/hue/icons.json | 5 + homeassistant/components/hue/scene.py | 19 +- homeassistant/components/hue/select.py | 22 + homeassistant/components/hue/strings.json | 5 + homeassistant/components/hue/v2/entity.py | 6 +- homeassistant/components/hue/v2/select.py | 195 ++++++ tests/components/hue/conftest.py | 3 + .../components/hue/fixtures/v2_resources.json | 10 +- tests/components/hue/test_bridge.py | 1 + tests/components/hue/test_scene.py | 116 +++- tests/components/hue/test_select.py | 625 ++++++++++++++++++ 12 files changed, 996 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/hue/select.py create mode 100644 homeassistant/components/hue/v2/select.py create mode 100644 tests/components/hue/test_select.py diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index c1bf684236a73..b5c18385a603a 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -9,6 +9,7 @@ from aiohttp import client_exceptions from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized from aiohue.errors import AiohueException, BridgeBusy +from aiohue.v2.scene_activity import SceneActivityTracker from homeassistant import core from homeassistant.components import persistent_notification @@ -31,6 +32,7 @@ Platform.EVENT, Platform.LIGHT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -49,6 +51,7 @@ def __init__(self, hass: core.HomeAssistant, config_entry: HueConfigEntry) -> No # Jobs to be executed when API is reset. self.reset_jobs: list[core.CALLBACK_TYPE] = [] self.sensor_manager: SensorManager | None = None + self.scene_activity_tracker: SceneActivityTracker | None = None self.logger = logging.getLogger(__name__) # store actual api connection to bridge as api app_key: str = self.config_entry.data[CONF_API_KEY] @@ -121,6 +124,9 @@ async def async_initialize_bridge(self) -> bool: else: await async_setup_devices(self) await async_setup_hue_events(self) + self.scene_activity_tracker = SceneActivityTracker(self.api.scenes) + self.scene_activity_tracker.start() + self.reset_jobs.append(self.scene_activity_tracker.stop) await self.hass.config_entries.async_forward_entry_setups( self.config_entry, PLATFORMS_v2 ) diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 92079afce589e..459c4529452da 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -27,6 +27,11 @@ } } } + }, + "select": { + "active_scene": { + "default": "mdi:palette" + } } }, "services": { diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 00681795359e3..034bc89029796 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -6,8 +6,10 @@ from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.scenes import ScenesController +from aiohue.v2.models.room import Room from aiohue.v2.models.scene import Scene as HueScene, ScenePut as HueScenePut from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene, SmartSceneState +from aiohue.v2.models.zone import Zone import probatio from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity @@ -50,13 +52,21 @@ def async_add_entity( event_type: EventType, resource: HueScene | HueSmartScene ) -> None: """Add entity from Hue resource.""" + if (group := api.scenes.get_group(resource.id)) is None: + LOGGER.warning( + "Skipping Hue scene %s: group %s could not be resolved", + resource.id, + resource.group.rid, + ) + return + # Catch creation errors to continue adding other scenes even if one fails try: entity: HueSceneEntityBase if isinstance(resource, HueSmartScene): - entity = HueSmartSceneEntity(bridge, api.scenes, resource) + entity = HueSmartSceneEntity(bridge, api.scenes, resource, group) else: - entity = HueSceneEntity(bridge, api.scenes, resource) + entity = HueSceneEntity(bridge, api.scenes, resource, group) except KeyError, StopIteration: LOGGER.exception("Unable to create Hue scene entity for %s", resource.id) return @@ -102,14 +112,13 @@ def __init__( bridge: HueBridge, controller: ScenesController, resource: HueScene | HueSmartScene, + group: Room | Zone, ) -> None: """Initialize the entity.""" super().__init__(bridge, controller, resource) self.resource = resource self.controller = controller - if (hue_group := self.controller.get_group(self.resource.id)) is None: - raise KeyError(self.resource.group.rid) - self.hue_group = hue_group + self.hue_group = group # we create a virtual service/device for Hue zones/rooms # so we have a parent for grouped lights and scenes self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/hue/select.py b/homeassistant/components/hue/select.py new file mode 100644 index 0000000000000..ce05bcf9711f1 --- /dev/null +++ b/homeassistant/components/hue/select.py @@ -0,0 +1,22 @@ +"""Support for select platform for Hue scenes (V2 only).""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .bridge import HueConfigEntry +from .v2.select import async_setup_entry as setup_entry_v2 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hue select entities.""" + bridge = config_entry.runtime_data + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Select support is only available for V2 bridges") + await setup_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index bbc8384ed3a09..9d5f82352c5a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -130,6 +130,11 @@ } } }, + "select": { + "active_scene": { + "name": "Scene" + } + }, "sensor": { "zigbee_connectivity": { "name": "Zigbee connectivity", diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 38616e2c4f324..c14a604bbcc2e 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -21,8 +21,12 @@ from aiohue.v2.models.light import Light from aiohue.v2.models.light_level import LightLevel from aiohue.v2.models.motion import Motion + from aiohue.v2.models.room import Room + from aiohue.v2.models.zone import Zone - type HueResource = Light | DevicePower | GroupedLight | LightLevel | Motion + type HueResource = ( + Light | DevicePower | GroupedLight | LightLevel | Motion | Room | Zone + ) RESOURCE_TYPE_NAMES = { diff --git a/homeassistant/components/hue/v2/select.py b/homeassistant/components/hue/v2/select.py new file mode 100644 index 0000000000000..291a926e4f865 --- /dev/null +++ b/homeassistant/components/hue/v2/select.py @@ -0,0 +1,195 @@ +"""Select entities for Hue scene selection per group.""" + +from typing import override + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.groups import RoomController, ZoneController +from aiohue.v2.models.room import Room +from aiohue.v2.models.scene import Scene as HueScene +from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene +from aiohue.v2.models.zone import Zone +from aiohue.v2.scene_activity import SceneActivityTracker + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN +from .entity import HueBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hue scene select entities from a config entry.""" + bridge = config_entry.runtime_data + api: HueBridgeV2 = bridge.api + tracker = bridge.scene_activity_tracker + assert tracker is not None + + # Prepare initial options before entity registration reads capabilities. + scenes_by_group: dict[str, list[HueScene | HueSmartScene]] = {} + for scene in api.scenes: + scenes_by_group.setdefault(scene.group.rid, []).append(scene) + + @callback + def _on_group_added(_: EventType, group: Room | Zone) -> None: + controller = api.groups.room if isinstance(group, Room) else api.groups.zone + async_add_entities([HueSceneSelectEntity(bridge, tracker, controller, group)]) + + for group_controller in (api.groups.room, api.groups.zone): + async_add_entities( + HueSceneSelectEntity( + bridge, + tracker, + group_controller, + group, + scenes_by_group.get(group.id, []), + ) + for group in group_controller + ) + config_entry.async_on_unload( + group_controller.subscribe( + _on_group_added, event_filter=EventType.RESOURCE_ADDED + ) + ) + + +def _build_scene_option_maps( + scenes: list[HueScene | HueSmartScene], +) -> tuple[dict[str, str], dict[str, str]]: + """Build bidirectional option maps for a scene collection.""" + # Sort for a stable option order across restarts and updates. + scenes = sorted(scenes, key=lambda s: (s.metadata.name, s.id)) + option_to_scene_id: dict[str, str] = {} + scene_id_to_option: dict[str, str] = {} + + for scene in scenes: + # Hue allows duplicate scene names within a group; number the repeats. + option = scene.metadata.name + repeat = 1 + while option in option_to_scene_id: + repeat += 1 + option = f"{scene.metadata.name} ({repeat})" + option_to_scene_id[option] = scene.id + scene_id_to_option[scene.id] = option + + return option_to_scene_id, scene_id_to_option + + +# pylint: disable-next=home-assistant-enforce-class-module +class HueSceneSelectEntity(HueBaseEntity, SelectEntity): + """Select entity showing and controlling the active scene of a Hue group.""" + + _attr_has_entity_name = True + _attr_translation_key = "active_scene" + _option_to_scene_id: dict[str, str] + _scene_id_to_option: dict[str, str] + _scene_id_to_name: dict[str, str] + + def __init__( + self, + bridge: HueBridge, + tracker: SceneActivityTracker, + controller: RoomController | ZoneController, + group: Room | Zone, + initial_scenes: list[HueScene | HueSmartScene] | None = None, + ) -> None: + """Initialize the scene select entity.""" + super().__init__(bridge, controller, group) + self._tracker = tracker + self._group_id = group.id + self._group_state = tracker.get_group_state(self._group_id) + # Attach to the virtual Hue group device (same as grouped lights and scenes). + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.resource.id)}, + ) + self._attr_unique_id = f"{self._group_id}_scene_select" + self.refresh_options(initial_scenes) + + @override + async def async_added_to_hass(self) -> None: + """Register listeners when added to Home Assistant.""" + await super().async_added_to_hass() + + @callback + def _on_tracker_update(_: str) -> None: + self._group_state = self._tracker.get_group_state(self._group_id) + self.async_write_ha_state() + + self.async_on_remove( + self._tracker.subscribe(self._group_id, _on_tracker_update) + ) + self.async_on_remove( + self.bridge.api.scenes.subscribe( + self._handle_scene_event, + event_filter=( + EventType.RESOURCE_ADDED, + EventType.RESOURCE_UPDATED, + EventType.RESOURCE_DELETED, + ), + ) + ) + self.refresh_options() + + @callback + def _handle_scene_event( + self, event_type: EventType, scene: HueScene | HueSmartScene + ) -> None: + """Refresh options when this group's scenes change.""" + if scene.group.rid != self._group_id: + return + # Skip rebuild on status updates where the name hasn't changed. + if event_type == EventType.RESOURCE_UPDATED and self._scene_option_matches_name( + scene.id, scene.metadata.name + ): + return + self.refresh_options() + self.async_write_ha_state() + + def _scene_option_matches_name(self, scene_id: str, name: str) -> bool: + """Return if the current option label still matches an unchanged scene name.""" + return self._scene_id_to_name.get(scene_id) == name + + def refresh_options( + self, scenes: list[HueScene | HueSmartScene] | None = None + ) -> None: + """Rebuild the name-to-ID map of scenes available for this group.""" + if scenes is None: + scenes = [ + scene + for scene in self.bridge.api.scenes + if scene.group.rid == self._group_id + ] + self._scene_id_to_name = {scene.id: scene.metadata.name for scene in scenes} + self._option_to_scene_id, self._scene_id_to_option = _build_scene_option_maps( + scenes + ) + + @property + @override + def options(self) -> list[str]: + """Return the available scene names for this group.""" + return list(self._option_to_scene_id) + + @property + @override + def current_option(self) -> str | None: + """Return the name of the currently active scene.""" + if not (scene_id := self._group_state.scene_id): + return None + return self._scene_id_to_option.get(scene_id) + + @override + async def async_select_option(self, option: str) -> None: + """Activate the scene with the given name.""" + scene_id = self._option_to_scene_id[option] + await self.bridge.async_request_call( + self.bridge.api.scenes.recall, + scene_id, + ) diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 2c951c60ae27d..26a379a5ac97d 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -10,6 +10,7 @@ import aiohue.v1 as aiohue_v1 import aiohue.v2 as aiohue_v2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.scene_activity import SceneActivityTracker import pytest from homeassistant.components import hue @@ -62,6 +63,8 @@ async def async_initialize_bridge(): bridge.config_entry.runtime_data = bridge if bridge.api_version == 2: await async_setup_devices(bridge) + bridge.scene_activity_tracker = SceneActivityTracker(bridge.api.scenes) + bridge.scene_activity_tracker.start() return True bridge.async_initialize_bridge = async_initialize_bridge diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index b52ab82b5be84..09dc7fa35ce60 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -169,6 +169,10 @@ }, "speed": 0.6269841194152832, "auto_dynamic": false, + "status": { + "active": "dynamic_palette", + "last_recall": "2025-09-12T11:41:46.318Z" + }, "type": "scene" }, { @@ -222,10 +226,14 @@ }, "speed": 0.5, "auto_dynamic": false, + "status": { + "active": "static", + "last_recall": "2025-09-12T11:41:46.318Z" + }, "type": "scene" }, { - "id": "redacted-8abe5a3e-94c8-4058-908f-56241818509a", + "id": "8abe5a3e-94c8-4058-908f-56241818509a", "type": "smart_scene", "metadata": { "name": "Smart Test Scene", diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index be283a4e7dc46..1310d33544234 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -170,6 +170,7 @@ async def test_bridge_setup_v2(hass: HomeAssistant, mock_api_v2: Mock) -> None: "light", "binary_sensor", "event", + "select", "sensor", "switch", "scene", diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index fb0b845072d92..35f0d4a3b3c54 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -1,5 +1,7 @@ """Philips Hue scene platform tests for V2 bridge/api.""" +from copy import deepcopy +import logging from unittest.mock import Mock import pytest @@ -205,25 +207,119 @@ async def test_scene_updates( assert test_entity is None +@pytest.mark.parametrize( + "resource_type", + [pytest.param("scene", id="regular"), pytest.param("smart_scene", id="smart")], +) async def test_scene_with_orphaned_group( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, caplog: pytest.LogCaptureFixture, + resource_type: str, ) -> None: """Test that a scene referencing a non-existent group is skipped and logged.""" - orphaned_scene = { - **FAKE_SCENE, - "id": "orphaned_scene_id", - "group": {"rid": "non-existent-group-id", "rtype": "room"}, - } + caplog.set_level(logging.WARNING) + orphaned_scene = deepcopy( + next( + resource + for resource in v2_resources_test_data + if resource["type"] == resource_type + ) + ) + orphaned_scene["id"] = "orphaned_scene_id" + orphaned_scene["group"] = {"rid": "non-existent-group-id", "rtype": "room"} await mock_bridge_v2.api.load_test_data([*v2_resources_test_data, orphaned_scene]) await setup_platform(hass, mock_bridge_v2, Platform.SCENE) - # the orphaned scene should not be created as an entity - assert hass.states.get("scene.test_room_mocked_scene_orphaned") is None - # the valid scenes should still be created + assert ( + entity_registry.async_get_entity_id( + Platform.SCENE, DOMAIN, orphaned_scene["id"] + ) + is None + ) assert len(hass.states.async_all()) == 3 - # an error should be logged for the orphaned scene - assert "Unable to create Hue scene entity for orphaned_scene_id" in caplog.text + record = next( + record + for record in caplog.records + if record.name == "homeassistant.components.hue.scene" + ) + assert record.levelno == logging.WARNING + assert record.getMessage() == ( + "Skipping Hue scene orphaned_scene_id: group non-existent-group-id " + "could not be resolved" + ) + assert record.exc_info is None + + +@pytest.mark.parametrize( + "resource_type", + [pytest.param("scene", id="regular"), pytest.param("smart_scene", id="smart")], +) +async def test_scene_added_after_group_deleted( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + caplog: pytest.LogCaptureFixture, + resource_type: str, +) -> None: + """Test a late scene for a deleted group is skipped without blocking valid scenes.""" + caplog.set_level(logging.WARNING) + room = next( + resource for resource in v2_resources_test_data if resource["type"] == "room" + ) + zone = next( + resource for resource in v2_resources_test_data if resource["type"] == "zone" + ) + late_scene = deepcopy( + next( + resource + for resource in v2_resources_test_data + if resource["type"] == resource_type + and resource["group"]["rid"] == room["id"] + ) + ) + late_scene["id"] = "late_scene_id" + late_scene["metadata"]["name"] = "Late scene" + + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.SCENE) + + mock_bridge_v2.api.emit_event("delete", {"type": "room", "id": room["id"]}) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert room["id"] not in mock_bridge_v2.api.groups + + caplog.clear() + mock_bridge_v2.api.emit_event("add", late_scene) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(Platform.SCENE, DOMAIN, late_scene["id"]) + is None + ) + record = next( + record + for record in caplog.records + if record.name == "homeassistant.components.hue.scene" + ) + assert record.levelno == logging.WARNING + assert record.getMessage() == ( + f"Skipping Hue scene late_scene_id: group {room['id']} could not be resolved" + ) + assert record.exc_info is None + + valid_scene = deepcopy(late_scene) + valid_scene["id"] = "valid_scene_id" + valid_scene["group"] = {"rid": zone["id"], "rtype": "zone"} + mock_bridge_v2.api.emit_event("add", valid_scene) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + Platform.SCENE, DOMAIN, valid_scene["id"] + ) + assert entity_id is not None + assert hass.states.get(entity_id) is not None diff --git a/tests/components/hue/test_select.py b/tests/components/hue/test_select.py new file mode 100644 index 0000000000000..1bdfa677d74dc --- /dev/null +++ b/tests/components/hue/test_select.py @@ -0,0 +1,625 @@ +"""Tests for Hue scene select entities.""" + +from __future__ import annotations + +import asyncio +from copy import deepcopy +from unittest.mock import Mock, patch + +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.scene import Scene as HueScene +import pytest + +from homeassistant.components.hue.v2.select import HueSceneSelectEntity +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.json import JsonArrayType + +from .conftest import setup_platform + +TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" +TEST_ZONE_ID = "7cee478d-6455-483a-9e32-9f9fdcbcc4f6" +TEST_ROOM_SCENE_ENTITY = "select.test_room_test_room_scene" +DUPLICATE_SCENE_ID = "22222222-3333-4444-8555-666666666666" +LITERAL_SUFFIX_SCENE_ID = "33333333-4444-4555-8666-777777777777" + + +async def test_scene_select_initial_state( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test that scene select entities are created with correct initial state.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # A smart scene and its effective regular scene can both be active. The smart + # scene is the top-level selection shown by the Hue app. + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + assert state.state == "Smart Test Scene" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Smart Test Scene", + ] + assert hass.states.get("select.test_room_test_room_smart_scene") is None + + # Test Zone has "Dynamic Test Scene" active (dynamic_palette) from fixture + state = hass.states.get("select.test_zone_scene") + assert state is not None + assert state.state == "Dynamic Test Scene" + assert state.attributes["options"] == ["Dynamic Test Scene"] + assert hass.states.get("select.test_zone_smart_scene") is None + + +async def test_scene_select_becomes_inactive( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test that the select entity reflects unknown state when no scene is active.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # The active smart scene takes precedence over its effective regular scene. + assert ( + hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" + ) + + smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" + regular_scene_id = "cdbf3740-7977-4a11-8275-8c78636ad4bd" + + # When the smart scene stops, fall back to the still-active regular scene. + mock_bridge_v2.api.emit_event( + "update", + {"id": smart_scene_id, "type": "smart_scene", "state": "inactive"}, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state + == "Regular Test Scene" + ) + + # Once both scenes are inactive, the select has no active option. + mock_bridge_v2.api.emit_event( + "update", + { + "id": regular_scene_id, + "type": "scene", + "status": {"active": "inactive"}, + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("select.test_room_test_room_scene").state == STATE_UNKNOWN + + # Reactivate the scene + mock_bridge_v2.api.emit_event( + "update", + { + "id": regular_scene_id, + "type": "scene", + "status": { + "active": "static", + "last_recall": "2025-12-31T23:59:59.999Z", + }, + }, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state + == "Regular Test Scene" + ) + + +async def test_scene_select_activate_option( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test that selecting an option calls the bridge scene recall API.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # Select an option by calling the select_option service + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Bridge API should have been called with the correct scene id + regular_scene_id = "cdbf3740-7977-4a11-8275-8c78636ad4bd" + assert len(mock_bridge_v2.mock_requests) == 1 + path = mock_bridge_v2.mock_requests[0]["path"] + assert "/scene/" in path + assert regular_scene_id in path + + +async def test_scene_select_disambiguates_duplicate_names( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test duplicate regular scene names are exposed and recalled distinctly.""" + test_data = deepcopy(v2_resources_test_data) + duplicate_scene = deepcopy( + next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + ) + duplicate_scene["id"] = DUPLICATE_SCENE_ID + duplicate_scene["status"] = { + "active": "inactive", + "last_recall": "2025-09-12T11:41:46.318Z", + } + test_data.append(duplicate_scene) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + assert state.state == "Smart Test Scene" + # The duplicate sorts before the original on scene id, so it keeps the bare name. + assert state.attributes["options"] == [ + "Regular Test Scene", + "Regular Test Scene (2)", + "Smart Test Scene", + ] + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene", + }, + blocking=True, + ) + await hass.async_block_till_done() + + last_request = mock_bridge_v2.mock_requests[-1] + assert "/scene/" in last_request["path"] + assert DUPLICATE_SCENE_ID in last_request["path"] + + +@pytest.mark.parametrize( + ("option", "expected_scene_id"), + [ + pytest.param( + "Regular Test Scene", + DUPLICATE_SCENE_ID, + id="duplicate_keeps_bare_name", + ), + pytest.param( + "Regular Test Scene (2) (2)", + LITERAL_SUFFIX_SCENE_ID, + id="literal_name_is_disambiguated", + ), + ], +) +async def test_scene_select_disambiguated_label_does_not_shadow_scene_name( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + option: str, + expected_scene_id: str, +) -> None: + """Test a generated duplicate label cannot shadow a literal scene name.""" + test_data = deepcopy(v2_resources_test_data) + regular_scene = next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + + duplicate_scene = deepcopy(regular_scene) + duplicate_scene["id"] = DUPLICATE_SCENE_ID + duplicate_scene["status"]["active"] = "inactive" + test_data.append(duplicate_scene) + + literal_suffix_scene = deepcopy(regular_scene) + literal_suffix_scene["id"] = LITERAL_SUFFIX_SCENE_ID + literal_suffix_scene["metadata"]["name"] = "Regular Test Scene (2)" + literal_suffix_scene["status"]["active"] = "inactive" + test_data.append(literal_suffix_scene) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get(TEST_ROOM_SCENE_ENTITY) + assert state is not None + assert state.attributes["options"] == [ + "Regular Test Scene", + "Regular Test Scene (2)", + "Regular Test Scene (2) (2)", + "Smart Test Scene", + ] + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + {"entity_id": TEST_ROOM_SCENE_ENTITY, "option": option}, + blocking=True, + ) + assert expected_scene_id in mock_bridge_v2.mock_requests[0]["path"] + + +async def test_scene_select_refreshes_options_for_scene_events( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test add, rename, and delete events refresh the unified scene options.""" + test_data = deepcopy(v2_resources_test_data) + regular_scene = next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + smart_scene = next( + resource for resource in test_data if resource["type"] == "smart_scene" + ) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + added_scene = deepcopy(regular_scene) + added_scene["id"] = "22222222-3333-4444-8555-666666666666" + added_scene["metadata"]["name"] = "Added scene" + added_scene["status"]["active"] = "inactive" + mock_bridge_v2.api.emit_event("add", added_scene) + await hass.async_block_till_done() + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + assert state.attributes["options"] == [ + "Added scene", + "Regular Test Scene", + "Smart Test Scene", + ] + + renamed_scene = deepcopy(added_scene) + renamed_scene["metadata"]["name"] = "Renamed scene" + mock_bridge_v2.api.emit_event("update", renamed_scene) + await hass.async_block_till_done() + + state = hass.states.get("select.test_room_test_room_scene") + assert state.attributes["options"] == [ + "Regular Test Scene", + "Renamed scene", + "Smart Test Scene", + ] + + # Deleting the active smart scene removes its option and exposes its effective + # regular scene as the current selection. + mock_bridge_v2.api.emit_event("delete", smart_scene) + await hass.async_block_till_done() + + state = hass.states.get("select.test_room_test_room_scene") + assert state.state == "Regular Test Scene" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Renamed scene", + ] + + +async def test_scene_select_prefers_active_smart_scene( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test smart scene state transitions in the unified scene select.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # Smart scene starts active + assert ( + hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" + ) + + smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" + + # Deactivate smart scene + mock_bridge_v2.api.emit_event( + "update", + {"id": smart_scene_id, "type": "smart_scene", "state": "inactive"}, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state + == "Regular Test Scene" + ) + + # Reactivate smart scene + mock_bridge_v2.api.emit_event( + "update", + {"id": smart_scene_id, "type": "smart_scene", "state": "active"}, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" + ) + + +async def test_scene_select_activate_smart_scene_option( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test selecting a smart scene uses the smart scene recall API.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test_room_test_room_scene", "option": "Smart Test Scene"}, + blocking=True, + ) + await hass.async_block_till_done() + + smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" + assert len(mock_bridge_v2.mock_requests) == 1 + path = mock_bridge_v2.mock_requests[0]["path"] + assert "/smart_scene/" in path + assert smart_scene_id in path + + +async def test_scene_select_disambiguates_duplicate_smart_scene_names( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test duplicate smart scene names are exposed and recalled distinctly.""" + test_data = deepcopy(v2_resources_test_data) + duplicate_smart_scene = deepcopy( + next(resource for resource in test_data if resource["type"] == "smart_scene") + ) + duplicate_smart_scene_id = "11111111-2222-4333-8444-555555555555" + duplicate_smart_scene["id"] = duplicate_smart_scene_id + duplicate_smart_scene["state"] = "inactive" + test_data.append(duplicate_smart_scene) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + # The duplicate sorts before the original on scene id, so it keeps the bare name. + assert state.state == "Smart Test Scene (2)" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Smart Test Scene", + "Smart Test Scene (2)", + ] + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Smart Test Scene", + }, + blocking=True, + ) + await hass.async_block_till_done() + + last_request = mock_bridge_v2.mock_requests[-1] + assert "/smart_scene/" in last_request["path"] + assert duplicate_smart_scene_id in last_request["path"] + + +async def test_scene_select_disambiguates_names_across_scene_types( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test identical regular and smart scene names remain independently selectable.""" + test_data = deepcopy(v2_resources_test_data) + smart_scene = next( + resource for resource in test_data if resource["type"] == "smart_scene" + ) + smart_scene["metadata"]["name"] = "Regular Test Scene" + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + # The smart scene sorts before the regular scene on scene id. + assert state.state == "Regular Test Scene" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Regular Test Scene (2)", + ] + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene (2)", + }, + blocking=True, + ) + assert "/scene/" in mock_bridge_v2.mock_requests[0]["path"] + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene", + }, + blocking=True, + ) + assert "/smart_scene/" in mock_bridge_v2.mock_requests[0]["path"] + + +@pytest.mark.parametrize( + ("entity_id", "expected_options"), + [ + ( + "select.test_room_test_room_scene", + ["Regular Test Scene", "Smart Test Scene"], + ), + ("select.test_zone_scene", ["Dynamic Test Scene"]), + ], +) +async def test_scene_select_options( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + entity_id: str, + expected_options: list[str], +) -> None: + """Test that each select entity exposes the correct scene options for its group.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["options"] == expected_options + + +async def test_scene_select_removed_when_group_deleted( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, +) -> None: + """Test that deleting a Hue group removes its scene select entity.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + assert hass.states.get(TEST_ROOM_SCENE_ENTITY) is not None + assert entity_registry.async_get(TEST_ROOM_SCENE_ENTITY) is not None + + mock_bridge_v2.api.emit_event( + "delete", + {"type": "room", "id": TEST_ROOM_ID}, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.states.get(TEST_ROOM_SCENE_ENTITY) is None + assert entity_registry.async_get(TEST_ROOM_SCENE_ENTITY) is None + + +@pytest.mark.parametrize( + ("source_id", "new_id", "new_name", "entity_id"), + [ + pytest.param( + TEST_ROOM_ID, + "aaaaaaaa-bbbb-4ccc-8ddd-111111111111", + "New Room", + "select.new_room_new_room_scene", + id="room", + ), + pytest.param( + TEST_ZONE_ID, + "aaaaaaaa-bbbb-4ccc-8ddd-222222222222", + "New Zone", + "select.new_zone_scene", + id="zone", + ), + ], +) +async def test_scene_select_created_when_group_added( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + source_id: str, + new_id: str, + new_name: str, + entity_id: str, +) -> None: + """Test that adding a Hue group at runtime creates its scene select entity.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + assert hass.states.get(entity_id) is None + + new_group = deepcopy( + next( + resource + for resource in v2_resources_test_data + if resource["id"] == source_id + ) + ) + new_group["id"] = new_id + new_group["metadata"]["name"] = new_name + mock_bridge_v2.api.emit_event("add", new_group) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["options"] == [] + + +async def test_scene_select_refreshes_options_missed_before_subscribe( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test options include a scene added after init and before subscribe.""" + test_data = [ + deepcopy(resource) + for resource in v2_resources_test_data + if resource["id"] != TEST_ZONE_ID + ] + regular_scene = next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + late_scene = deepcopy(regular_scene) + late_scene["id"] = "44444444-5555-4666-8777-888888888888" + late_scene["metadata"]["name"] = "Late scene" + late_scene["status"]["active"] = "inactive" + + original_added_to_hass = HueSceneSelectEntity.async_added_to_hass + + async def async_added_to_hass_with_late_scene( + self: HueSceneSelectEntity, + ) -> None: + assert self.unique_id == f"{TEST_ROOM_ID}_scene_select" + scene_added = asyncio.Event() + + def on_scene_added(_: EventType, scene: HueScene) -> None: + scene_added.set() + + unsubscribe = mock_bridge_v2.api.scenes.subscribe( + on_scene_added, + late_scene["id"], + EventType.RESOURCE_ADDED, + ) + try: + mock_bridge_v2.api.emit_event("add", late_scene) + async with asyncio.timeout(5): + await scene_added.wait() + finally: + unsubscribe() + + assert late_scene["id"] in mock_bridge_v2.api.scenes + assert "Late scene" not in self.options + await original_added_to_hass(self) + + await mock_bridge_v2.api.load_test_data(test_data) + with patch.object( + HueSceneSelectEntity, + "async_added_to_hass", + async_added_to_hass_with_late_scene, + ): + await setup_platform(hass, mock_bridge_v2, Platform.SELECT) + + state = hass.states.get(TEST_ROOM_SCENE_ENTITY) + assert state is not None + assert state.attributes["options"] == [ + "Late scene", + "Regular Test Scene", + "Smart Test Scene", + ] From 54a65cb1777ef79eb231deca11239f5882b6dc6a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 15 Sep 2026 13:26:21 +0200 Subject: [PATCH 05/15] Fix Spotify reauth crash when config entry data lacks "id" (#182290) Co-authored-by: Claude --- homeassistant/components/spotify/config_flow.py | 2 +- tests/components/spotify/conftest.py | 1 - tests/components/spotify/test_config_flow.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index f72b78e070e78..b099aa69ebe99 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -75,7 +75,7 @@ async def async_step_reauth_confirm( if user_input is None: return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"account": reauth_entry.data["id"]}, + description_placeholders={"account": reauth_entry.title}, errors={}, ) diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index fbf78a91bb5bf..f5115f8a298c3 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -60,7 +60,6 @@ def mock_config_entry(expires_at: int) -> MockConfigEntry: "expires_at": expires_at, "scope": SCOPES, }, - "id": "1112264111", "name": "spotify_account_1", }, entry_id="01J5TX5A0FF6G5V0QJX6HBC94T", diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 2919a1a58bce3..02d2bcdb43245 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -204,6 +204,7 @@ async def test_reauthentication( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"]["account"] == "spotify_1" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) From 5815eb242044375ef7336763710d27083ee8b33b Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:26:57 +0200 Subject: [PATCH 06/15] Bump lunatone-rest-api-client to 0.10.1 (#182300) --- homeassistant/components/lunatone/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 46fe5cac31051..dbf5106d694f9 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.10.0"], + "requirements": ["lunatone-rest-api-client==0.10.1"], "zeroconf": [ { "properties": { diff --git a/requirements_all.txt b/requirements_all.txt index 4e9727892738d..97346c55065d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1562,7 +1562,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.10.0 +lunatone-rest-api-client==0.10.1 # homeassistant.components.lupusec lupupy==0.3.2 From 162089a8740ec541cbacfb4930cb25fc5fcd5136 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 15 Sep 2026 13:44:53 +0200 Subject: [PATCH 07/15] Refactor command_line notify to async subprocess (#182282) --- .../components/command_line/notify.py | 87 ++++---- tests/components/command_line/test_notify.py | 198 ++++++++++++++---- 2 files changed, 214 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index fc40398da6d64..21871e0151945 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -1,7 +1,7 @@ """Support for command line notification services.""" -import logging -import subprocess +import asyncio +from contextlib import suppress from typing import Any, override from homeassistant.components.notify import ( @@ -12,13 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.process import kill_subprocess from .const import CONF_COMMAND_TIMEOUT, DOMAIN, LOGGER from .utils import create_platform_yaml_not_supported_issue, render_template_args -_LOGGER = logging.getLogger(__name__) - async def async_get_service( hass: HomeAssistant, @@ -46,40 +43,58 @@ def __init__(self, command: str, timeout: int) -> None: self._timeout = timeout @override - def send_message(self, message: str = "", **kwargs: Any) -> None: + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" if not (command := render_template_args(self.hass, self.command)): return LOGGER.debug("Running with message: %s", message) - with subprocess.Popen( # noqa: S602 # shell by design - command, - universal_newlines=True, - stdin=subprocess.PIPE, - close_fds=False, # required for posix_spawn - shell=True, - ) as proc: - try: - proc.communicate(input=message, timeout=self._timeout) - if proc.returncode != 0: - _LOGGER.error( - "Command failed (with return code %s): %s", - proc.returncode, - command, - ) - except subprocess.TimeoutExpired as err: - _LOGGER.debug("Timeout for command: %s", command) - kill_subprocess(proc) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_error", - translation_placeholders={"command": command}, - ) from err - except subprocess.SubprocessError as err: - _LOGGER.debug("Error trying to exec command: %s", command) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_error", - translation_placeholders={"command": command, "error": str(err)}, - ) from err + try: + proc = await asyncio.create_subprocess_shell( # shell by design + command, + stdin=asyncio.subprocess.PIPE, + close_fds=False, # required for posix_spawn + ) + except OSError as err: + LOGGER.debug("Error trying to exec command: %s", command) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"command": command, "error": str(err)}, + ) from err + + try: + async with asyncio.timeout(self._timeout): + await proc.communicate(input=message.encode()) + except TimeoutError as err: + LOGGER.debug("Timeout for command: %s", command) + with suppress(ProcessLookupError): + # The command may have exited between the timeout and the kill. + proc.kill() + if (stdin := proc.stdin) is not None and ( + not stdin.is_closing() or stdin.transport.get_write_buffer_size() + ): + # A still connected stdin pipe keeps proc.wait() pending forever, + # see https://bugs.python.org/issue43884. + stdin.transport.abort() + await proc.wait() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_error", + translation_placeholders={"command": command}, + ) from err + except asyncio.CancelledError: + # Kill synchronously so the child isn't orphaned; the event loop + # reaps it without awaiting wait(), which cancellation would + # interrupt anyway. + with suppress(ProcessLookupError): + proc.kill() + raise + + if proc.returncode != 0: + LOGGER.error( + "Command failed (with return code %s): %s", + proc.returncode, + command, + ) diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index 1fe070b216b8c..571e7bfda85b6 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -1,15 +1,16 @@ """The tests for the command line notification platform.""" +import asyncio import os from pathlib import Path -import subprocess import tempfile -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.notify import CommandLineNotificationService from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -210,8 +211,8 @@ async def test_error_for_none_zero_exit_code( "command_line": [ { "notify": { - "command": "sleep 10000", - "command_timeout": 0.0000001, + "command": "sleep 5", + "command_timeout": 0, "name": "Test5", } } @@ -223,15 +224,55 @@ async def test_timeout( caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test blocking is not forever.""" - with pytest.raises( - HomeAssistantError, match="Timeout trying to execute command: sleep 10000" - ): + with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( NOTIFY_DOMAIN, "test5", {"message": "error"}, blocking=True ) + assert exc_info.value.translation_key == "timeout_error" + assert exc_info.value.translation_placeholders == {"command": "sleep 5"} assert "Timeout" in caplog.text +@pytest.mark.parametrize( + "message", + [ + pytest.param("x" * 100000, id="stdin_buffer_below_high_water_mark"), + pytest.param("x" * 200000, id="stdin_buffer_above_high_water_mark"), + ], +) +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "sleep 5", + "command_timeout": 1, + "name": "Test7", + } + } + ] + } + ], +) +@pytest.mark.usefixtures("load_yaml_integration") +async def test_timeout_with_unflushed_stdin(hass: HomeAssistant, message: str) -> None: + """Test a timeout is raised when the command never drains stdin. + + A message larger than the pipe buffer leaves data queued in the stdin + transport when the timeout cancels communicate(). The outer timeout keeps a + regression from hanging the test run instead of failing it. + """ + with pytest.raises(HomeAssistantError) as exc_info: + async with asyncio.timeout(3): + await hass.services.async_call( + NOTIFY_DOMAIN, "test7", {"message": message}, blocking=True + ) + assert exc_info.value.translation_key == "timeout_error" + assert exc_info.value.translation_placeholders == {"command": "sleep 5"} + + @pytest.mark.parametrize( "get_config", [ @@ -247,34 +288,121 @@ async def test_timeout( } ], ) -async def test_subprocess_exceptions( +async def test_spawn_error( caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: - """Test that notify subprocess exceptions are handled correctly.""" - - with patch( - "homeassistant.components.command_line.notify.subprocess.Popen" - ) as check_output: - check_output.return_value.__enter__ = check_output - check_output.return_value.communicate.side_effect = [ - subprocess.TimeoutExpired("cmd", 10), - None, - subprocess.SubprocessError(), - ] - with pytest.raises( - HomeAssistantError, match="Timeout trying to execute command: exit 0" - ): - await hass.services.async_call( - NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True - ) - assert check_output.call_count == 2 - assert "Timeout for command" in caplog.text + """Test that a failure to spawn the command is handled correctly.""" + + with ( + patch( + "homeassistant.components.command_line.notify.asyncio.create_subprocess_shell", + side_effect=OSError("exec failed"), + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True + ) + assert exc_info.value.translation_key == "command_error" + assert exc_info.value.translation_placeholders == { + "command": "exit 0", + "error": "exec failed", + } + assert "Error trying to exec command" in caplog.text - with pytest.raises( - HomeAssistantError, match="Error trying to execute command: exit 0. Error: " - ): - await hass.services.async_call( - NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True - ) - assert check_output.call_count == 4 - assert "Error trying to exec command" in caplog.text + +@pytest.mark.parametrize( + ("is_closing", "write_buffer_size", "expected_abort"), + [ + pytest.param(True, 0, False, id="stdin_already_closed"), + pytest.param(True, 4096, True, id="stdin_closing_with_queued_data"), + pytest.param(False, 0, True, id="stdin_still_connected"), + ], +) +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 0", + "name": "Test6", + } + } + ] + } + ], +) +@pytest.mark.usefixtures("load_yaml_integration") +async def test_timeout_cleanup( + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, + is_closing: bool, + write_buffer_size: int, + expected_abort: bool, +) -> None: + """Test the stdin pipe is only aborted while it can still block wait(). + + The command is assumed to have exited between the timeout and the kill, so + this also covers that kill() raises ProcessLookupError. + """ + mock_proc = AsyncMock() + mock_proc.communicate.side_effect = TimeoutError + mock_proc.kill = MagicMock(side_effect=ProcessLookupError) + mock_proc.stdin = MagicMock() + mock_proc.stdin.is_closing.return_value = is_closing + mock_proc.stdin.transport.get_write_buffer_size.return_value = write_buffer_size + + with ( + patch( + "homeassistant.components.command_line.notify.asyncio.create_subprocess_shell", + return_value=mock_proc, + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True + ) + assert exc_info.value.translation_key == "timeout_error" + assert exc_info.value.translation_placeholders == {"command": "exit 0"} + mock_proc.kill.assert_called_once() + mock_proc.wait.assert_awaited_once() + assert mock_proc.stdin.transport.abort.called is expected_abort + assert "Timeout for command" in caplog.text + + +@pytest.mark.parametrize( + "kill_side_effect", + [ + pytest.param(None, id="process_running"), + # The command may have exited before the kill. + pytest.param(ProcessLookupError, id="process_already_gone"), + ], +) +async def test_cancelled_kills_process( + hass: HomeAssistant, kill_side_effect: type[Exception] | None +) -> None: + """Test the subprocess is killed and the cancellation is re-raised. + + The event loop reaps the killed child on its own, so the cancellation path + does not await wait(). + """ + mock_proc = AsyncMock() + mock_proc.communicate.side_effect = asyncio.CancelledError + mock_proc.kill = MagicMock(side_effect=kill_side_effect) + + service = CommandLineNotificationService("exit 0", 15) + service.hass = hass + + with ( + patch( + "homeassistant.components.command_line.notify.asyncio.create_subprocess_shell", + return_value=mock_proc, + ), + pytest.raises(asyncio.CancelledError), + ): + await service.async_send_message("error") + + mock_proc.kill.assert_called_once() + mock_proc.wait.assert_not_awaited() From c507e71d0d647518c6b7159a62cca4a046110187 Mon Sep 17 00:00:00 2001 From: Patrik Bodin Date: Tue, 15 Sep 2026 05:25:48 -0700 Subject: [PATCH 08/15] scrape: fix unit_of_measurement field hardcoded to temperature units (#181361) Co-authored-by: G Johansson --- homeassistant/components/scrape/config_flow.py | 15 ++++++++++----- homeassistant/components/scrape/strings.json | 7 ------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 332be51434aa9..9f497971956a9 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -15,7 +15,7 @@ DEFAULT_METHOD, METHODS, ) -from homeassistant.components.sensor import CONF_STATE_CLASS +from homeassistant.components.sensor import CONF_STATE_CLASS, DEVICE_CLASS_UNITS from homeassistant.config_entries import ( SOURCE_USER, ConfigEntry, @@ -45,7 +45,6 @@ HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, Platform, - UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( @@ -161,10 +160,16 @@ probatio.Optional(CONF_STATE_CLASS): StateClassSelector(), probatio.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], - custom_value=True, + options=list( + { + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + } + ), mode=SelectSelectorMode.DROPDOWN, - translation_key="unit_of_measurement", + custom_value=True, sort=True, ) ), diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 7e9f10a962c45..f12a072346056 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -175,12 +175,5 @@ } } } - }, - "selector": { - "unit_of_measurement": { - "options": { - "none": "No unit of measurement" - } - } } } From 086843e4f5bf4355f3833a6fac6f209da945f6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 15 Sep 2026 13:40:59 +0100 Subject: [PATCH 09/15] Split trigger helper (#182238) --- homeassistant/helpers/trigger/__init__.py | 837 +++++++++++++ homeassistant/helpers/trigger/descriptions.py | 164 +++ .../{trigger.py => trigger/entity_trigger.py} | 1084 +---------------- homeassistant/helpers/trigger/models.py | 182 +++ tests/components/common.py | 14 +- tests/helpers/test_trigger.py | 8 +- 6 files changed, 1227 insertions(+), 1062 deletions(-) create mode 100644 homeassistant/helpers/trigger/__init__.py create mode 100644 homeassistant/helpers/trigger/descriptions.py rename homeassistant/helpers/{trigger.py => trigger/entity_trigger.py} (52%) create mode 100644 homeassistant/helpers/trigger/models.py diff --git a/homeassistant/helpers/trigger/__init__.py b/homeassistant/helpers/trigger/__init__.py new file mode 100644 index 0000000000000..cc547784dd3dd --- /dev/null +++ b/homeassistant/helpers/trigger/__init__.py @@ -0,0 +1,837 @@ +"""Triggers.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable, Coroutine +from contextvars import copy_context +from dataclasses import dataclass, field +import functools +import inspect +import logging +from typing import Any, Literal, Protocol, TypedDict, cast + +import probatio + +from homeassistant.const import ( + CONF_ALIAS, + CONF_AT, + CONF_DEVICE_ID, + CONF_ENABLED, + CONF_ENTITY_ID, + CONF_EVENT_DATA, + CONF_ID, + CONF_OPTIONS, + CONF_PLATFORM, + CONF_TARGET, + CONF_VARIABLES, + CONF_ZONE, +) +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HassJob, + HassJobType, + HomeAssistant, + callback, + get_hassjob_callable_job_type, + is_callback, + valid_entity_id, +) +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import ( + get_absolute_description_key, + get_relative_description_key, + move_options_fields_to_top_level, +) +from homeassistant.helpers.frame import report_usage +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ( + UNDEFINED, + ConfigType, + TemplateVarsType, + UndefinedType, +) +from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey + +from .descriptions import ( + TRIGGER_DESCRIPTION_CACHE, + async_get_all_descriptions, + starts_with_dot, +) +from .entity_trigger import ( + ATTR_BEHAVIOR, + BEHAVIOR_ALL, + BEHAVIOR_EACH, + BEHAVIOR_FIRST, + ENTITY_STATE_TRIGGER_SCHEMA, + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, + NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA, + NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA, + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateChangedTriggerWithUnitBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateCrossedThresholdTriggerWithUnitBase, + EntityNumericalStateTriggerBase, + EntityNumericalStateTriggerWithUnitBase, + EntityOriginStateTriggerBase, + EntityTargetStateTriggerBase, + EntityTransitionTriggerBase, + EntityTriggerBase, + NotTriggeredReasonReporter, + StatelessEntityTriggerBase, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_changed_with_unit_trigger, + make_entity_numerical_state_crossed_threshold_trigger, + make_entity_numerical_state_crossed_threshold_with_unit_trigger, + make_entity_origin_state_trigger, + make_entity_target_state_trigger, + make_entity_transition_trigger, + make_numerical_state_changed_with_unit_schema, +) +from .models import ( + TRIGGERS, + NotTriggeredInfo, + Trigger, + TriggerAction, + TriggerActionPayloadBuilder, + TriggerActionRunner, + TriggerConfig, + TriggerNotTriggeredReporter, +) + +__all__ = [ + "ATTR_BEHAVIOR", + "BEHAVIOR_ALL", + "BEHAVIOR_EACH", + "BEHAVIOR_FIRST", + "DATA_PLUGGABLE_ACTIONS", + "ENTITY_STATE_TRIGGER_SCHEMA", + "ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR", + "NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA", + "NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA", + "TRIGGERS", + "TRIGGER_DESCRIPTION_CACHE", + "TRIGGER_PLATFORM_SUBSCRIPTIONS", + "EntityNumericalStateChangedTriggerBase", + "EntityNumericalStateChangedTriggerWithUnitBase", + "EntityNumericalStateCrossedThresholdTriggerBase", + "EntityNumericalStateCrossedThresholdTriggerWithUnitBase", + "EntityNumericalStateTriggerBase", + "EntityNumericalStateTriggerWithUnitBase", + "EntityOriginStateTriggerBase", + "EntityTargetStateTriggerBase", + "EntityTransitionTriggerBase", + "EntityTriggerBase", + "NotTriggeredInfo", + "NotTriggeredReasonReporter", + "PluggableAction", + "PluggableActionsEntry", + "StatelessEntityTriggerBase", + "Trigger", + "TriggerAction", + "TriggerActionPayloadBuilder", + "TriggerActionRunner", + "TriggerActionType", + "TriggerConfig", + "TriggerData", + "TriggerInfo", + "TriggerNotTriggeredAction", + "TriggerNotTriggeredReporter", + "TriggerProtocol", + "async_extract_devices", + "async_extract_entities", + "async_extract_targets", + "async_get_all_descriptions", + "async_initialize_triggers", + "async_setup", + "async_subscribe_platform_events", + "async_validate_trigger_config", + "make_entity_numerical_state_changed_trigger", + "make_entity_numerical_state_changed_with_unit_trigger", + "make_entity_numerical_state_crossed_threshold_trigger", + "make_entity_numerical_state_crossed_threshold_with_unit_trigger", + "make_entity_origin_state_trigger", + "make_entity_target_state_trigger", + "make_entity_transition_trigger", + "make_numerical_state_changed_with_unit_schema", + "starts_with_dot", +] + +_LOGGER = logging.getLogger(__name__) + +_PLATFORM_ALIASES = { + "device": "device_automation", + "event": "homeassistant", + "numeric_state": "homeassistant", + "state": "homeassistant", + "time_pattern": "homeassistant", + "time": "homeassistant", +} + +DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = HassKey( + "pluggable_actions" +) + +TRIGGER_PLATFORM_SUBSCRIPTIONS: HassKey[ + list[Callable[[set[str]], Coroutine[Any, Any, None]]] +] = HassKey("trigger_platform_subscriptions") + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the trigger helper.""" + hass.data[TRIGGER_DESCRIPTION_CACHE] = {} + hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] = [] + hass.data[TRIGGERS] = {} + + await async_process_integration_platforms( + hass, "trigger", _register_trigger_platform, wait_for_platforms=True + ) + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[set[str]], Coroutine[Any, Any, None]], +) -> Callable[[], None]: + """Subscribe to trigger platform events.""" + trigger_platform_event_subscriptions = hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] + + def remove_subscription() -> None: + trigger_platform_event_subscriptions.remove(on_event) + + trigger_platform_event_subscriptions.append(on_event) + return remove_subscription + + +async def _register_trigger_platform( + hass: HomeAssistant, integration_domain: str, platform: TriggerProtocol +) -> None: + """Register a trigger platform and notify listeners. + + If the trigger platform does not provide any triggers, + listeners will not be notified. + """ + new_triggers: set[str] = set() + triggers = hass.data[TRIGGERS] + + if hasattr(platform, "async_get_triggers"): + all_triggers = await platform.async_get_triggers(hass) + for trigger_key in all_triggers: + trigger_key = get_absolute_description_key(integration_domain, trigger_key) + if trigger_key not in triggers: + triggers[trigger_key] = integration_domain + new_triggers.add(trigger_key) + if not new_triggers: + if not all_triggers: + _LOGGER.debug( + "Integration %s returned no triggers in async_get_triggers", + integration_domain, + ) + return + elif hasattr(platform, "async_validate_trigger_config") or hasattr( + platform, "TRIGGER_SCHEMA" + ): + if integration_domain in triggers: + return + triggers[integration_domain] = integration_domain + new_triggers.add(integration_domain) + else: + _LOGGER.debug( + "Integration %s does not provide trigger support, skipping", + integration_domain, + ) + return + + # We don't use gather here because gather adds additional overhead + # when wrapping each coroutine in a task, and we expect our listeners + # to call trigger.async_get_all_descriptions which will only yield + # the first time it's called, after that it returns cached data. + for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]: + try: + await listener(new_triggers) + except Exception: + _LOGGER.exception("Error while notifying trigger platform listener") + + +class TriggerProtocol(Protocol): + """Define the format of trigger modules. + + New implementations should only implement async_get_triggers. + """ + + async def async_get_triggers(self, hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers provided by this integration.""" + + TRIGGER_SCHEMA: probatio.Schema + + async def async_validate_trigger_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + async def async_attach_trigger( + self, + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + + +class TriggerNotTriggeredAction(Protocol): + """Protocol type for the did_not_trigger consumer callback. + + Sibling of the action callback. Invoked - instead of the action - when a + trigger evaluated a relevant change but reported it did not fire. + """ + + @callback + def __call__( + self, + run_variables: dict[str, Any], + info: NotTriggeredInfo, + context: Context | None = None, + ) -> None: + """Define did_not_trigger consumer callback type.""" + + +class TriggerActionType(Protocol): + """Protocol type for trigger action callback. + + Contrary to TriggerAction, this type supports both sync and async callables. + """ + + def __call__( + self, + run_variables: dict[str, Any], + context: Context | None = None, + ) -> Coroutine[Any, Any, Any] | Any: + """Define action callback type.""" + + +class TriggerData(TypedDict): + """Trigger data.""" + + id: str + idx: str + alias: str | None + + +class TriggerInfo(TypedDict): + """Information about trigger.""" + + domain: str + name: str + variables: TemplateVarsType + trigger_data: TriggerData + + +@dataclass(slots=True) +class PluggableActionsEntry: + """Holder to keep track of all plugs and actions for a given trigger.""" + + plugs: set[PluggableAction] = field(default_factory=set) + actions: dict[ + object, + tuple[ + HassJob[[dict[str, Any], Context | None], Coroutine[Any, Any, None] | Any], + dict[str, Any], + ], + ] = field(default_factory=dict) + + +class PluggableAction: + """A pluggable action handler.""" + + _entry: PluggableActionsEntry | None = None + + def __init__(self, update: CALLBACK_TYPE | None = None) -> None: + """Initialize a pluggable action. + + :param update: callback triggered whenever triggers are attached or removed. + """ + self._update = update + + def __bool__(self) -> bool: + """Return if we have something attached.""" + return bool(self._entry and self._entry.actions) + + @callback + def async_run_update(self) -> None: + """Run update function if one exists.""" + if self._update: + self._update() + + @staticmethod + @callback + def async_get_registry(hass: HomeAssistant) -> dict[tuple, PluggableActionsEntry]: + """Return the pluggable actions registry.""" + if data := hass.data.get(DATA_PLUGGABLE_ACTIONS): + return data + data = hass.data[DATA_PLUGGABLE_ACTIONS] = defaultdict(PluggableActionsEntry) + return data + + @staticmethod + @callback + def async_attach_trigger( + hass: HomeAssistant, + trigger: dict[str, str], + action: TriggerActionType, + variables: dict[str, Any], + ) -> CALLBACK_TYPE: + """Attach an action to a trigger entry. + + Existing or future plugs registered will be attached. + """ + reg = PluggableAction.async_get_registry(hass) + key = tuple(sorted(trigger.items())) + entry = reg[key] + + def _update() -> None: + for plug in entry.plugs: + plug.async_run_update() + + @callback + def _remove() -> None: + """Remove this action attachment, and disconnect all plugs.""" + del entry.actions[_remove] + _update() + if not entry.actions and not entry.plugs: + del reg[key] + + job = HassJob(action, f"trigger {trigger} {variables}") + entry.actions[_remove] = (job, variables) + _update() + + return _remove + + @callback + def async_register( + self, hass: HomeAssistant, trigger: dict[str, str] + ) -> CALLBACK_TYPE: + """Register plug in the global plugs dictionary.""" + + reg = PluggableAction.async_get_registry(hass) + key = tuple(sorted(trigger.items())) + self._entry = reg[key] + self._entry.plugs.add(self) + + @callback + def _remove() -> None: + """Remove plug from registration. + + Clean up entry if there are no actions or plugs registered. + """ + assert self._entry + self._entry.plugs.remove(self) + if not self._entry.actions and not self._entry.plugs: + del reg[key] + self._entry = None + + return _remove + + async def async_run( + self, hass: HomeAssistant, context: Context | None = None + ) -> None: + """Run all actions.""" + assert self._entry + for job, variables in self._entry.actions.values(): + task = hass.async_run_hass_job(job, variables, context) + if task: + await task + + +async def _async_get_trigger_platform( + hass: HomeAssistant, trigger_key: str +) -> tuple[str, TriggerProtocol]: + platform_and_sub_type = trigger_key.split(".") + platform = platform_and_sub_type[0] + # Only apply aliases for old-style triggers (no sub_type). + # New-style triggers (e.g. "event.received") use the integration domain directly. + if len(platform_and_sub_type) == 1: + platform = _PLATFORM_ALIASES.get(platform, platform) + + try: + integration = await async_get_integration(hass, platform) + except IntegrationNotFound: + raise probatio.Invalid(f"Invalid trigger '{trigger_key}' specified") from None + try: + platform_module = await integration.async_get_platform("trigger") + except ImportError: + raise probatio.Invalid( + f"Integration '{platform}' does not provide trigger support" + ) from None + + # Ensure triggers are registered so descriptions can be loaded + await _register_trigger_platform(hass, platform, platform_module) + + return platform, platform_module + + +async def async_validate_trigger_config( + hass: HomeAssistant, trigger_config: list[ConfigType] +) -> list[ConfigType]: + """Validate triggers.""" + config = [] + for conf in trigger_config: + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) + if hasattr(platform, "async_get_triggers"): + trigger_descriptors = await platform.async_get_triggers(hass) + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + if not (trigger := trigger_descriptors.get(relative_trigger_key)): + raise probatio.Invalid(f"Invalid trigger '{trigger_key}' specified") + conf = await trigger.async_validate_complete_config(hass, conf) + elif hasattr(platform, "async_validate_trigger_config"): + conf = move_options_fields_to_top_level(conf, cv.TRIGGER_BASE_SCHEMA) + conf = await platform.async_validate_trigger_config(hass, conf) + else: + conf = move_options_fields_to_top_level(conf, cv.TRIGGER_BASE_SCHEMA) + conf = platform.TRIGGER_SCHEMA(conf) + config.append(conf) + return config + + +def _trigger_action_wrapper( + hass: HomeAssistant, action: Callable, conf: ConfigType +) -> Callable: + """Wrap trigger action with extra vars if configured. + + If action is a coroutine function, a coroutine function will be returned. + If action is a callback, a callback will be returned. + """ + if CONF_VARIABLES not in conf: + return action + + # Check for partials to properly determine if coroutine function + check_func = action + while isinstance(check_func, functools.partial): + check_func = check_func.func + + wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] + if inspect.iscoroutinefunction(check_func): + async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) + + @functools.wraps(async_action) + async def async_with_vars( + run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Wrap action with extra vars.""" + trigger_variables = conf[CONF_VARIABLES] + run_variables.update(trigger_variables.async_render(hass, run_variables)) + return await action(run_variables, context) + + wrapper_func = async_with_vars + + else: + + @functools.wraps(action) + def with_vars( + run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Wrap action with extra vars.""" + trigger_variables = conf[CONF_VARIABLES] + run_variables.update(trigger_variables.async_render(hass, run_variables)) + return action(run_variables, context) + + if is_callback(check_func): + with_vars = callback(with_vars) + + wrapper_func = with_vars + + return wrapper_func + + +async def _async_attach_trigger_cls( + hass: HomeAssistant, + trigger_cls: type[Trigger], + trigger_key: str, + conf: ConfigType, + action: Callable, + trigger_info: TriggerInfo, + did_not_trigger: TriggerNotTriggeredAction | None = None, +) -> CALLBACK_TYPE: + """Initialize a new Trigger class and attach it.""" + + def action_payload_builder( + extra_trigger_payload: dict[str, Any], description: str + ) -> dict[str, Any]: + """Build action variables.""" + payload = { + "trigger": { + **trigger_info["trigger_data"], + CONF_PLATFORM: trigger_key, + "description": description, + **extra_trigger_payload, + } + } + if CONF_VARIABLES in conf: + trigger_variables = conf[CONF_VARIABLES] + payload.update(trigger_variables.async_render(hass, payload)) + return payload + + report_not_triggered: TriggerNotTriggeredReporter | None = None + if did_not_trigger is not None: + not_triggered_action = did_not_trigger + + @callback + def report_not_triggered( + info: NotTriggeredInfo, context: Context | None = None + ) -> None: + """Forward a did-not-fire report to the consumer.""" + run_variables = { + "trigger": { + **trigger_info["trigger_data"], + CONF_PLATFORM: trigger_key, + } + } + # The consumer records a trace using the trace context variables. + # Run it in a copied context so it does not disturb the trace of the + # run that produced this state change (e.g. a chained automation). + copy_context().run(not_triggered_action, run_variables, info, context) + + # Wrap sync action so that it is always async. + # This simplifies the Trigger action runner interface by + # always returning a coroutine, removing the need for + # integrations to check for the return type when awaiting + # the action. + match get_hassjob_callable_job_type(action): + case HassJobType.Executor: + original_action = action + + async def wrapped_executor_action( + run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Wrap sync action to be called in executor.""" + return await hass.async_add_executor_job( + original_action, run_variables, context + ) + + action = wrapped_executor_action + + case HassJobType.Callback: + original_action = action + + async def wrapped_callback_action( + run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Wrap callback action to be awaitable.""" + return original_action(run_variables, context) + + action = wrapped_callback_action + + trigger = trigger_cls( + hass, + TriggerConfig( + key=trigger_key, + target=conf.get(CONF_TARGET), + options=conf.get(CONF_OPTIONS), + ), + ) + return await trigger.async_attach_action( + action, action_payload_builder, did_not_trigger=report_not_triggered + ) + + +async def async_initialize_triggers( + hass: HomeAssistant, + trigger_config: list[ConfigType], + action: Callable, + domain: str, + name: str, + log_cb: Callable, + home_assistant_start: bool | UndefinedType = UNDEFINED, + variables: TemplateVarsType = None, + *, + did_not_trigger: TriggerNotTriggeredAction | None = None, +) -> CALLBACK_TYPE | None: + """Initialize triggers. + + The optional ``did_not_trigger`` consumer is the sibling of ``action``, + invoked - for new-style triggers that support it - when a trigger evaluates + a relevant change but reports it did not fire. Old-style triggers ignore it. + """ + if home_assistant_start is not UNDEFINED: + report_usage( + "passes `home_assistant_start` to `async_initialize_triggers`, which is " + "deprecated and will be removed in Home Assistant 2027.8; the parameter " + "no longer has any effect", + breaks_in_ha_version="2027.8.0", + ) + + triggers: list[asyncio.Task[CALLBACK_TYPE]] = [] + for idx, conf in enumerate(trigger_config): + # Skip triggers that are not enabled + if CONF_ENABLED in conf: + enabled = conf[CONF_ENABLED] + if isinstance(enabled, Template): + try: + enabled = enabled.async_render(variables, limited=True) + except TemplateError as err: + log_cb(logging.ERROR, f"Error rendering enabled template: {err}") + continue + if not enabled: + continue + + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) + trigger_id = conf.get(CONF_ID, f"{idx}") + trigger_idx = f"{idx}" + trigger_alias = conf.get(CONF_ALIAS) + trigger_data = TriggerData(id=trigger_id, idx=trigger_idx, alias=trigger_alias) + info = TriggerInfo( + domain=domain, + name=name, + variables=variables, + trigger_data=trigger_data, + ) + + if hasattr(platform, "async_get_triggers"): + trigger_descriptors = await platform.async_get_triggers(hass) + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + trigger_cls = trigger_descriptors[relative_trigger_key] + coro = _async_attach_trigger_cls( + hass, trigger_cls, trigger_key, conf, action, info, did_not_trigger + ) + else: + action_wrapper = _trigger_action_wrapper(hass, action, conf) + coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) + + triggers.append(create_eager_task(coro)) + + attach_results = await asyncio.gather(*triggers, return_exceptions=True) + removes: list[Callable[[], None]] = [] + + for result in attach_results: + if isinstance(result, HomeAssistantError): + log_cb(logging.ERROR, f"Got error '{result}' when setting up triggers for") + elif isinstance(result, Exception): + log_cb(logging.ERROR, "Error setting up trigger", exc_info=result) + elif isinstance(result, BaseException): + raise result from None + elif result is None: + log_cb( # type: ignore[unreachable] + logging.ERROR, "Unknown error while setting up trigger (empty result)" + ) + else: + removes.append(result) + + if not removes: + return None + + log_cb(logging.INFO, "Initialized trigger") + + @callback + def remove_triggers() -> None: + """Remove triggers.""" + for remove in removes: + remove() + + return remove_triggers + + +@callback +def async_extract_devices(trigger_conf: dict) -> list[str]: + """Extract devices from a trigger config.""" + if trigger_conf[CONF_PLATFORM] == "device": + return [trigger_conf[CONF_DEVICE_ID]] + + if ( + trigger_conf[CONF_PLATFORM] == "event" + and CONF_EVENT_DATA in trigger_conf + and CONF_DEVICE_ID in trigger_conf[CONF_EVENT_DATA] + and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID], str) + ): + return [trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID]] + + if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf: + return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return] + + if target_devices := async_extract_targets(trigger_conf, CONF_DEVICE_ID): + return target_devices + + return [] + + +@callback +def async_extract_entities(trigger_conf: dict) -> list[str]: + """Extract entities from a trigger config.""" + if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): + return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return] + + if trigger_conf[CONF_PLATFORM] == "time": + # Each at time can be a time, an entity id, an entity id with + # an offset, or a template. + entity_ids: list[str] = [] + for at_time in trigger_conf[CONF_AT]: + if isinstance(at_time, str) and valid_entity_id(at_time): + entity_ids.append(at_time) + elif isinstance(at_time, dict) and CONF_ENTITY_ID in at_time: + entity_ids.append(at_time[CONF_ENTITY_ID]) + return entity_ids + + if trigger_conf[CONF_PLATFORM] == "device": + # Only extract the entity if it has been resolved to an entity id + # during validation; unvalidated configs hold an entity registry id. + if isinstance( + entity_id := trigger_conf.get(CONF_ENTITY_ID), str + ) and valid_entity_id(entity_id): + return [entity_id] + return [] + + if trigger_conf[CONF_PLATFORM] == "calendar": + return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]] + + if trigger_conf[CONF_PLATFORM] == "zone": + options = trigger_conf[CONF_OPTIONS] + return [*options[CONF_ENTITY_ID], options[CONF_ZONE]] + + if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"): + return [ + *async_extract_targets(trigger_conf, CONF_ENTITY_ID), + trigger_conf[CONF_OPTIONS][CONF_ZONE], + ] + + if trigger_conf[CONF_PLATFORM] == "geo_location": + return [trigger_conf[CONF_ZONE]] + + if trigger_conf[CONF_PLATFORM] == "sun": + return ["sun.sun"] + + if ( + trigger_conf[CONF_PLATFORM] == "event" + and CONF_EVENT_DATA in trigger_conf + and CONF_ENTITY_ID in trigger_conf[CONF_EVENT_DATA] + and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID], str) + and valid_entity_id(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]) + ): + return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]] + + if target_entities := async_extract_targets(trigger_conf, CONF_ENTITY_ID): + return target_entities + + return [] + + +@callback +def async_extract_targets( + config: dict, + target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"], +) -> list[str]: + """Extract targets from a target config.""" + if not (target_conf := config.get(CONF_TARGET)): + return [] + if not (targets := target_conf.get(target)): + return [] + return [targets] if isinstance(targets, str) else targets diff --git a/homeassistant/helpers/trigger/descriptions.py b/homeassistant/helpers/trigger/descriptions.py new file mode 100644 index 0000000000000..be63871c098af --- /dev/null +++ b/homeassistant/helpers/trigger/descriptions.py @@ -0,0 +1,164 @@ +"""Triggers description helpers.""" + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, Any, cast + +import probatio + +from homeassistant.const import CONF_SELECTOR +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.automation import get_absolute_description_key +from homeassistant.helpers.selector import TargetSelector +from homeassistant.loader import Integration, async_get_integrations +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.yaml import load_yaml_dict + +from .models import TRIGGERS + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( + "trigger_description_cache" +) + +# Basic schemas to sanity check the trigger descriptions, +# full validation is done by hassfest.triggers +_FIELD_DESCRIPTION_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_SELECTOR): selector.validate_selector, + }, + extra=probatio.ALLOW_EXTRA, +) + +_TRIGGER_DESCRIPTION_SCHEMA = probatio.Schema( + { + probatio.Optional("target"): TargetSelector.CONFIG_SCHEMA, + probatio.Optional("fields"): probatio.Schema({str: _FIELD_DESCRIPTION_SCHEMA}), + }, + extra=probatio.ALLOW_EXTRA, +) + + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise probatio.Invalid("Key does not start with .") + return key + + +_TRIGGERS_DESCRIPTION_SCHEMA = probatio.Schema( + { + probatio.Remove(probatio.All(str, starts_with_dot)): object, + cv.underscore_slug: probatio.Any(None, _TRIGGER_DESCRIPTION_SCHEMA), + } +) + + +def _load_triggers_file(integration: Integration) -> dict[str, Any]: + """Load triggers file for an integration.""" + try: + return cast( + dict[str, Any], + _TRIGGERS_DESCRIPTION_SCHEMA( + load_yaml_dict(str(integration.file_path / "triggers.yaml")) + ), + ) + except FileNotFoundError: + _LOGGER.warning( + "Unable to find triggers.yaml for the %s integration", integration.domain + ) + return {} + except (HomeAssistantError, probatio.Invalid) as ex: + _LOGGER.warning( + "Unable to parse triggers.yaml for the %s integration: %s", + integration.domain, + ex, + ) + return {} + + +def _load_triggers_files( + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: + """Load trigger files for multiple integrations.""" + return { + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_triggers_file(integration).items() + } + for integration in integrations + } + + +async def async_get_all_descriptions( + hass: HomeAssistant, +) -> dict[str, dict[str, Any] | None]: + """Return descriptions (i.e. user documentation) for all triggers.""" + descriptions_cache = hass.data[TRIGGER_DESCRIPTION_CACHE] + + triggers = hass.data[TRIGGERS] + # See if there are new triggers not seen before. + # Any trigger that we saw before already has an entry in description_cache. + all_triggers = set(triggers) + previous_all_triggers = set(descriptions_cache) + # If the triggers are the same, we can return the cache + if previous_all_triggers == all_triggers: + return descriptions_cache + + # Files we loaded for missing descriptions + new_triggers_descriptions: dict[str, dict[str, Any]] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new triggers get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + triggers = triggers.copy() + + if missing_triggers := all_triggers.difference(descriptions_cache): + domains_with_missing_triggers = { + triggers[missing_trigger] for missing_trigger in missing_triggers + } + ints_or_excs = await async_get_integrations(hass, domains_with_missing_triggers) + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration and int_or_exc.has_triggers: + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.debug( + "Failed to load triggers.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) + + if integrations: + new_triggers_descriptions = await hass.async_add_executor_job( + _load_triggers_files, integrations + ) + + # Make a copy of the old cache and add missing descriptions to it + new_descriptions_cache = descriptions_cache.copy() + for missing_trigger in missing_triggers: + domain = triggers[missing_trigger] + if ( + yaml_description := new_triggers_descriptions.get(domain, {}).get( + missing_trigger + ) + ) is None: + _LOGGER.debug( + "No trigger descriptions found for trigger %s, skipping", + missing_trigger, + ) + new_descriptions_cache[missing_trigger] = None + continue + + description = {"fields": yaml_description.get("fields", {})} + if (target := yaml_description.get("target")) is not None: + description["target"] = target + + new_descriptions_cache[missing_trigger] = description + hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache + return new_descriptions_cache diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger/entity_trigger.py similarity index 52% rename from homeassistant/helpers/trigger.py rename to homeassistant/helpers/trigger/entity_trigger.py index 0dfe924eac574..10589df59c361 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger/entity_trigger.py @@ -1,45 +1,16 @@ -"""Triggers.""" - -import abc -import asyncio -from collections import defaultdict -from collections.abc import Callable, Coroutine, Iterable, Mapping -from contextvars import copy_context -from dataclasses import dataclass, field +"""Entity state trigger helpers.""" + +from collections.abc import Iterable, Mapping from datetime import datetime, timedelta -import functools -import inspect -import logging -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, - Final, - Literal, - Protocol, - TypedDict, - cast, - override, -) +from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, cast, override import probatio from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_ALIAS, - CONF_AT, - CONF_DEVICE_ID, - CONF_ENABLED, - CONF_ENTITY_ID, - CONF_EVENT_DATA, CONF_FOR, - CONF_ID, CONF_OPTIONS, - CONF_PLATFORM, - CONF_SELECTOR, CONF_TARGET, - CONF_VARIABLES, - CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, EntityStateAttribute, @@ -47,273 +18,40 @@ from homeassistant.core import ( CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, - Context, - HassJob, - HassJobType, HomeAssistant, State, async_get_hass_or_none, callback, - get_hassjob_callable_job_type, - is_callback, - valid_entity_id, -) -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.loader import ( - Integration, - IntegrationNotFound, - async_get_integration, - async_get_integrations, ) -from homeassistant.util.async_ import create_eager_task -from homeassistant.util.hass_dict import HassKey -from homeassistant.util.unit_conversion import BaseUnitConverter -from homeassistant.util.yaml import load_yaml_dict - -from . import config_validation as cv, selector -from .automation import ( +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import ( DomainSpec, ThresholdConfig, filter_by_domain_specs, - get_absolute_description_key, - get_relative_description_key, - move_options_fields_to_top_level, ) -from .event import async_call_later -from .frame import report_usage -from .integration_platform import async_process_integration_platforms -from .selector import ( +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.selector import ( NumericThresholdMode, NumericThresholdSelector, NumericThresholdSelectorConfig, NumericThresholdType, - TargetSelector, ) -from .target import ( +from homeassistant.helpers.target import ( TargetStateChangedData, async_track_target_selector_state_change_event, ) -from .template import Template -from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType - -_LOGGER = logging.getLogger(__name__) - -_PLATFORM_ALIASES = { - "device": "device_automation", - "event": "homeassistant", - "numeric_state": "homeassistant", - "state": "homeassistant", - "time_pattern": "homeassistant", - "time": "homeassistant", -} - -DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = HassKey( - "pluggable_actions" -) - -TRIGGER_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( - "trigger_description_cache" -) -TRIGGER_PLATFORM_SUBSCRIPTIONS: HassKey[ - list[Callable[[set[str]], Coroutine[Any, Any, None]]] -] = HassKey("trigger_platform_subscriptions") -TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers") - - -# Basic schemas to sanity check the trigger descriptions, -# full validation is done by hassfest.triggers -_FIELD_DESCRIPTION_SCHEMA = probatio.Schema( - { - probatio.Optional(CONF_SELECTOR): selector.validate_selector, - }, - extra=probatio.ALLOW_EXTRA, -) - -_TRIGGER_DESCRIPTION_SCHEMA = probatio.Schema( - { - probatio.Optional("target"): TargetSelector.CONFIG_SCHEMA, - probatio.Optional("fields"): probatio.Schema({str: _FIELD_DESCRIPTION_SCHEMA}), - }, - extra=probatio.ALLOW_EXTRA, -) - - -def starts_with_dot(key: str) -> str: - """Check if key starts with dot.""" - if not key.startswith("."): - raise probatio.Invalid("Key does not start with .") - return key - - -_TRIGGERS_DESCRIPTION_SCHEMA = probatio.Schema( - { - probatio.Remove(probatio.All(str, starts_with_dot)): object, - cv.underscore_slug: probatio.Any(None, _TRIGGER_DESCRIPTION_SCHEMA), - } -) - - -async def async_setup(hass: HomeAssistant) -> None: - """Set up the trigger helper.""" - hass.data[TRIGGER_DESCRIPTION_CACHE] = {} - hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] = [] - hass.data[TRIGGERS] = {} - - await async_process_integration_platforms( - hass, "trigger", _register_trigger_platform, wait_for_platforms=True - ) - - -@callback -def async_subscribe_platform_events( - hass: HomeAssistant, - on_event: Callable[[set[str]], Coroutine[Any, Any, None]], -) -> Callable[[], None]: - """Subscribe to trigger platform events.""" - trigger_platform_event_subscriptions = hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] - - def remove_subscription() -> None: - trigger_platform_event_subscriptions.remove(on_event) - - trigger_platform_event_subscriptions.append(on_event) - return remove_subscription - - -async def _register_trigger_platform( - hass: HomeAssistant, integration_domain: str, platform: TriggerProtocol -) -> None: - """Register a trigger platform and notify listeners. - - If the trigger platform does not provide any triggers, - listeners will not be notified. - """ - new_triggers: set[str] = set() - triggers = hass.data[TRIGGERS] - - if hasattr(platform, "async_get_triggers"): - all_triggers = await platform.async_get_triggers(hass) - for trigger_key in all_triggers: - trigger_key = get_absolute_description_key(integration_domain, trigger_key) - if trigger_key not in triggers: - triggers[trigger_key] = integration_domain - new_triggers.add(trigger_key) - if not new_triggers: - if not all_triggers: - _LOGGER.debug( - "Integration %s returned no triggers in async_get_triggers", - integration_domain, - ) - return - elif hasattr(platform, "async_validate_trigger_config") or hasattr( - platform, "TRIGGER_SCHEMA" - ): - if integration_domain in triggers: - return - triggers[integration_domain] = integration_domain - new_triggers.add(integration_domain) - else: - _LOGGER.debug( - "Integration %s does not provide trigger support, skipping", - integration_domain, - ) - return - - # We don't use gather here because gather adds additional overhead - # when wrapping each coroutine in a task, and we expect our listeners - # to call trigger.async_get_all_descriptions which will only yield - # the first time it's called, after that it returns cached data. - for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]: - try: - await listener(new_triggers) - except Exception: - _LOGGER.exception("Error while notifying trigger platform listener") - +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType +from homeassistant.util.unit_conversion import BaseUnitConverter -_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( - { - probatio.Optional(CONF_OPTIONS): object, - probatio.Optional(CONF_TARGET): cv.TARGET_FIELDS, - } +from .models import ( + NotTriggeredInfo, + Trigger, + TriggerActionRunner, + TriggerConfig, + TriggerNotTriggeredReporter, ) - -class Trigger(abc.ABC): - """Trigger class.""" - - _hass: HomeAssistant - - @classmethod - async def async_validate_complete_config( - cls, hass: HomeAssistant, complete_config: ConfigType - ) -> ConfigType: - """Validate complete config. - - The complete config includes fields that are generic to all triggers, - such as the alias or the ID. - This method should be overridden by triggers that need to migrate - from the old-style config. - """ - complete_config = _TRIGGER_SCHEMA(complete_config) - - specific_config: ConfigType = {} - for key in (CONF_OPTIONS, CONF_TARGET): - if key in complete_config: - specific_config[key] = complete_config.pop(key) - specific_config = await cls.async_validate_config(hass, specific_config) - - for key in (CONF_OPTIONS, CONF_TARGET): - if key in specific_config: - complete_config[key] = specific_config[key] - - return complete_config - - @classmethod - @abc.abstractmethod - async def async_validate_config( - cls, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: - """Initialize trigger.""" - self._hass = hass - - async def async_attach_action( - self, - action: TriggerAction, - action_payload_builder: TriggerActionPayloadBuilder, - *, - did_not_trigger: TriggerNotTriggeredReporter | None = None, - ) -> CALLBACK_TYPE: - """Attach the trigger to an action. - - The optional ``did_not_trigger`` reporter is the sibling of the action - runner: triggers may call it - in certain interesting cases - when they - evaluate a relevant change but decide not to fire. - """ - - @callback - def run_action( - extra_trigger_payload: dict[str, Any], - description: str, - context: Context | None = None, - ) -> asyncio.Task[Any]: - """Run action with trigger variables.""" - - payload = action_payload_builder(extra_trigger_payload, description) - return self._hass.async_create_task(action(payload, context)) - - return await self.async_attach_runner(run_action, did_not_trigger) - - @abc.abstractmethod - async def async_attach_runner( - self, - run_action: TriggerActionRunner, - did_not_trigger: TriggerNotTriggeredReporter | None = None, - ) -> CALLBACK_TYPE: - """Attach the trigger to an action runner.""" - - ATTR_BEHAVIOR: Final = "behavior" BEHAVIOR_FIRST: Final = "first" BEHAVIOR_ALL: Final = "all" @@ -327,7 +65,10 @@ def _create_deprecated_behavior_issue(deprecated: str, replacement: str) -> None if (hass := async_get_hass_or_none()) is None: return - from .issue_registry import IssueSeverity, async_create_issue # noqa: PLC0415 + from homeassistant.helpers.issue_registry import ( # noqa: PLC0415 + IssueSeverity, + async_create_issue, + ) async_create_issue( hass, @@ -375,6 +116,13 @@ def _backwards_compatible_behavior(value: Any) -> Any: ) +class NotTriggeredReasonReporter(Protocol): + """Reports why an evaluated change did not fire an entity trigger.""" + + def __call__(self, reason: str, /, **data: Any) -> None: + """Report, with diagnostic data, why the change did not fire.""" + + def _report_not_triggered_noop(reason: str, /, **data: Any) -> None: """Swallow a not-triggered report; used when diagnostics are not wanted.""" @@ -1342,777 +1090,3 @@ class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerWithUnitBase): _unit_converter = unit_converter return CustomTrigger - - -class TriggerProtocol(Protocol): - """Define the format of trigger modules. - - New implementations should only implement async_get_triggers. - """ - - async def async_get_triggers(self, hass: HomeAssistant) -> dict[str, type[Trigger]]: - """Return the triggers provided by this integration.""" - - TRIGGER_SCHEMA: probatio.Schema - - async def async_validate_trigger_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - async def async_attach_trigger( - self, - hass: HomeAssistant, - config: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, - ) -> CALLBACK_TYPE: - """Attach a trigger.""" - - -@dataclass(slots=True, frozen=True) -class TriggerConfig: - """Trigger config.""" - - key: str # The key used to identify the trigger, e.g. "zwave.event" - target: dict[str, Any] | None = None - options: dict[str, Any] | None = None - - -@dataclass(slots=True, frozen=True) -class NotTriggeredInfo: - """Diagnostics describing why a trigger evaluated a change but did not fire. - - Passed by a trigger to its ``did_not_trigger`` reporter, the sibling of the - action runner that is called - in certain interesting cases - when the - trigger does not fire. ``reason`` is a stable, machine-readable code; the - optional ``data`` carries the evaluated context for the trace. - """ - - reason: str - data: Mapping[str, Any] | None = None - - def as_dict(self) -> dict[str, Any]: - """Return a JSON-serializable dict for storing in a trace.""" - result: dict[str, Any] = {"reason": self.reason} - if self.data is not None: - result["data"] = dict(self.data) - return result - - -class TriggerActionRunner(Protocol): - """Protocol type for the trigger action runner helper callback.""" - - @callback - def __call__( - self, - extra_trigger_payload: dict[str, Any], - description: str, - context: Context | None = None, - ) -> asyncio.Task[Any]: - """Define trigger action runner type. - - Returns: - A Task that allows awaiting for the action to finish. - """ - - -class TriggerNotTriggeredReporter(Protocol): - """Protocol type for the did_not_trigger reporter passed to a trigger runner. - - A trigger calls this to report that it evaluated a relevant change but - decided not to fire, supplying diagnostics for tracing. - """ - - @callback - def __call__( - self, - info: NotTriggeredInfo, - context: Context | None = None, - ) -> None: - """Report that the trigger did not fire.""" - - -class NotTriggeredReasonReporter(Protocol): - """Reports why an evaluated change did not fire an entity trigger.""" - - def __call__(self, reason: str, /, **data: Any) -> None: - """Report, with diagnostic data, why the change did not fire.""" - - -class TriggerNotTriggeredAction(Protocol): - """Protocol type for the did_not_trigger consumer callback. - - Sibling of the action callback. Invoked - instead of the action - when a - trigger evaluated a relevant change but reported it did not fire. - """ - - @callback - def __call__( - self, - run_variables: dict[str, Any], - info: NotTriggeredInfo, - context: Context | None = None, - ) -> None: - """Define did_not_trigger consumer callback type.""" - - -class TriggerActionPayloadBuilder(Protocol): - """Protocol type for the trigger action payload builder.""" - - def __call__( - self, extra_trigger_payload: dict[str, Any], description: str - ) -> dict[str, Any]: - """Define trigger action payload builder type.""" - - -class TriggerAction(Protocol): - """Protocol type for trigger action callback.""" - - async def __call__( - self, run_variables: dict[str, Any], context: Context | None = None - ) -> Any: - """Define action callback type.""" - - -class TriggerActionType(Protocol): - """Protocol type for trigger action callback. - - Contrary to TriggerAction, this type supports both sync and async callables. - """ - - def __call__( - self, - run_variables: dict[str, Any], - context: Context | None = None, - ) -> Coroutine[Any, Any, Any] | Any: - """Define action callback type.""" - - -class TriggerData(TypedDict): - """Trigger data.""" - - id: str - idx: str - alias: str | None - - -class TriggerInfo(TypedDict): - """Information about trigger.""" - - domain: str - name: str - variables: TemplateVarsType - trigger_data: TriggerData - - -@dataclass(slots=True) -class PluggableActionsEntry: - """Holder to keep track of all plugs and actions for a given trigger.""" - - plugs: set[PluggableAction] = field(default_factory=set) - actions: dict[ - object, - tuple[ - HassJob[[dict[str, Any], Context | None], Coroutine[Any, Any, None] | Any], - dict[str, Any], - ], - ] = field(default_factory=dict) - - -class PluggableAction: - """A pluggable action handler.""" - - _entry: PluggableActionsEntry | None = None - - def __init__(self, update: CALLBACK_TYPE | None = None) -> None: - """Initialize a pluggable action. - - :param update: callback triggered whenever triggers are attached or removed. - """ - self._update = update - - def __bool__(self) -> bool: - """Return if we have something attached.""" - return bool(self._entry and self._entry.actions) - - @callback - def async_run_update(self) -> None: - """Run update function if one exists.""" - if self._update: - self._update() - - @staticmethod - @callback - def async_get_registry(hass: HomeAssistant) -> dict[tuple, PluggableActionsEntry]: - """Return the pluggable actions registry.""" - if data := hass.data.get(DATA_PLUGGABLE_ACTIONS): - return data - data = hass.data[DATA_PLUGGABLE_ACTIONS] = defaultdict(PluggableActionsEntry) - return data - - @staticmethod - @callback - def async_attach_trigger( - hass: HomeAssistant, - trigger: dict[str, str], - action: TriggerActionType, - variables: dict[str, Any], - ) -> CALLBACK_TYPE: - """Attach an action to a trigger entry. - - Existing or future plugs registered will be attached. - """ - reg = PluggableAction.async_get_registry(hass) - key = tuple(sorted(trigger.items())) - entry = reg[key] - - def _update() -> None: - for plug in entry.plugs: - plug.async_run_update() - - @callback - def _remove() -> None: - """Remove this action attachment, and disconnect all plugs.""" - del entry.actions[_remove] - _update() - if not entry.actions and not entry.plugs: - del reg[key] - - job = HassJob(action, f"trigger {trigger} {variables}") - entry.actions[_remove] = (job, variables) - _update() - - return _remove - - @callback - def async_register( - self, hass: HomeAssistant, trigger: dict[str, str] - ) -> CALLBACK_TYPE: - """Register plug in the global plugs dictionary.""" - - reg = PluggableAction.async_get_registry(hass) - key = tuple(sorted(trigger.items())) - self._entry = reg[key] - self._entry.plugs.add(self) - - @callback - def _remove() -> None: - """Remove plug from registration. - - Clean up entry if there are no actions or plugs registered. - """ - assert self._entry - self._entry.plugs.remove(self) - if not self._entry.actions and not self._entry.plugs: - del reg[key] - self._entry = None - - return _remove - - async def async_run( - self, hass: HomeAssistant, context: Context | None = None - ) -> None: - """Run all actions.""" - assert self._entry - for job, variables in self._entry.actions.values(): - task = hass.async_run_hass_job(job, variables, context) - if task: - await task - - -async def _async_get_trigger_platform( - hass: HomeAssistant, trigger_key: str -) -> tuple[str, TriggerProtocol]: - platform_and_sub_type = trigger_key.split(".") - platform = platform_and_sub_type[0] - # Only apply aliases for old-style triggers (no sub_type). - # New-style triggers (e.g. "event.received") use the integration domain directly. - if len(platform_and_sub_type) == 1: - platform = _PLATFORM_ALIASES.get(platform, platform) - - try: - integration = await async_get_integration(hass, platform) - except IntegrationNotFound: - raise probatio.Invalid(f"Invalid trigger '{trigger_key}' specified") from None - try: - platform_module = await integration.async_get_platform("trigger") - except ImportError: - raise probatio.Invalid( - f"Integration '{platform}' does not provide trigger support" - ) from None - - # Ensure triggers are registered so descriptions can be loaded - await _register_trigger_platform(hass, platform, platform_module) - - return platform, platform_module - - -async def async_validate_trigger_config( - hass: HomeAssistant, trigger_config: list[ConfigType] -) -> list[ConfigType]: - """Validate triggers.""" - config = [] - for conf in trigger_config: - trigger_key: str = conf[CONF_PLATFORM] - platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) - if hasattr(platform, "async_get_triggers"): - trigger_descriptors = await platform.async_get_triggers(hass) - relative_trigger_key = get_relative_description_key( - platform_domain, trigger_key - ) - if not (trigger := trigger_descriptors.get(relative_trigger_key)): - raise probatio.Invalid(f"Invalid trigger '{trigger_key}' specified") - conf = await trigger.async_validate_complete_config(hass, conf) - elif hasattr(platform, "async_validate_trigger_config"): - conf = move_options_fields_to_top_level(conf, cv.TRIGGER_BASE_SCHEMA) - conf = await platform.async_validate_trigger_config(hass, conf) - else: - conf = move_options_fields_to_top_level(conf, cv.TRIGGER_BASE_SCHEMA) - conf = platform.TRIGGER_SCHEMA(conf) - config.append(conf) - return config - - -def _trigger_action_wrapper( - hass: HomeAssistant, action: Callable, conf: ConfigType -) -> Callable: - """Wrap trigger action with extra vars if configured. - - If action is a coroutine function, a coroutine function will be returned. - If action is a callback, a callback will be returned. - """ - if CONF_VARIABLES not in conf: - return action - - # Check for partials to properly determine if coroutine function - check_func = action - while isinstance(check_func, functools.partial): - check_func = check_func.func - - wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] - if inspect.iscoroutinefunction(check_func): - async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) - - @functools.wraps(async_action) - async def async_with_vars( - run_variables: dict[str, Any], context: Context | None = None - ) -> Any: - """Wrap action with extra vars.""" - trigger_variables = conf[CONF_VARIABLES] - run_variables.update(trigger_variables.async_render(hass, run_variables)) - return await action(run_variables, context) - - wrapper_func = async_with_vars - - else: - - @functools.wraps(action) - def with_vars( - run_variables: dict[str, Any], context: Context | None = None - ) -> Any: - """Wrap action with extra vars.""" - trigger_variables = conf[CONF_VARIABLES] - run_variables.update(trigger_variables.async_render(hass, run_variables)) - return action(run_variables, context) - - if is_callback(check_func): - with_vars = callback(with_vars) - - wrapper_func = with_vars - - return wrapper_func - - -async def _async_attach_trigger_cls( - hass: HomeAssistant, - trigger_cls: type[Trigger], - trigger_key: str, - conf: ConfigType, - action: Callable, - trigger_info: TriggerInfo, - did_not_trigger: TriggerNotTriggeredAction | None = None, -) -> CALLBACK_TYPE: - """Initialize a new Trigger class and attach it.""" - - def action_payload_builder( - extra_trigger_payload: dict[str, Any], description: str - ) -> dict[str, Any]: - """Build action variables.""" - payload = { - "trigger": { - **trigger_info["trigger_data"], - CONF_PLATFORM: trigger_key, - "description": description, - **extra_trigger_payload, - } - } - if CONF_VARIABLES in conf: - trigger_variables = conf[CONF_VARIABLES] - payload.update(trigger_variables.async_render(hass, payload)) - return payload - - report_not_triggered: TriggerNotTriggeredReporter | None = None - if did_not_trigger is not None: - not_triggered_action = did_not_trigger - - @callback - def report_not_triggered( - info: NotTriggeredInfo, context: Context | None = None - ) -> None: - """Forward a did-not-fire report to the consumer.""" - run_variables = { - "trigger": { - **trigger_info["trigger_data"], - CONF_PLATFORM: trigger_key, - } - } - # The consumer records a trace using the trace context variables. - # Run it in a copied context so it does not disturb the trace of the - # run that produced this state change (e.g. a chained automation). - copy_context().run(not_triggered_action, run_variables, info, context) - - # Wrap sync action so that it is always async. - # This simplifies the Trigger action runner interface by - # always returning a coroutine, removing the need for - # integrations to check for the return type when awaiting - # the action. - match get_hassjob_callable_job_type(action): - case HassJobType.Executor: - original_action = action - - async def wrapped_executor_action( - run_variables: dict[str, Any], context: Context | None = None - ) -> Any: - """Wrap sync action to be called in executor.""" - return await hass.async_add_executor_job( - original_action, run_variables, context - ) - - action = wrapped_executor_action - - case HassJobType.Callback: - original_action = action - - async def wrapped_callback_action( - run_variables: dict[str, Any], context: Context | None = None - ) -> Any: - """Wrap callback action to be awaitable.""" - return original_action(run_variables, context) - - action = wrapped_callback_action - - trigger = trigger_cls( - hass, - TriggerConfig( - key=trigger_key, - target=conf.get(CONF_TARGET), - options=conf.get(CONF_OPTIONS), - ), - ) - return await trigger.async_attach_action( - action, action_payload_builder, did_not_trigger=report_not_triggered - ) - - -async def async_initialize_triggers( - hass: HomeAssistant, - trigger_config: list[ConfigType], - action: Callable, - domain: str, - name: str, - log_cb: Callable, - home_assistant_start: bool | UndefinedType = UNDEFINED, - variables: TemplateVarsType = None, - *, - did_not_trigger: TriggerNotTriggeredAction | None = None, -) -> CALLBACK_TYPE | None: - """Initialize triggers. - - The optional ``did_not_trigger`` consumer is the sibling of ``action``, - invoked - for new-style triggers that support it - when a trigger evaluates - a relevant change but reports it did not fire. Old-style triggers ignore it. - """ - if home_assistant_start is not UNDEFINED: - report_usage( - "passes `home_assistant_start` to `async_initialize_triggers`, which is " - "deprecated and will be removed in Home Assistant 2027.8; the parameter " - "no longer has any effect", - breaks_in_ha_version="2027.8.0", - ) - - triggers: list[asyncio.Task[CALLBACK_TYPE]] = [] - for idx, conf in enumerate(trigger_config): - # Skip triggers that are not enabled - if CONF_ENABLED in conf: - enabled = conf[CONF_ENABLED] - if isinstance(enabled, Template): - try: - enabled = enabled.async_render(variables, limited=True) - except TemplateError as err: - log_cb(logging.ERROR, f"Error rendering enabled template: {err}") - continue - if not enabled: - continue - - trigger_key: str = conf[CONF_PLATFORM] - platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) - trigger_id = conf.get(CONF_ID, f"{idx}") - trigger_idx = f"{idx}" - trigger_alias = conf.get(CONF_ALIAS) - trigger_data = TriggerData(id=trigger_id, idx=trigger_idx, alias=trigger_alias) - info = TriggerInfo( - domain=domain, - name=name, - variables=variables, - trigger_data=trigger_data, - ) - - if hasattr(platform, "async_get_triggers"): - trigger_descriptors = await platform.async_get_triggers(hass) - relative_trigger_key = get_relative_description_key( - platform_domain, trigger_key - ) - trigger_cls = trigger_descriptors[relative_trigger_key] - coro = _async_attach_trigger_cls( - hass, trigger_cls, trigger_key, conf, action, info, did_not_trigger - ) - else: - action_wrapper = _trigger_action_wrapper(hass, action, conf) - coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) - - triggers.append(create_eager_task(coro)) - - attach_results = await asyncio.gather(*triggers, return_exceptions=True) - removes: list[Callable[[], None]] = [] - - for result in attach_results: - if isinstance(result, HomeAssistantError): - log_cb(logging.ERROR, f"Got error '{result}' when setting up triggers for") - elif isinstance(result, Exception): - log_cb(logging.ERROR, "Error setting up trigger", exc_info=result) - elif isinstance(result, BaseException): - raise result from None - elif result is None: - log_cb( # type: ignore[unreachable] - logging.ERROR, "Unknown error while setting up trigger (empty result)" - ) - else: - removes.append(result) - - if not removes: - return None - - log_cb(logging.INFO, "Initialized trigger") - - @callback - def remove_triggers() -> None: - """Remove triggers.""" - for remove in removes: - remove() - - return remove_triggers - - -def _load_triggers_file(integration: Integration) -> dict[str, Any]: - """Load triggers file for an integration.""" - try: - return cast( - dict[str, Any], - _TRIGGERS_DESCRIPTION_SCHEMA( - load_yaml_dict(str(integration.file_path / "triggers.yaml")) - ), - ) - except FileNotFoundError: - _LOGGER.warning( - "Unable to find triggers.yaml for the %s integration", integration.domain - ) - return {} - except (HomeAssistantError, probatio.Invalid) as ex: - _LOGGER.warning( - "Unable to parse triggers.yaml for the %s integration: %s", - integration.domain, - ex, - ) - return {} - - -def _load_triggers_files( - integrations: Iterable[Integration], -) -> dict[str, dict[str, Any]]: - """Load trigger files for multiple integrations.""" - return { - integration.domain: { - get_absolute_description_key(integration.domain, key): value - for key, value in _load_triggers_file(integration).items() - } - for integration in integrations - } - - -async def async_get_all_descriptions( - hass: HomeAssistant, -) -> dict[str, dict[str, Any] | None]: - """Return descriptions (i.e. user documentation) for all triggers.""" - descriptions_cache = hass.data[TRIGGER_DESCRIPTION_CACHE] - - triggers = hass.data[TRIGGERS] - # See if there are new triggers not seen before. - # Any trigger that we saw before already has an entry in description_cache. - all_triggers = set(triggers) - previous_all_triggers = set(descriptions_cache) - # If the triggers are the same, we can return the cache - if previous_all_triggers == all_triggers: - return descriptions_cache - - # Files we loaded for missing descriptions - new_triggers_descriptions: dict[str, dict[str, Any]] = {} - # We try to avoid making a copy in the event the cache is good, - # but now we must make a copy in case new triggers get added - # while we are loading the missing ones so we do not - # add the new ones to the cache without their descriptions - triggers = triggers.copy() - - if missing_triggers := all_triggers.difference(descriptions_cache): - domains_with_missing_triggers = { - triggers[missing_trigger] for missing_trigger in missing_triggers - } - ints_or_excs = await async_get_integrations(hass, domains_with_missing_triggers) - integrations: list[Integration] = [] - for domain, int_or_exc in ints_or_excs.items(): - if type(int_or_exc) is Integration and int_or_exc.has_triggers: - integrations.append(int_or_exc) - continue - if TYPE_CHECKING: - assert isinstance(int_or_exc, Exception) - _LOGGER.debug( - "Failed to load triggers.yaml for integration: %s", - domain, - exc_info=int_or_exc, - ) - - if integrations: - new_triggers_descriptions = await hass.async_add_executor_job( - _load_triggers_files, integrations - ) - - # Make a copy of the old cache and add missing descriptions to it - new_descriptions_cache = descriptions_cache.copy() - for missing_trigger in missing_triggers: - domain = triggers[missing_trigger] - if ( - yaml_description := new_triggers_descriptions.get(domain, {}).get( - missing_trigger - ) - ) is None: - _LOGGER.debug( - "No trigger descriptions found for trigger %s, skipping", - missing_trigger, - ) - new_descriptions_cache[missing_trigger] = None - continue - - description = {"fields": yaml_description.get("fields", {})} - if (target := yaml_description.get("target")) is not None: - description["target"] = target - - new_descriptions_cache[missing_trigger] = description - hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache - return new_descriptions_cache - - -@callback -def async_extract_devices(trigger_conf: dict) -> list[str]: - """Extract devices from a trigger config.""" - if trigger_conf[CONF_PLATFORM] == "device": - return [trigger_conf[CONF_DEVICE_ID]] - - if ( - trigger_conf[CONF_PLATFORM] == "event" - and CONF_EVENT_DATA in trigger_conf - and CONF_DEVICE_ID in trigger_conf[CONF_EVENT_DATA] - and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID], str) - ): - return [trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID]] - - if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf: - return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return] - - if target_devices := async_extract_targets(trigger_conf, CONF_DEVICE_ID): - return target_devices - - return [] - - -@callback -def async_extract_entities(trigger_conf: dict) -> list[str]: - """Extract entities from a trigger config.""" - if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): - return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return] - - if trigger_conf[CONF_PLATFORM] == "time": - # Each at time can be a time, an entity id, an entity id with - # an offset, or a template. - entity_ids: list[str] = [] - for at_time in trigger_conf[CONF_AT]: - if isinstance(at_time, str) and valid_entity_id(at_time): - entity_ids.append(at_time) - elif isinstance(at_time, dict) and CONF_ENTITY_ID in at_time: - entity_ids.append(at_time[CONF_ENTITY_ID]) - return entity_ids - - if trigger_conf[CONF_PLATFORM] == "device": - # Only extract the entity if it has been resolved to an entity id - # during validation; unvalidated configs hold an entity registry id. - if isinstance( - entity_id := trigger_conf.get(CONF_ENTITY_ID), str - ) and valid_entity_id(entity_id): - return [entity_id] - return [] - - if trigger_conf[CONF_PLATFORM] == "calendar": - return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]] - - if trigger_conf[CONF_PLATFORM] == "zone": - options = trigger_conf[CONF_OPTIONS] - return [*options[CONF_ENTITY_ID], options[CONF_ZONE]] - - if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"): - return [ - *async_extract_targets(trigger_conf, CONF_ENTITY_ID), - trigger_conf[CONF_OPTIONS][CONF_ZONE], - ] - - if trigger_conf[CONF_PLATFORM] == "geo_location": - return [trigger_conf[CONF_ZONE]] - - if trigger_conf[CONF_PLATFORM] == "sun": - return ["sun.sun"] - - if ( - trigger_conf[CONF_PLATFORM] == "event" - and CONF_EVENT_DATA in trigger_conf - and CONF_ENTITY_ID in trigger_conf[CONF_EVENT_DATA] - and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID], str) - and valid_entity_id(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]) - ): - return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]] - - if target_entities := async_extract_targets(trigger_conf, CONF_ENTITY_ID): - return target_entities - - return [] - - -@callback -def async_extract_targets( - config: dict, - target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"], -) -> list[str]: - """Extract targets from a target config.""" - if not (target_conf := config.get(CONF_TARGET)): - return [] - if not (targets := target_conf.get(target)): - return [] - - return [targets] if isinstance(targets, str) else targets diff --git a/homeassistant/helpers/trigger/models.py b/homeassistant/helpers/trigger/models.py new file mode 100644 index 0000000000000..d5b30e2be3c4f --- /dev/null +++ b/homeassistant/helpers/trigger/models.py @@ -0,0 +1,182 @@ +"""Common trigger classes and constants.""" + +import abc +import asyncio +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +import probatio + +from homeassistant.const import CONF_OPTIONS, CONF_TARGET +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey + +TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers") + +_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + probatio.Optional(CONF_OPTIONS): object, + probatio.Optional(CONF_TARGET): cv.TARGET_FIELDS, + } +) + + +@dataclass(slots=True, frozen=True) +class TriggerConfig: + """Trigger config.""" + + key: str # The key used to identify the trigger, e.g. "zwave.event" + target: dict[str, Any] | None = None + options: dict[str, Any] | None = None + + +class TriggerActionRunner(Protocol): + """Protocol type for the trigger action runner helper callback.""" + + @callback + def __call__( + self, + extra_trigger_payload: dict[str, Any], + description: str, + context: Context | None = None, + ) -> asyncio.Task[Any]: + """Define trigger action runner type. + + Returns: + A Task that allows awaiting for the action to finish. + """ + + +@dataclass(slots=True, frozen=True) +class NotTriggeredInfo: + """Diagnostics describing why a trigger evaluated a change but did not fire. + + Passed by a trigger to its ``did_not_trigger`` reporter, the sibling of the + action runner that is called - in certain interesting cases - when the + trigger does not fire. ``reason`` is a stable, machine-readable code; the + optional ``data`` carries the evaluated context for the trace. + """ + + reason: str + data: Mapping[str, Any] | None = None + + def as_dict(self) -> dict[str, Any]: + """Return a JSON-serializable dict for storing in a trace.""" + result: dict[str, Any] = {"reason": self.reason} + if self.data is not None: + result["data"] = dict(self.data) + return result + + +class TriggerNotTriggeredReporter(Protocol): + """Protocol type for the did_not_trigger reporter passed to a trigger runner. + + A trigger calls this to report that it evaluated a relevant change but + decided not to fire, supplying diagnostics for tracing. + """ + + @callback + def __call__( + self, + info: NotTriggeredInfo, + context: Context | None = None, + ) -> None: + """Report that the trigger did not fire.""" + + +class TriggerActionPayloadBuilder(Protocol): + """Protocol type for the trigger action payload builder.""" + + def __call__( + self, extra_trigger_payload: dict[str, Any], description: str + ) -> dict[str, Any]: + """Define trigger action payload builder type.""" + + +class TriggerAction(Protocol): + """Protocol type for trigger action callback.""" + + async def __call__( + self, run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Define action callback type.""" + + +class Trigger(abc.ABC): + """Trigger class.""" + + _hass: HomeAssistant + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config. + + The complete config includes fields that are generic to all triggers, + such as the alias or the ID. + This method should be overridden by triggers that need to migrate + from the old-style config. + """ + complete_config = _TRIGGER_SCHEMA(complete_config) + + specific_config: ConfigType = {} + for key in (CONF_OPTIONS, CONF_TARGET): + if key in complete_config: + specific_config[key] = complete_config.pop(key) + specific_config = await cls.async_validate_config(hass, specific_config) + + for key in (CONF_OPTIONS, CONF_TARGET): + if key in specific_config: + complete_config[key] = specific_config[key] + + return complete_config + + @classmethod + @abc.abstractmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize trigger.""" + self._hass = hass + + async def async_attach_action( + self, + action: TriggerAction, + action_payload_builder: TriggerActionPayloadBuilder, + *, + did_not_trigger: TriggerNotTriggeredReporter | None = None, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action. + + The optional ``did_not_trigger`` reporter is the sibling of the action + runner: triggers may call it - in certain interesting cases - when they + evaluate a relevant change but decide not to fire. + """ + + @callback + def run_action( + extra_trigger_payload: dict[str, Any], + description: str, + context: Context | None = None, + ) -> asyncio.Task[Any]: + """Run action with trigger variables.""" + + payload = action_payload_builder(extra_trigger_payload, description) + return self._hass.async_create_task(action(payload, context)) + + return await self.async_attach_runner(run_action, did_not_trigger) + + @abc.abstractmethod + async def async_attach_runner( + self, + run_action: TriggerActionRunner, + did_not_trigger: TriggerNotTriggeredReporter | None = None, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" diff --git a/tests/components/common.py b/tests/components/common.py index 4b988b733c647..1fa181450b0a4 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -286,11 +286,19 @@ class TargetSupport(StrEnum): ) +def _is_helper_module(module: str) -> bool: + """Return True for a trigger/condition helper module or one of its submodules.""" + return any( + module == helper or module.startswith(f"{helper}.") + for helper in _TARGET_HELPER_MODULES + ) + + def _foreign_names(cls: type) -> set[str]: """Return names defined by MRO classes outside the trigger/condition helpers.""" names: set[str] = set() for klass in cls.__mro__: - if klass.__module__ in _TARGET_HELPER_MODULES: + if _is_helper_module(klass.__module__): continue names.update(vars(klass)) return names @@ -315,7 +323,7 @@ def _target_slot_validator(cls: type) -> object | None: def _init_hygiene_violation(cls: type, key: str, config_cls_name: str) -> str | None: """Return an error if an __init__ override rewrites the config or target.""" for klass in cls.__mro__: - if klass.__module__ in _TARGET_HELPER_MODULES: + if _is_helper_module(klass.__module__): return None if "__init__" not in vars(klass): continue @@ -340,7 +348,7 @@ def _init_hygiene_violation(cls: type, key: str, config_cls_name: str) -> str | def _entity_filter_hygiene_violation(cls: type, key: str) -> str | None: """Return an error if an entity_filter override does not narrow the base.""" for klass in cls.__mro__: - if klass.__module__ in _TARGET_HELPER_MODULES: + if _is_helper_module(klass.__module__): return None if "entity_filter" not in vars(klass): continue diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 6329b9fb81ee6..fc3308d7ef0e1 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -82,7 +82,6 @@ TriggerConfig, TriggerNotTriggeredReporter, _async_get_trigger_platform, - _report_not_triggered_noop, async_initialize_triggers, async_validate_trigger_config, make_entity_numerical_state_changed_trigger, @@ -92,6 +91,7 @@ make_entity_target_state_trigger, make_entity_transition_trigger, ) +from homeassistant.helpers.trigger.entity_trigger import _report_not_triggered_noop from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component @@ -1171,8 +1171,8 @@ def _load_yaml(fname, secrets=None): with ( patch( - "homeassistant.helpers.trigger._load_triggers_files", - side_effect=trigger._load_triggers_files, + "homeassistant.helpers.trigger.descriptions._load_triggers_files", + side_effect=trigger.descriptions._load_triggers_files, ) as proxy_load_triggers_files, patch( "annotatedyaml.loader.load_yaml", @@ -1311,7 +1311,7 @@ def _load_yaml_dict(fname, secrets=None): with ( patch( - "homeassistant.helpers.trigger.load_yaml_dict", + "homeassistant.helpers.trigger.descriptions.load_yaml_dict", side_effect=_load_yaml_dict, ), patch.object(Integration, "has_triggers", return_value=True), From 47e82be2d368269854ce215147648297172eac97 Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Tue, 15 Sep 2026 08:53:45 -0400 Subject: [PATCH 10/15] Add brianegge as code owner of the ipp integration (#182308) Co-authored-by: Claude Fable 5.1 --- CODEOWNERS | 4 ++-- homeassistant/components/ipp/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index ecc0753add5a7..c8c177df4ed58 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -932,8 +932,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/iperf3/ @rohankapoorcom /homeassistant/components/ipma/ @dgomes /tests/components/ipma/ @dgomes -/homeassistant/components/ipp/ @ctalkington -/tests/components/ipp/ @ctalkington +/homeassistant/components/ipp/ @ctalkington @brianegge +/tests/components/ipp/ @ctalkington @brianegge /homeassistant/components/iqvia/ @bachya /tests/components/iqvia/ @bachya /homeassistant/components/irish_rail_transport/ @ttroy50 diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index afe3413a97704..bc69d43c3bc35 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -1,7 +1,7 @@ { "domain": "ipp", "name": "Internet Printing Protocol (IPP)", - "codeowners": ["@ctalkington"], + "codeowners": ["@ctalkington", "@brianegge"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipp", "integration_type": "device", From a4a2e9b488fbfe57b6ce3aee950eb8064cab6416 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 15 Sep 2026 14:56:09 +0200 Subject: [PATCH 11/15] Bump aioshelly to 13.33.0 (#182312) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 4051511e49c45..b7348e8ddbe13 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.32.0"], + "requirements": ["aioshelly==13.33.0"], "zeroconf": [ { "name": "shelly*", diff --git a/requirements_all.txt b/requirements_all.txt index 97346c55065d8..de9e5ac2ae856 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -432,7 +432,7 @@ aiorussound==5.0.2 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.32.0 +aioshelly==13.33.0 # homeassistant.components.skybell aioskybell==23.12.0 From 61a63b82d0743e198140e6728f626aa1ea4528e1 Mon Sep 17 00:00:00 2001 From: Brent Rector Date: Tue, 15 Sep 2026 06:50:45 -0700 Subject: [PATCH 12/15] Only offer Braava mop behavior where the robot reports it (#179349) Co-authored-by: Claude --- homeassistant/components/roomba/vacuum.py | 6 ++++ tests/components/roomba/test_vacuum.py | 44 ++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index a608c837d5ca1..7731491a4c78c 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -352,6 +352,12 @@ def __init__(self, roomba, blid) -> None: for spray in BRAAVA_SPRAY_AMOUNT ] + # Combo models report a mop pad but not `rankOverlap`, which the mop + # behavior is derived from. + if self.vacuum_state.get("rankOverlap") is None: + self._attr_supported_features &= ~VacuumEntityFeature.FAN_SPEED + self._attr_fan_speed_list = [] + @property @override def fan_speed(self) -> str: diff --git a/tests/components/roomba/test_vacuum.py b/tests/components/roomba/test_vacuum.py index 1533ab445927c..e9ba408da431b 100644 --- a/tests/components/roomba/test_vacuum.py +++ b/tests/components/roomba/test_vacuum.py @@ -1,5 +1,6 @@ """Tests for the Roomba vacuum platform.""" +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +10,7 @@ DOMAIN as VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, VacuumActivity, + VacuumEntityFeature, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -68,6 +70,43 @@ async def test_vacuum_activity( assert state.state == expected +@pytest.mark.parametrize( + ("extra_state", "expect_fan_speed_support", "expected_fan_speed"), + [ + # 67 is OVERLAP_STANDARD. + ({"rankOverlap": 67}, True, "Standard-1"), + # Combo models report a mop pad but no rankOverlap. + ({}, False, None), + ], +) +async def test_braava_fan_speed_requires_rank_overlap( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + extra_state: dict[str, Any], + expect_fan_speed_support: bool, + expected_fan_speed: str | None, +) -> None: + """Test that fan speed is only offered when it can be produced.""" + reported = mock_roomba.master_state["state"]["reported"] + reported["detectedPad"] = "reusableWet" + # fan_speed reads the "disposable" key. + reported["padWetness"] = {"disposable": 1, "reusable": 1} + reported.pop("rankOverlap", None) + reported.update(extra_state) + + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.VACUUM]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + supported = VacuumEntityFeature(state.attributes["supported_features"]) + assert bool(supported & VacuumEntityFeature.FAN_SPEED) is expect_fan_speed_support + assert state.attributes.get("fan_speed") == expected_fan_speed + + @pytest.mark.parametrize( ("fan_speed", "translation_key"), [ @@ -89,7 +128,10 @@ async def test_braava_set_fan_speed_invalid( translation_key: str, ) -> None: """Test that invalid Braava fan speeds raise instead of being swallowed.""" - mock_roomba.master_state["state"]["reported"]["detectedPad"] = "reusableWet" + reported = mock_roomba.master_state["state"]["reported"] + reported["detectedPad"] = "reusableWet" + # 67 is OVERLAP_STANDARD; without it the mop behavior is not offered at all. + reported["rankOverlap"] = 67 await _setup(hass, mock_config_entry) From 362fa725e650537d58966a7ce7af6817a33855e7 Mon Sep 17 00:00:00 2001 From: Hamish Date: Wed, 16 Sep 2026 00:21:23 +0930 Subject: [PATCH 13/15] New integration: INDI Allsky (#179123) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek Co-authored-by: Claude Opus 4.8 --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/indi_allsky/__init__.py | 24 ++ .../components/indi_allsky/camera.py | 55 ++++ .../components/indi_allsky/config_flow.py | 151 +++++++++++ homeassistant/components/indi_allsky/const.py | 3 + .../components/indi_allsky/coordinator.py | 54 ++++ .../components/indi_allsky/entity.py | 26 ++ .../components/indi_allsky/manifest.json | 12 + .../components/indi_allsky/quality_scale.yaml | 78 ++++++ .../components/indi_allsky/strings.json | 34 +++ homeassistant/components/indi_allsky/util.py | 24 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/indi_allsky/__init__.py | 15 ++ tests/components/indi_allsky/conftest.py | 63 +++++ .../indi_allsky/snapshots/test_camera.ambr | 54 ++++ tests/components/indi_allsky/test_camera.py | 65 +++++ .../indi_allsky/test_config_flow.py | 243 ++++++++++++++++++ tests/components/indi_allsky/test_init.py | 43 ++++ 22 files changed, 967 insertions(+) create mode 100644 homeassistant/components/indi_allsky/__init__.py create mode 100644 homeassistant/components/indi_allsky/camera.py create mode 100644 homeassistant/components/indi_allsky/config_flow.py create mode 100644 homeassistant/components/indi_allsky/const.py create mode 100644 homeassistant/components/indi_allsky/coordinator.py create mode 100644 homeassistant/components/indi_allsky/entity.py create mode 100644 homeassistant/components/indi_allsky/manifest.json create mode 100644 homeassistant/components/indi_allsky/quality_scale.yaml create mode 100644 homeassistant/components/indi_allsky/strings.json create mode 100644 homeassistant/components/indi_allsky/util.py create mode 100644 tests/components/indi_allsky/__init__.py create mode 100644 tests/components/indi_allsky/conftest.py create mode 100644 tests/components/indi_allsky/snapshots/test_camera.ambr create mode 100644 tests/components/indi_allsky/test_camera.py create mode 100644 tests/components/indi_allsky/test_config_flow.py create mode 100644 tests/components/indi_allsky/test_init.py diff --git a/.strict-typing b/.strict-typing index 0ea45570fff47..bba2767899010 100644 --- a/.strict-typing +++ b/.strict-typing @@ -312,6 +312,7 @@ homeassistant.components.imgw_pib.* homeassistant.components.immich.* homeassistant.components.incomfort.* homeassistant.components.indevolt.* +homeassistant.components.indi_allsky.* homeassistant.components.inels.* homeassistant.components.infrared.* homeassistant.components.input_button.* diff --git a/CODEOWNERS b/CODEOWNERS index c8c177df4ed58..9d7731e81de7d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -887,6 +887,8 @@ CLAUDE.md @home-assistant/core /tests/components/incomfort/ @jbouwh /homeassistant/components/indevolt/ @xirt /tests/components/indevolt/ @xirt +/homeassistant/components/indi_allsky/ @TN-1 +/tests/components/indi_allsky/ @TN-1 /homeassistant/components/inels/ @epdevlab /tests/components/inels/ @epdevlab /homeassistant/components/influxdb/ @mdegat01 @Robbie1221 diff --git a/homeassistant/components/indi_allsky/__init__.py b/homeassistant/components/indi_allsky/__init__.py new file mode 100644 index 0000000000000..51eb4e00ba5b1 --- /dev/null +++ b/homeassistant/components/indi_allsky/__init__.py @@ -0,0 +1,24 @@ +"""The INDI Allsky integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.CAMERA] + + +async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> bool: + """Set up INDI Allsky from a config entry.""" + coordinator = IndiAllSkyDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/indi_allsky/camera.py b/homeassistant/components/indi_allsky/camera.py new file mode 100644 index 0000000000000..5cf5b2ca3fa46 --- /dev/null +++ b/homeassistant/components/indi_allsky/camera.py @@ -0,0 +1,55 @@ +"""Support for INDI Allsky camera.""" + +from typing import override + +from aioindiallsky import IndiAllSkyError + +from homeassistant.components.camera import Camera +from homeassistant.components.image import infer_image_type +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator +from .entity import IndiAllSkyEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndiAllSkyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the INDI Allsky camera platform.""" + coordinator = entry.runtime_data + async_add_entities([IndiAllSkyCamera(coordinator, entry)]) + + +class IndiAllSkyCamera(IndiAllSkyEntity, Camera): + """Representation of an INDI Allsky camera.""" + + _attr_name = None + + def __init__( + self, + coordinator: IndiAllSkyDataUpdateCoordinator, + entry: IndiAllSkyConfigEntry, + ) -> None: + """Initialize the camera.""" + super().__init__(coordinator, entry) + Camera.__init__(self) + self._attr_unique_id = entry.entry_id + + @override + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of current camera image.""" + try: + image: bytes = await self.coordinator.client.fetch_image("latestimage") + except IndiAllSkyError: + return None + else: + if content_type := infer_image_type(image): + self.content_type = content_type + return image diff --git a/homeassistant/components/indi_allsky/config_flow.py b/homeassistant/components/indi_allsky/config_flow.py new file mode 100644 index 0000000000000..4561f342878c4 --- /dev/null +++ b/homeassistant/components/indi_allsky/config_flow.py @@ -0,0 +1,151 @@ +"""Config flow for INDI Allsky integration.""" + +import logging +from typing import Any, override + +from aioindiallsky import ( + IndiAllSkyAuthError, + IndiAllSkyClient, + IndiAllSkyConnectionError, + IndiAllSkyTimeoutError, +) +import probatio + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN +from .util import get_ssl_context, normalize_host + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_HOST): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="host", + ), + ), + probatio.Required(CONF_PORT, default=443): NumberSelector( + NumberSelectorConfig( + min=1, + max=65535, + mode=NumberSelectorMode.BOX, + ), + ), + probatio.Optional(CONF_SSL, default=True): BooleanSelector(), + probatio.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector(), + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate that the user input allows us to connect to INDI Allsky.""" + client = IndiAllSkyClient( + host=data[CONF_HOST], + port=int(data[CONF_PORT]), + ssl=get_ssl_context( + data.get(CONF_SSL, True), + data.get(CONF_VERIFY_SSL, True), + ), + session=async_get_clientsession(hass), + ) + + try: + await client.fetch_image("latestimage") + except IndiAllSkyAuthError as err: + _LOGGER.error( + "Authentication failed for INDI Allsky at %s:%s: %s", + data[CONF_HOST], + data[CONF_PORT], + err, + ) + raise InvalidAuth from err + except (IndiAllSkyConnectionError, IndiAllSkyTimeoutError) as err: + _LOGGER.error( + "Cannot connect to INDI Allsky instance at %s:%s: %s", + data[CONF_HOST], + data[CONF_PORT], + err, + ) + raise CannotConnect from err + except Exception as err: + raise Unknown from err + + +class IndiAllSkyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for INDI Allsky.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + user_input[CONF_HOST] = normalize_host(user_input[CONF_HOST]) + user_input[CONF_PORT] = int(user_input[CONF_PORT]) + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + port = user_input[CONF_PORT] + default_port = 443 if user_input.get(CONF_SSL, True) else 80 + host_str = ( + f"{user_input[CONF_HOST]}:{port}" + if port != default_port + else user_input[CONF_HOST] + ) + return self.async_create_entry( + title=f"INDI Allsky ({host_str})", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class Unknown(HomeAssistantError): + """Unexpected error.""" diff --git a/homeassistant/components/indi_allsky/const.py b/homeassistant/components/indi_allsky/const.py new file mode 100644 index 0000000000000..eee9c52e426aa --- /dev/null +++ b/homeassistant/components/indi_allsky/const.py @@ -0,0 +1,3 @@ +"""Constants for the INDI Allsky integration.""" + +DOMAIN = "indi_allsky" diff --git a/homeassistant/components/indi_allsky/coordinator.py b/homeassistant/components/indi_allsky/coordinator.py new file mode 100644 index 0000000000000..f458005ba44c6 --- /dev/null +++ b/homeassistant/components/indi_allsky/coordinator.py @@ -0,0 +1,54 @@ +"""DataUpdateCoordinator for INDI Allsky integration.""" + +import logging +from typing import override + +from aioindiallsky import IndiAllSkyClient, IndiAllSkyError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .util import get_ssl_context + +_LOGGER = logging.getLogger(__name__) + +type IndiAllSkyConfigEntry = ConfigEntry[IndiAllSkyDataUpdateCoordinator] + + +class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching INDI Allsky data from the API.""" + + def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: + """Initialize the coordinator.""" + self.client = IndiAllSkyClient( + host=entry.data[CONF_HOST], + port=int(entry.data[CONF_PORT]), + ssl=get_ssl_context( + entry.data[CONF_SSL], + entry.data[CONF_VERIFY_SSL], + ), + session=async_get_clientsession(hass), + ) + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=None, + ) + + @override + async def _async_update_data(self) -> None: + """Fetch INDI Allsky metadata and verify connection.""" + try: + await self.client.fetch_image("latestimage") + except IndiAllSkyError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err diff --git a/homeassistant/components/indi_allsky/entity.py b/homeassistant/components/indi_allsky/entity.py new file mode 100644 index 0000000000000..7e1cf9320d635 --- /dev/null +++ b/homeassistant/components/indi_allsky/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the INDI Allsky integration.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator + + +class IndiAllSkyEntity(CoordinatorEntity[IndiAllSkyDataUpdateCoordinator]): + """Base class for INDI Allsky entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IndiAllSkyDataUpdateCoordinator, + entry: IndiAllSkyConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/indi_allsky/manifest.json b/homeassistant/components/indi_allsky/manifest.json new file mode 100644 index 0000000000000..c6666ed120bc9 --- /dev/null +++ b/homeassistant/components/indi_allsky/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "indi_allsky", + "name": "INDI Allsky", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/indi_allsky", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["aioindiallsky"], + "quality_scale": "bronze", + "requirements": ["aioindiallsky==0.1.1"] +} diff --git a/homeassistant/components/indi_allsky/quality_scale.yaml b/homeassistant/components/indi_allsky/quality_scale.yaml new file mode 100644 index 0000000000000..fa7036deccf5e --- /dev/null +++ b/homeassistant/components/indi_allsky/quality_scale.yaml @@ -0,0 +1,78 @@ +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-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not register custom 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: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not require reauthentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Entities define no custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not require user intervention repairs. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/indi_allsky/strings.json b/homeassistant/components/indi_allsky/strings.json new file mode 100644 index 0000000000000..d4884470a3fdc --- /dev/null +++ b/homeassistant/components/indi_allsky/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "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": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your INDI Allsky server.", + "port": "The port number of your INDI Allsky web server.", + "ssl": "Connect using SSL/HTTPS.", + "verify_ssl": "Verify the SSL certificate of the INDI Allsky server." + }, + "description": "Enter the connection details for your INDI Allsky instance." + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error communicating with INDI Allsky API" + } + } +} diff --git a/homeassistant/components/indi_allsky/util.py b/homeassistant/components/indi_allsky/util.py new file mode 100644 index 0000000000000..ff33508cd5ad1 --- /dev/null +++ b/homeassistant/components/indi_allsky/util.py @@ -0,0 +1,24 @@ +"""Utilities for the INDI Allsky integration.""" + +import ipaddress +import ssl + +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context + + +def normalize_host(host: str) -> str: + """Normalize hostname or IP address into canonical form.""" + host_clean = host.strip(" []") + try: + return str(ipaddress.ip_address(host_clean)) + except ValueError: + return host_clean.lower().removesuffix(".") + + +def get_ssl_context(ssl_enabled: bool, verify_ssl: bool) -> bool | ssl.SSLContext: + """Return SSL configuration for IndiAllSkyClient.""" + if not ssl_enabled: + return False + if not verify_ssl: + return get_default_no_verify_context() + return get_default_context() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bc4392725e6b6..bd717e766b7f2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -374,6 +374,7 @@ "improv_ble", "incomfort", "indevolt", + "indi_allsky", "inels", "influxdb", "inkbird", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3f527adbd689f..6071ed15de23a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3356,6 +3356,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "indi_allsky": { + "name": "INDI Allsky", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "indianamichiganpower": { "name": "Indiana Michigan Power", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 57ebca56658bf..9f25acf32c2a6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2878,6 +2878,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.indi_allsky.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.inels.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index de9e5ac2ae856..9b0c2eb4d5c2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -311,6 +311,9 @@ aioimaplib==2.0.1 # homeassistant.components.immich aioimmich==0.17.0 +# homeassistant.components.indi_allsky +aioindiallsky==0.1.1 + # homeassistant.components.ipp aioipp==0.19.0 diff --git a/tests/components/indi_allsky/__init__.py b/tests/components/indi_allsky/__init__.py new file mode 100644 index 0000000000000..080473fe6105e --- /dev/null +++ b/tests/components/indi_allsky/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the INDI Allsky integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the INDI Allsky integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/indi_allsky/conftest.py b/tests/components/indi_allsky/conftest.py new file mode 100644 index 0000000000000..4b3ebc9e8cdf8 --- /dev/null +++ b/tests/components/indi_allsky/conftest.py @@ -0,0 +1,63 @@ +"""Common fixtures for the INDI Allsky tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.indi_allsky.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_system_random() -> Generator[None]: + """Mock random.SystemRandom.getrandbits to produce deterministic camera access tokens.""" + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.indi_allsky.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_indi_allsky_client() -> Generator[AsyncMock]: + """Mock the third-party aioindiallsky client globally across coordinator and config flow.""" + with ( + patch( + "homeassistant.components.indi_allsky.coordinator.IndiAllSkyClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.indi_allsky.config_flow.IndiAllSkyClient", + new=mock_client, + ), + ): + client_instance = mock_client.return_value + client_instance.fetch_image = AsyncMock( + return_value=b"\xff\xd8\xff\xe0fake_jpeg_data" + ) + yield client_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture to cleanly create an INDI Allsky configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="INDI Allsky", + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 443, + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + entry_id="1234567890abcdef1234567890abcdef", + ) diff --git a/tests/components/indi_allsky/snapshots/test_camera.ambr b/tests/components/indi_allsky/snapshots/test_camera.ambr new file mode 100644 index 0000000000000..d1be924bc2ce0 --- /dev/null +++ b/tests/components/indi_allsky/snapshots/test_camera.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_camera_setup_and_states[camera.indi_allsky-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.indi_allsky', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_setup_and_states[camera.indi_allsky-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.indi_allsky?token=1caab5c3b3', + : 'INDI Allsky', + : , + }), + 'context': , + 'entity_id': 'camera.indi_allsky', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/indi_allsky/test_camera.py b/tests/components/indi_allsky/test_camera.py new file mode 100644 index 0000000000000..917f2615d3cda --- /dev/null +++ b/tests/components/indi_allsky/test_camera.py @@ -0,0 +1,65 @@ +"""Tests for the INDI Allsky camera platform.""" + +from unittest.mock import AsyncMock + +from aioindiallsky import IndiAllSkyError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.camera import async_get_image +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_indi_allsky_client") +async def test_camera_setup_and_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("image_bytes", "expected_content_type"), + [ + pytest.param(b"\xff\xd8\xff\xe0fake_jpeg_data", "image/jpeg", id="jpeg"), + pytest.param(b"\x89PNG\r\n\x1a\nfake_png_data", "image/png", id="png"), + ], +) +async def test_camera_image_and_update( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, + image_bytes: bytes, + expected_content_type: str, +) -> None: + """Test camera image fetching and content type inference.""" + mock_indi_allsky_client.fetch_image.return_value = image_bytes + await setup_integration(hass, mock_config_entry) + + image = await async_get_image(hass, "camera.indi_allsky") + assert image.content == image_bytes + assert image.content_type == expected_content_type + + +async def test_camera_image_fetch_failure( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test camera image fetching failure handling.""" + await setup_integration(hass, mock_config_entry) + + mock_indi_allsky_client.fetch_image.side_effect = IndiAllSkyError("Fetch error") + + with pytest.raises(HomeAssistantError, match="Unable to get image"): + await async_get_image(hass, "camera.indi_allsky") diff --git a/tests/components/indi_allsky/test_config_flow.py b/tests/components/indi_allsky/test_config_flow.py new file mode 100644 index 0000000000000..5dcd599d2f536 --- /dev/null +++ b/tests/components/indi_allsky/test_config_flow.py @@ -0,0 +1,243 @@ +"""Test the INDI Allsky Config flow.""" + +import ssl +from unittest.mock import AsyncMock + +from aioindiallsky import IndiAllSkyAuthError, IndiAllSkyConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.indi_allsky.const import DOMAIN +from homeassistant.components.indi_allsky.util import get_ssl_context, normalize_host +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("host", "port", "ssl_enabled", "verify_ssl", "expected_title"), + [ + pytest.param( + "127.0.0.1", + 443, + True, + False, + "INDI Allsky (127.0.0.1)", + id="ipv4_default_port", + ), + pytest.param( + "127.0.0.1", + 8443, + True, + True, + "INDI Allsky (127.0.0.1:8443)", + id="ipv4_custom_port", + ), + pytest.param( + "2001:db8::1", + 443, + True, + True, + "INDI Allsky (2001:db8::1)", + id="ipv6_default_port", + ), + pytest.param( + "2001:db8::1", + 8080, + False, + True, + "INDI Allsky (2001:db8::1:8080)", + id="ipv6_custom_port", + ), + ], +) +async def test_form_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_indi_allsky_client: AsyncMock, + host: str, + port: int, + ssl_enabled: bool, + verify_ssl: bool, + expected_title: str, +) -> None: + """Test we get the form, validate the client, and create a successful entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl_enabled, + CONF_VERIFY_SSL: verify_ssl, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title + assert result["data"] == { + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl_enabled, + CONF_VERIFY_SSL: verify_ssl, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (IndiAllSkyConnectionError("Cannot connect"), "cannot_connect"), + (IndiAllSkyAuthError("Invalid key"), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_indi_allsky_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test handling validation failures and ensuring the flow can recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_indi_allsky_client.fetch_image.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 443, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_indi_allsky_client.fetch_image.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 443, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "duplicate_host", + [ + "127.0.0.1", + " 127.0.0.1 ", + ], +) +async def test_form_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + duplicate_host: str, +) -> None: + """Test duplicate host/port configurations abort early.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: duplicate_host, + CONF_PORT: 443, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "duplicate_host", + [ + "2001:db8::1", + "[2001:db8::1]", + "2001:0db8:0000:0000:0000:0000:0000:0001", + "2001:DB8::1", + " [2001:db8::1] ", + ], +) +async def test_form_already_configured_ipv6( + hass: HomeAssistant, + duplicate_host: str, +) -> None: + """Test duplicate IPv6 configurations abort regardless of formatting variation.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="INDI Allsky (2001:db8::1)", + data={ + CONF_HOST: "2001:db8::1", + CONF_PORT: 443, + }, + entry_id="ipv6_entry", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: duplicate_host, + CONF_PORT: 443, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("input_host", "expected_host"), + [ + ("127.0.0.1", "127.0.0.1"), + (" 127.0.0.1 ", "127.0.0.1"), + ("allsky.local", "allsky.local"), + ("2001:db8::1", "2001:db8::1"), + ("[2001:db8::1]", "2001:db8::1"), + ("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1"), + ("2001:DB8::1", "2001:db8::1"), + (" [2001:db8::1] ", "2001:db8::1"), + ], +) +def test_normalize_host(input_host: str, expected_host: str) -> None: + """Test host normalization for IPv4, IPv6, and hostnames.""" + assert normalize_host(input_host) == expected_host + + +def test_get_ssl_context() -> None: + """Test get_ssl_context return values for various SSL setting combinations.""" + assert get_ssl_context(ssl_enabled=False, verify_ssl=True) is False + assert get_ssl_context(ssl_enabled=False, verify_ssl=False) is False + + ctx_verified = get_ssl_context(ssl_enabled=True, verify_ssl=True) + assert isinstance(ctx_verified, ssl.SSLContext) + assert ctx_verified.verify_mode != ssl.CERT_NONE + + ctx_no_verify = get_ssl_context(ssl_enabled=True, verify_ssl=False) + assert isinstance(ctx_no_verify, ssl.SSLContext) + assert ctx_no_verify.verify_mode == ssl.CERT_NONE diff --git a/tests/components/indi_allsky/test_init.py b/tests/components/indi_allsky/test_init.py new file mode 100644 index 0000000000000..32367e5eb6025 --- /dev/null +++ b/tests/components/indi_allsky/test_init.py @@ -0,0 +1,43 @@ +"""Test initialization of INDI Allsky integration.""" + +from unittest.mock import AsyncMock + +from aioindiallsky import IndiAllSkyConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_indi_allsky_client: AsyncMock, +) -> None: + """Test successful setup and unload of entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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 + + +async def test_setup_failure_retry( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an API connection failure during initial setup places entry in retry state.""" + mock_indi_allsky_client.fetch_image.side_effect = IndiAllSkyConnectionError( + "Cannot connect to INDI Allsky server" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 02e719292dc96c217529e24e50fbbacb06bb547e Mon Sep 17 00:00:00 2001 From: Marco <46717884+marcodutto@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:56:44 +0200 Subject: [PATCH 14/15] Retry Smarty coordinator update after transient failure (#182309) --- .../components/smarty/coordinator.py | 5 +- tests/components/smarty/test_sensor.py | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smarty/coordinator.py b/homeassistant/components/smarty/coordinator.py index 2de862f5c649c..ba54ba403b11c 100644 --- a/homeassistant/components/smarty/coordinator.py +++ b/homeassistant/components/smarty/coordinator.py @@ -45,4 +45,7 @@ async def _async_setup(self) -> None: async def _async_update_data(self) -> None: """Fetch data from Smarty.""" if not await self.hass.async_add_executor_job(self.client.update): - raise UpdateFailed("Failed to update Smarty data") + raise UpdateFailed( + "Failed to update Smarty data", + retry_after=2 if self.last_update_success else None, + ) diff --git a/tests/components/smarty/test_sensor.py b/tests/components/smarty/test_sensor.py index 7ec44886952ed..5a7a9ba6ba4a1 100644 --- a/tests/components/smarty/test_sensor.py +++ b/tests/components/smarty/test_sensor.py @@ -1,7 +1,9 @@ """Tests for the Smarty sensor platform.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -11,7 +13,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.freeze_time("2023-10-21") @@ -27,3 +29,45 @@ async def test_all_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_retry_after_failure( + hass: HomeAssistant, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test retrying once after a transient update failure.""" + with patch("homeassistant.components.smarty.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + mock_smarty.update.reset_mock() + mock_smarty.update.side_effect = [False, False, True] + + # First scheduled update fails after the normal 30-second interval. + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_smarty.update.call_count == 1 + + # The coordinator retries after 2 seconds. + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_smarty.update.call_count == 2 + + # A second consecutive failure falls back to the normal interval. + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_smarty.update.call_count == 2 + + # The next update occurs after the normal 30-second interval. + freezer.tick(timedelta(seconds=28)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_smarty.update.call_count == 3 From 71c5b0e700b8af1972b0d45ff07a092530d29480 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 15 Sep 2026 16:57:21 +0200 Subject: [PATCH 15/15] Redact the AirVisual API key in debug logging (#182280) --- homeassistant/components/airvisual/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 23e219c4c4572..456f7b188849a 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -26,6 +26,7 @@ entity_registry as er, ) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.redact import partial_redact from .const import ( CONF_CITY, @@ -63,7 +64,7 @@ def async_get_cloud_api_update_interval( LOGGER.debug( "Leveling API key usage (%s): %s consumers, %s minutes between updates", - api_key, + partial_redact(api_key), num_consumers, minutes_between_api_calls, )