From 4ac651d0b49745231c1c04cc168c751416357783 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 13 Mar 2026 08:41:48 +0100 Subject: [PATCH] Add occupancy triggers (#165374) --- CODEOWNERS | 2 + homeassistant/bootstrap.py | 1 + .../components/automation/__init__.py | 1 + .../components/occupancy/__init__.py | 17 + homeassistant/components/occupancy/icons.json | 10 + .../components/occupancy/manifest.json | 8 + .../components/occupancy/strings.json | 38 ++ homeassistant/components/occupancy/trigger.py | 57 +++ .../components/occupancy/triggers.yaml | 25 ++ script/hassfest/manifest.py | 1 + script/hassfest/quality_scale.py | 1 + tests/components/occupancy/__init__.py | 1 + tests/components/occupancy/test_trigger.py | 327 ++++++++++++++++++ tests/snapshots/test_bootstrap.ambr | 2 + 14 files changed, 491 insertions(+) create mode 100644 homeassistant/components/occupancy/__init__.py create mode 100644 homeassistant/components/occupancy/icons.json create mode 100644 homeassistant/components/occupancy/manifest.json create mode 100644 homeassistant/components/occupancy/strings.json create mode 100644 homeassistant/components/occupancy/trigger.py create mode 100644 homeassistant/components/occupancy/triggers.yaml create mode 100644 tests/components/occupancy/__init__.py create mode 100644 tests/components/occupancy/test_trigger.py diff --git a/CODEOWNERS b/CODEOWNERS index cb6550f8086..1e506a6c456 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1186,6 +1186,8 @@ build.json @home-assistant/supervisor /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi @ejpenney /tests/components/obihai/ @dshokouhi @ejpenney +/homeassistant/components/occupancy/ @home-assistant/core +/tests/components/occupancy/ @home-assistant/core /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 45499892be5..8590bc8fdfd 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -246,6 +246,7 @@ DEFAULT_INTEGRATIONS = { "gate", "humidity", "motion", + "occupancy", "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 07730491dc3..d51b5fc6813 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -153,6 +153,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "lock", "media_player", "motion", + "occupancy", "person", "remote", "scene", diff --git a/homeassistant/components/occupancy/__init__.py b/homeassistant/components/occupancy/__init__.py new file mode 100644 index 00000000000..d9c1e38fd93 --- /dev/null +++ b/homeassistant/components/occupancy/__init__.py @@ -0,0 +1,17 @@ +"""Integration for occupancy triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "occupancy" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/occupancy/icons.json b/homeassistant/components/occupancy/icons.json new file mode 100644 index 00000000000..f437e3e67a1 --- /dev/null +++ b/homeassistant/components/occupancy/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "cleared": { + "trigger": "mdi:home-outline" + }, + "detected": { + "trigger": "mdi:home-account" + } + } +} diff --git a/homeassistant/components/occupancy/manifest.json b/homeassistant/components/occupancy/manifest.json new file mode 100644 index 00000000000..db5ba9ebebe --- /dev/null +++ b/homeassistant/components/occupancy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "occupancy", + "name": "Occupancy", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/occupancy", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/occupancy/strings.json b/homeassistant/components/occupancy/strings.json new file mode 100644 index 00000000000..078d4393eab --- /dev/null +++ b/homeassistant/components/occupancy/strings.json @@ -0,0 +1,38 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Occupancy", + "triggers": { + "cleared": { + "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::occupancy::common::trigger_behavior_description%]", + "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + } + }, + "name": "Occupancy cleared" + }, + "detected": { + "description": "Triggers after one or more occupancy sensors start detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::occupancy::common::trigger_behavior_description%]", + "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + } + }, + "name": "Occupancy detected" + } + } +} diff --git a/homeassistant/components/occupancy/trigger.py b/homeassistant/components/occupancy/trigger.py new file mode 100644 index 00000000000..3c87a988851 --- /dev/null +++ b/homeassistant/components/occupancy/trigger.py @@ -0,0 +1,57 @@ +"""Provides triggers for occupancy.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import ( + EntityTargetStateTriggerBase, + EntityTriggerBase, + Trigger, + get_device_class_or_undefined, +) + + +class _OccupancyBinaryTriggerBase(EntityTriggerBase): + """Base trigger for occupancy binary sensor state changes.""" + + _domains = {BINARY_SENSOR_DOMAIN} + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities by occupancy device class.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if get_device_class_or_undefined(self._hass, entity_id) + == BinarySensorDeviceClass.OCCUPANCY + } + + +class OccupancyDetectedTrigger( + _OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase +): + """Trigger for occupancy detected (binary sensor ON).""" + + _to_states = {STATE_ON} + + +class OccupancyClearedTrigger( + _OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase +): + """Trigger for occupancy cleared (binary sensor OFF).""" + + _to_states = {STATE_OFF} + + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": OccupancyDetectedTrigger, + "cleared": OccupancyClearedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for occupancy.""" + return TRIGGERS diff --git a/homeassistant/components/occupancy/triggers.yaml b/homeassistant/components/occupancy/triggers.yaml new file mode 100644 index 00000000000..9613e28c4ce --- /dev/null +++ b/homeassistant/components/occupancy/triggers.yaml @@ -0,0 +1,25 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: occupancy + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: occupancy diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 18fde39f30c..fb241dfc73c 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -104,6 +104,7 @@ NO_IOT_CLASS = [ "media_source", "motion", "my", + "occupancy", "onboarding", "panel_custom", "plant", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 6566f11d2dc..9cc9a0748bb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2139,6 +2139,7 @@ NO_QUALITY_SCALE = [ "media_source", "motion", "my", + "occupancy", "onboarding", "panel_custom", "proxy", diff --git a/tests/components/occupancy/__init__.py b/tests/components/occupancy/__init__.py new file mode 100644 index 00000000000..423086f9270 --- /dev/null +++ b/tests/components/occupancy/__init__.py @@ -0,0 +1 @@ +"""Tests for the occupancy integration.""" diff --git a/tests/components/occupancy/test_trigger.py b/tests/components/occupancy/test_trigger.py new file mode 100644 index 00000000000..3ce1d08f8df --- /dev/null +++ b/tests/components/occupancy/test_trigger.py @@ -0,0 +1,327 @@ +"""Test occupancy trigger.""" + +from typing import Any + +import pytest + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_LABEL_ID, + CONF_ENTITY_ID, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + TriggerStateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + "trigger_key", + [ + "occupancy.detected", + "occupancy.cleared", + ], +) +async def test_occupancy_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the occupancy triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="occupancy.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="occupancy.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + ], +) +async def test_occupancy_trigger_binary_sensor_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test occupancy trigger fires for binary_sensor entities with device_class occupancy.""" + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + for eid in target_binary_sensors["included"]: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + for eid in excluded_entity_ids: + set_or_remove_state(hass, eid, states[0]["excluded"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + excluded_state = state["excluded"] + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + for excluded_entity_id in excluded_entity_ids: + set_or_remove_state(hass, excluded_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="occupancy.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="occupancy.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + ], +) +async def test_occupancy_trigger_binary_sensor_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test occupancy trigger fires on the first binary_sensor state change.""" + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + for eid in target_binary_sensors["included"]: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + for eid in excluded_entity_ids: + set_or_remove_state(hass, eid, states[0]["excluded"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + + for state in states[1:]: + excluded_state = state["excluded"] + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, excluded_state) + await hass.async_block_till_done() + for excluded_entity_id in excluded_entity_ids: + set_or_remove_state(hass, excluded_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="occupancy.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="occupancy.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + ], +) +async def test_occupancy_trigger_binary_sensor_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test occupancy trigger fires when the last binary_sensor changes state.""" + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + for eid in target_binary_sensors["included"]: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + for eid in excluded_entity_ids: + set_or_remove_state(hass, eid, states[0]["excluded"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + + for state in states[1:]: + excluded_state = state["excluded"] + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + for excluded_entity_id in excluded_entity_ids: + set_or_remove_state(hass, excluded_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +# --- Device class exclusion tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ( + "trigger_key", + "trigger_options", + "initial_state", + "target_state", + ), + [ + ( + "occupancy.detected", + {}, + STATE_OFF, + STATE_ON, + ), + ( + "occupancy.cleared", + {}, + STATE_ON, + STATE_OFF, + ), + ], +) +async def test_occupancy_trigger_excludes_non_occupancy_binary_sensor( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + trigger_options: dict[str, Any], + initial_state: str, + target_state: str, +) -> None: + """Test occupancy trigger does not fire for entities without device_class occupancy.""" + entity_id_occupancy = "binary_sensor.test_occupancy" + entity_id_motion = "binary_sensor.test_motion" + + # Set initial states + hass.states.async_set( + entity_id_occupancy, initial_state, {ATTR_DEVICE_CLASS: "occupancy"} + ) + hass.states.async_set( + entity_id_motion, initial_state, {ATTR_DEVICE_CLASS: "motion"} + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger_key, + trigger_options, + { + CONF_ENTITY_ID: [ + entity_id_occupancy, + entity_id_motion, + ] + }, + ) + + # Occupancy binary_sensor changes - should trigger + hass.states.async_set( + entity_id_occupancy, target_state, {ATTR_DEVICE_CLASS: "occupancy"} + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_occupancy + service_calls.clear() + + # Motion binary_sensor changes - should NOT trigger (wrong device class) + hass.states.async_set(entity_id_motion, target_state, {ATTR_DEVICE_CLASS: "motion"}) + await hass.async_block_till_done() + assert len(service_calls) == 0 diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 8049401a812..e93ccbc23d6 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -69,6 +69,7 @@ 'network', 'notify', 'number', + 'occupancy', 'onboarding', 'person', 'remote', @@ -172,6 +173,7 @@ 'network', 'notify', 'number', + 'occupancy', 'onboarding', 'person', 'remote',