Skip to content

Commit e63242e

Browse files
authored
Add occupancy binary sensor triggers (home-assistant#157631)
1 parent e84c097 commit e63242e

File tree

19 files changed

+646
-130
lines changed

19 files changed

+646
-130
lines changed

homeassistant/components/automation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
125125
"alarm_control_panel",
126126
"assist_satellite",
127+
"binary_sensor",
127128
"climate",
128129
"cover",
129130
"fan",

homeassistant/components/binary_sensor/icons.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,5 +174,13 @@
174174
"on": "mdi:window-open"
175175
}
176176
}
177+
},
178+
"triggers": {
179+
"occupancy_cleared": {
180+
"trigger": "mdi:home-outline"
181+
},
182+
"occupancy_detected": {
183+
"trigger": "mdi:home"
184+
}
177185
}
178186
}

homeassistant/components/binary_sensor/strings.json

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{
2+
"common": {
3+
"trigger_behavior_description_presence": "The behavior of the targeted presence sensors to trigger on.",
4+
"trigger_behavior_name": "Behavior"
5+
},
26
"device_automation": {
37
"condition_type": {
48
"is_bat_low": "{entity_name} battery is low",
@@ -317,5 +321,36 @@
317321
}
318322
}
319323
},
320-
"title": "Binary sensor"
324+
"selector": {
325+
"trigger_behavior": {
326+
"options": {
327+
"any": "Any",
328+
"first": "First",
329+
"last": "Last"
330+
}
331+
}
332+
},
333+
"title": "Binary sensor",
334+
"triggers": {
335+
"occupancy_cleared": {
336+
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
337+
"fields": {
338+
"behavior": {
339+
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]",
340+
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
341+
}
342+
},
343+
"name": "Occupancy cleared"
344+
},
345+
"occupancy_detected": {
346+
"description": "Triggers after one ore more occupancy sensors start detecting occupancy.",
347+
"fields": {
348+
"behavior": {
349+
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]",
350+
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
351+
}
352+
},
353+
"name": "Occupancy detected"
354+
}
355+
}
321356
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Provides triggers for binary sensors."""
2+
3+
from homeassistant.const import STATE_OFF, STATE_ON
4+
from homeassistant.core import HomeAssistant
5+
from homeassistant.exceptions import HomeAssistantError
6+
from homeassistant.helpers.entity import get_device_class
7+
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
8+
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
9+
10+
from . import DOMAIN, BinarySensorDeviceClass
11+
12+
13+
def get_device_class_or_undefined(
14+
hass: HomeAssistant, entity_id: str
15+
) -> str | None | UndefinedType:
16+
"""Get the device class of an entity or UNDEFINED if not found."""
17+
try:
18+
return get_device_class(hass, entity_id)
19+
except HomeAssistantError:
20+
return UNDEFINED
21+
22+
23+
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
24+
"""Class for binary sensor on/off triggers."""
25+
26+
_device_class: BinarySensorDeviceClass | None
27+
_domain: str = DOMAIN
28+
29+
def entity_filter(self, entities: set[str]) -> set[str]:
30+
"""Filter entities of this domain."""
31+
entities = super().entity_filter(entities)
32+
return {
33+
entity_id
34+
for entity_id in entities
35+
if get_device_class_or_undefined(self._hass, entity_id)
36+
== self._device_class
37+
}
38+
39+
40+
def make_binary_sensor_trigger(
41+
device_class: BinarySensorDeviceClass | None,
42+
to_state: str,
43+
) -> type[BinarySensorOnOffTrigger]:
44+
"""Create an entity state trigger class."""
45+
46+
class CustomTrigger(BinarySensorOnOffTrigger):
47+
"""Trigger for entity state changes."""
48+
49+
_device_class = device_class
50+
_to_state = to_state
51+
52+
return CustomTrigger
53+
54+
55+
TRIGGERS: dict[str, type[Trigger]] = {
56+
"occupancy_detected": make_binary_sensor_trigger(
57+
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
58+
),
59+
"occupancy_cleared": make_binary_sensor_trigger(
60+
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
61+
),
62+
}
63+
64+
65+
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
66+
"""Return the triggers for binary sensors."""
67+
return TRIGGERS
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.trigger_common_fields: &trigger_common_fields
2+
behavior:
3+
required: true
4+
default: any
5+
selector:
6+
select:
7+
translation_key: trigger_behavior
8+
options:
9+
- first
10+
- last
11+
- any
12+
13+
occupancy_cleared:
14+
fields: *trigger_common_fields
15+
target:
16+
entity:
17+
domain: binary_sensor
18+
device_class: presence
19+
20+
occupancy_detected:
21+
fields: *trigger_common_fields
22+
target:
23+
entity:
24+
domain: binary_sensor
25+
device_class: presence

tests/components/__init__.py

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,15 @@
2929
from tests.common import MockConfigEntry, mock_device_registry
3030

3131

32-
async def target_entities(hass: HomeAssistant, domain: str) -> list[str]:
33-
"""Create multiple entities associated with different targets."""
32+
async def target_entities(
33+
hass: HomeAssistant, domain: str
34+
) -> tuple[list[str], list[str]]:
35+
"""Create multiple entities associated with different targets.
36+
37+
Returns a dict with the following keys:
38+
- included: List of entity_ids meant to be targeted.
39+
- excluded: List of entity_ids not meant to be targeted.
40+
"""
3441
await async_setup_component(hass, domain, {})
3542

3643
config_entry = MockConfigEntry(domain="test")
@@ -55,40 +62,71 @@ async def target_entities(hass: HomeAssistant, domain: str) -> list[str]:
5562
mock_device_registry(hass, {device.id: device})
5663

5764
entity_reg = er.async_get(hass)
58-
# Entity associated with area
65+
# Entities associated with area
5966
entity_area = entity_reg.async_get_or_create(
6067
domain=domain,
6168
platform="test",
6269
unique_id=f"{domain}_area",
6370
suggested_object_id=f"area_{domain}",
6471
)
6572
entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
73+
entity_area_excluded = entity_reg.async_get_or_create(
74+
domain=domain,
75+
platform="test",
76+
unique_id=f"{domain}_area_excluded",
77+
suggested_object_id=f"area_{domain}_excluded",
78+
)
79+
entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id)
6680

67-
# Entity associated with device
81+
# Entities associated with device
6882
entity_reg.async_get_or_create(
6983
domain=domain,
7084
platform="test",
7185
unique_id=f"{domain}_device",
7286
suggested_object_id=f"device_{domain}",
7387
device_id=device.id,
7488
)
89+
entity_reg.async_get_or_create(
90+
domain=domain,
91+
platform="test",
92+
unique_id=f"{domain}_device_excluded",
93+
suggested_object_id=f"device_{domain}_excluded",
94+
device_id=device.id,
95+
)
7596

76-
# Entity associated with label
97+
# Entities associated with label
7798
entity_label = entity_reg.async_get_or_create(
7899
domain=domain,
79100
platform="test",
80101
unique_id=f"{domain}_label",
81102
suggested_object_id=f"label_{domain}",
82103
)
83104
entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
105+
entity_label_excluded = entity_reg.async_get_or_create(
106+
domain=domain,
107+
platform="test",
108+
unique_id=f"{domain}_label_excluded",
109+
suggested_object_id=f"label_{domain}_excluded",
110+
)
111+
entity_reg.async_update_entity(
112+
entity_label_excluded.entity_id, labels={label.label_id}
113+
)
84114

85115
# Return all available entities
86-
return [
87-
f"{domain}.standalone_{domain}",
88-
f"{domain}.label_{domain}",
89-
f"{domain}.area_{domain}",
90-
f"{domain}.device_{domain}",
91-
]
116+
return {
117+
"included": [
118+
f"{domain}.standalone_{domain}",
119+
f"{domain}.label_{domain}",
120+
f"{domain}.area_{domain}",
121+
f"{domain}.device_{domain}",
122+
],
123+
"excluded": [
124+
f"{domain}.standalone_{domain}_excluded",
125+
f"{domain}.label_{domain}_excluded",
126+
f"{domain}.area_{domain}_excluded",
127+
f"{domain}.device_{domain}_excluded",
128+
],
129+
}
92130

93131

94132
def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
@@ -112,11 +150,18 @@ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
112150
]
113151

114152

115-
class StateDescription(TypedDict):
153+
class _StateDescription(TypedDict):
116154
"""Test state and expected service call count."""
117155

118156
state: str | None
119157
attributes: dict
158+
159+
160+
class StateDescription(TypedDict):
161+
"""Test state and expected service call count."""
162+
163+
included: _StateDescription
164+
excluded: _StateDescription
120165
count: int
121166

122167

@@ -147,10 +192,26 @@ def state_with_attributes(
147192
) -> dict:
148193
"""Return (state, attributes) dict."""
149194
if isinstance(state, str) or state is None:
150-
return {"state": state, "attributes": additional_attributes, "count": count}
195+
return {
196+
"included": {
197+
"state": state,
198+
"attributes": additional_attributes,
199+
},
200+
"excluded": {
201+
"state": state,
202+
"attributes": {},
203+
},
204+
"count": count,
205+
}
151206
return {
152-
"state": state[0],
153-
"attributes": state[1] | additional_attributes,
207+
"included": {
208+
"state": state[0],
209+
"attributes": state[1] | additional_attributes,
210+
},
211+
"excluded": {
212+
"state": state[0],
213+
"attributes": state[1],
214+
},
154215
"count": count,
155216
}
156217

tests/components/alarm_control_panel/test_trigger.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]:
4242
@pytest.fixture
4343
async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]:
4444
"""Create multiple alarm control panel entities associated with different targets."""
45-
return await target_entities(hass, "alarm_control_panel")
45+
return (await target_entities(hass, "alarm_control_panel"))["included"]
4646

4747

4848
@pytest.mark.parametrize(
@@ -160,13 +160,14 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
160160

161161
# Set all alarm control panels, including the tested one, to the initial state
162162
for eid in target_alarm_control_panels:
163-
set_or_remove_state(hass, eid, states[0])
163+
set_or_remove_state(hass, eid, states[0]["included"])
164164
await hass.async_block_till_done()
165165

166166
await arm_trigger(hass, trigger, {}, trigger_target_config)
167167

168168
for state in states[1:]:
169-
set_or_remove_state(hass, entity_id, state)
169+
included_state = state["included"]
170+
set_or_remove_state(hass, entity_id, included_state)
170171
await hass.async_block_till_done()
171172
assert len(service_calls) == state["count"]
172173
for service_call in service_calls:
@@ -175,7 +176,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
175176

176177
# Check if changing other alarm control panels also triggers
177178
for other_entity_id in other_entity_ids:
178-
set_or_remove_state(hass, other_entity_id, state)
179+
set_or_remove_state(hass, other_entity_id, included_state)
179180
await hass.async_block_till_done()
180181
assert len(service_calls) == (entities_in_target - 1) * state["count"]
181182
service_calls.clear()
@@ -271,13 +272,14 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
271272

272273
# Set all alarm control panels, including the tested one, to the initial state
273274
for eid in target_alarm_control_panels:
274-
set_or_remove_state(hass, eid, states[0])
275+
set_or_remove_state(hass, eid, states[0]["included"])
275276
await hass.async_block_till_done()
276277

277278
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
278279

279280
for state in states[1:]:
280-
set_or_remove_state(hass, entity_id, state)
281+
included_state = state["included"]
282+
set_or_remove_state(hass, entity_id, included_state)
281283
await hass.async_block_till_done()
282284
assert len(service_calls) == state["count"]
283285
for service_call in service_calls:
@@ -286,7 +288,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
286288

287289
# Triggering other alarm control panels should not cause the trigger to fire again
288290
for other_entity_id in other_entity_ids:
289-
set_or_remove_state(hass, other_entity_id, state)
291+
set_or_remove_state(hass, other_entity_id, included_state)
290292
await hass.async_block_till_done()
291293
assert len(service_calls) == 0
292294

@@ -381,18 +383,19 @@ async def test_alarm_control_panel_state_trigger_behavior_last(
381383

382384
# Set all alarm control panels, including the tested one, to the initial state
383385
for eid in target_alarm_control_panels:
384-
set_or_remove_state(hass, eid, states[0])
386+
set_or_remove_state(hass, eid, states[0]["included"])
385387
await hass.async_block_till_done()
386388

387389
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
388390

389391
for state in states[1:]:
392+
included_state = state["included"]
390393
for other_entity_id in other_entity_ids:
391-
set_or_remove_state(hass, other_entity_id, state)
394+
set_or_remove_state(hass, other_entity_id, included_state)
392395
await hass.async_block_till_done()
393396
assert len(service_calls) == 0
394397

395-
set_or_remove_state(hass, entity_id, state)
398+
set_or_remove_state(hass, entity_id, included_state)
396399
await hass.async_block_till_done()
397400
assert len(service_calls) == state["count"]
398401
for service_call in service_calls:

0 commit comments

Comments
 (0)