-
-
Notifications
You must be signed in to change notification settings - Fork 36.3k
Add occupancy binary sensor triggers #157631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
932480b
25179b7
492bc7f
a0db660
ba71105
4213736
2bb71ec
297be0b
81cbea2
8c015d7
b689310
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,8 @@ | ||
| { | ||
| "common": { | ||
| "trigger_behavior_description_presence": "The behavior of the targeted presence sensors to trigger on.", | ||
| "trigger_behavior_name": "Behavior" | ||
| }, | ||
| "device_automation": { | ||
| "condition_type": { | ||
| "is_bat_low": "{entity_name} battery is low", | ||
|
|
@@ -317,5 +321,36 @@ | |
| } | ||
| } | ||
| }, | ||
| "title": "Binary sensor" | ||
| "selector": { | ||
| "trigger_behavior": { | ||
| "options": { | ||
| "any": "Any", | ||
| "first": "First", | ||
| "last": "Last" | ||
| } | ||
| } | ||
| }, | ||
| "title": "Binary sensor", | ||
| "triggers": { | ||
| "occupancy_cleared": { | ||
| "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", | ||
| "fields": { | ||
| "behavior": { | ||
| "description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]", | ||
| "name": "[%key:component::binary_sensor::common::trigger_behavior_name%]" | ||
| } | ||
| }, | ||
| "name": "Occupancy cleared" | ||
| }, | ||
| "occupancy_detected": { | ||
| "description": "Triggers after one ore more occupancy sensors start detecting occupancy.", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We missed the typo "ore" instead of "or".
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixing that in #157791 |
||
| "fields": { | ||
| "behavior": { | ||
| "description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]", | ||
| "name": "[%key:component::binary_sensor::common::trigger_behavior_name%]" | ||
| } | ||
| }, | ||
| "name": "Occupancy detected" | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| """Provides triggers for binary sensors.""" | ||
|
|
||
| from homeassistant.const import STATE_OFF, STATE_ON | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import HomeAssistantError | ||
| from homeassistant.helpers.entity import get_device_class | ||
| from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger | ||
| from homeassistant.helpers.typing import UNDEFINED, UndefinedType | ||
|
|
||
| from . import DOMAIN, BinarySensorDeviceClass | ||
|
|
||
|
|
||
| def get_device_class_or_undefined( | ||
| hass: HomeAssistant, entity_id: str | ||
| ) -> str | None | UndefinedType: | ||
| """Get the device class of an entity or UNDEFINED if not found.""" | ||
| try: | ||
| return get_device_class(hass, entity_id) | ||
| except HomeAssistantError: | ||
| return UNDEFINED | ||
emontnemery marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| class BinarySensorOnOffTrigger(EntityStateTriggerBase): | ||
| """Class for binary sensor on/off triggers.""" | ||
|
|
||
| _device_class: BinarySensorDeviceClass | None | ||
| _domain: str = DOMAIN | ||
|
|
||
emontnemery marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| def entity_filter(self, entities: set[str]) -> set[str]: | ||
| """Filter entities of this domain.""" | ||
| entities = super().entity_filter(entities) | ||
| return { | ||
| entity_id | ||
| for entity_id in entities | ||
| if get_device_class_or_undefined(self._hass, entity_id) | ||
| == self._device_class | ||
| } | ||
|
|
||
|
|
||
| def make_binary_sensor_trigger( | ||
| device_class: BinarySensorDeviceClass | None, | ||
| to_state: str, | ||
| ) -> type[BinarySensorOnOffTrigger]: | ||
| """Create an entity state trigger class.""" | ||
|
|
||
| class CustomTrigger(BinarySensorOnOffTrigger): | ||
| """Trigger for entity state changes.""" | ||
|
|
||
| _device_class = device_class | ||
| _to_state = to_state | ||
|
|
||
| return CustomTrigger | ||
|
|
||
|
|
||
| TRIGGERS: dict[str, type[Trigger]] = { | ||
| "occupancy_detected": make_binary_sensor_trigger( | ||
| BinarySensorDeviceClass.OCCUPANCY, STATE_ON | ||
| ), | ||
| "occupancy_cleared": make_binary_sensor_trigger( | ||
| BinarySensorDeviceClass.OCCUPANCY, STATE_OFF | ||
| ), | ||
| } | ||
|
|
||
|
|
||
| async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: | ||
| """Return the triggers for binary sensors.""" | ||
| return TRIGGERS | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
| occupancy_detected: | ||
| fields: *trigger_common_fields | ||
| target: | ||
| entity: | ||
| domain: binary_sensor | ||
| device_class: presence | ||
|
|
||
| occupancy_cleared: | ||
MartinHjelmare marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| fields: *trigger_common_fields | ||
| target: | ||
| entity: | ||
| domain: binary_sensor | ||
| device_class: presence | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,8 +29,15 @@ | |
| from tests.common import MockConfigEntry, mock_device_registry | ||
|
|
||
|
|
||
| async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: | ||
| """Create multiple entities associated with different targets.""" | ||
| async def target_entities( | ||
| hass: HomeAssistant, domain: str | ||
| ) -> tuple[list[str], list[str]]: | ||
| """Create multiple entities associated with different targets. | ||
|
|
||
| Returns a dict with the following keys: | ||
| - included: List of entity_ids meant to be targeted. | ||
| - excluded: List of entity_ids not meant to be targeted. | ||
| """ | ||
| await async_setup_component(hass, domain, {}) | ||
|
|
||
| config_entry = MockConfigEntry(domain="test") | ||
|
|
@@ -55,40 +62,71 @@ async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: | |
| mock_device_registry(hass, {device.id: device}) | ||
|
|
||
| entity_reg = er.async_get(hass) | ||
| # Entity associated with area | ||
| # Entities associated with area | ||
| entity_area = entity_reg.async_get_or_create( | ||
| domain=domain, | ||
| platform="test", | ||
| unique_id=f"{domain}_area", | ||
| suggested_object_id=f"area_{domain}", | ||
| ) | ||
| entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id) | ||
| entity_area_excluded = entity_reg.async_get_or_create( | ||
| domain=domain, | ||
| platform="test", | ||
| unique_id=f"{domain}_area_excluded", | ||
| suggested_object_id=f"area_{domain}_excluded", | ||
| ) | ||
| entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id) | ||
|
|
||
| # Entity associated with device | ||
| # Entities associated with device | ||
| entity_reg.async_get_or_create( | ||
| domain=domain, | ||
| platform="test", | ||
| unique_id=f"{domain}_device", | ||
| suggested_object_id=f"device_{domain}", | ||
| device_id=device.id, | ||
| ) | ||
| entity_reg.async_get_or_create( | ||
| domain=domain, | ||
| platform="test", | ||
| unique_id=f"{domain}_device_excluded", | ||
| suggested_object_id=f"device_{domain}_excluded", | ||
| device_id=device.id, | ||
| ) | ||
|
|
||
| # Entity associated with label | ||
| # Entities associated with label | ||
| entity_label = entity_reg.async_get_or_create( | ||
| domain=domain, | ||
| platform="test", | ||
| unique_id=f"{domain}_label", | ||
| suggested_object_id=f"label_{domain}", | ||
| ) | ||
| entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id}) | ||
| entity_label_excluded = entity_reg.async_get_or_create( | ||
| domain=domain, | ||
| platform="test", | ||
| unique_id=f"{domain}_label_excluded", | ||
| suggested_object_id=f"label_{domain}_excluded", | ||
| ) | ||
| entity_reg.async_update_entity( | ||
| entity_label_excluded.entity_id, labels={label.label_id} | ||
| ) | ||
|
|
||
| # Return all available entities | ||
| return [ | ||
| f"{domain}.standalone_{domain}", | ||
| f"{domain}.label_{domain}", | ||
| f"{domain}.area_{domain}", | ||
| f"{domain}.device_{domain}", | ||
| ] | ||
| return { | ||
| "included": [ | ||
| f"{domain}.standalone_{domain}", | ||
| f"{domain}.label_{domain}", | ||
| f"{domain}.area_{domain}", | ||
| f"{domain}.device_{domain}", | ||
| ], | ||
| "excluded": [ | ||
| f"{domain}.standalone_{domain}_excluded", | ||
| f"{domain}.label_{domain}_excluded", | ||
| f"{domain}.area_{domain}_excluded", | ||
| f"{domain}.device_{domain}_excluded", | ||
| ], | ||
| } | ||
|
|
||
|
|
||
| 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]]: | |
| ] | ||
|
|
||
|
|
||
| class StateDescription(TypedDict): | ||
| class _StateDescription(TypedDict): | ||
| """Test state and expected service call count.""" | ||
|
|
||
| state: str | None | ||
| attributes: dict | ||
|
|
||
|
|
||
| class StateDescription(TypedDict): | ||
| """Test state and expected service call count.""" | ||
|
|
||
| included: _StateDescription | ||
| excluded: _StateDescription | ||
| count: int | ||
|
|
||
|
|
||
|
|
@@ -147,10 +192,26 @@ def state_with_attributes( | |
| ) -> dict: | ||
| """Return (state, attributes) dict.""" | ||
| if isinstance(state, str) or state is None: | ||
| return {"state": state, "attributes": additional_attributes, "count": count} | ||
| return { | ||
| "included": { | ||
| "state": state, | ||
| "attributes": additional_attributes, | ||
| }, | ||
| "excluded": { | ||
| "state": state, | ||
| "attributes": {}, | ||
| }, | ||
| "count": count, | ||
| } | ||
| return { | ||
| "state": state[0], | ||
| "attributes": state[1] | additional_attributes, | ||
| "included": { | ||
| "state": state[0], | ||
| "attributes": state[1] | additional_attributes, | ||
| }, | ||
| "excluded": { | ||
| "state": state[0], | ||
| "attributes": state[1], | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There doesn't seem to be much difference between included and excluded states. It's not clear to me why we need the separation here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The excluded state in the binary_sensor case don't have the wanted device class. For other domains it will be states without some capability attribute etc. The purpose is to check the filtering of entities which are not supported by the trigger is working. |
||
| }, | ||
| "count": count, | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jlpouffier Please check the text and also the trigger keys (
binary_sensor.started_detecting_presence,binary_sensor.stopped_detecting_presence) are as you expectUh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the cover one, we used that convention:
cover.shutter_openedSo let's try to use the same... meaning
binary_sensor.presence_something_somethingWhen it comes to the something something 😅 we can either be generic or find something that suits each device class best.
I would vote for the latter, and in that case, I would do
binary_sensor.presence_detectedbinary_sensor.presence_clearedIt's both past tense, so it fits our model, and it's simpler to understand.
When it comes to the strings, I would align all of them to this.
Title: Presence Detected
Description: Triggers after one or several presence sensors start detecting presence
Title: Presence Cleared
Description: Triggers after one or several presence sensors stop detecting presence
(I struggled a bit to write something with the word "Cleared".
I think it's fine to have a different wording in the description than the title).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jlpouffier can you check again please?