Compare commits

...

1 Commits

Author SHA1 Message Date
Erik 0d9dde7601 Add zone triggers entered/left zone 2026-05-21 18:44:20 +02:00
5 changed files with 451 additions and 83 deletions
+8
View File
@@ -3,5 +3,13 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"entered": {
"trigger": "mdi:map-marker-plus"
},
"left": {
"trigger": "mdi:map-marker-minus"
}
}
}
@@ -1,8 +1,46 @@
{
"common": {
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"services": {
"reload": {
"description": "Reloads zones from the YAML-configuration.",
"name": "Reload zones"
}
},
"triggers": {
"entered": {
"description": "Triggers when one or more persons or device trackers enter a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "The zones to trigger on.",
"name": "Zones"
}
},
"name": "Entered zone"
},
"left": {
"description": "Triggers when one or more persons or device trackers leave a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "The zones to trigger on.",
"name": "Zones"
}
},
"name": "Left zone"
}
}
}
+163 -76
View File
@@ -1,22 +1,24 @@
"""Offer zone automation rules."""
import logging
from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_PLATFORM,
CONF_OPTIONS,
CONF_ZONE,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HassJob,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
@@ -24,8 +26,18 @@ from homeassistant.helpers import (
entity_registry as er,
location,
)
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTriggerBase,
Trigger,
TriggerActionRunner,
TriggerConfig,
)
from homeassistant.helpers.typing import ConfigType
from . import condition
@@ -38,93 +50,168 @@ _LOGGER = logging.getLogger(__name__)
_EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"}
_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE),
}
_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): "zone",
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(
EVENT_ENTER, EVENT_LEAVE
),
vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA,
},
)
# New-style zone trigger schema
_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): vol.All(
cv.ensure_list, [cv.entity_id], vol.Length(min=1)
),
},
}
)
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate trigger config."""
config = _TRIGGER_SCHEMA(config)
registry = er.async_get(hass)
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_ENTITY_ID]
)
return config
_DOMAIN_SPECS: dict[str, DomainSpec] = {
"person": DomainSpec(),
"device_tracker": DomainSpec(),
}
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
platform_type: str = "zone",
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
trigger_data = trigger_info["trigger_data"]
entity_id: list[str] = config[CONF_ENTITY_ID]
zone_entity_id: str = config[CONF_ZONE]
event: str = config[CONF_EVENT]
job = HassJob(action)
class LegacyZoneTrigger(Trigger):
"""Legacy zone trigger (platform: zone)."""
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config, migrating legacy format to options."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _LEGACY_OPTIONS_SCHEMA
)
return await super().async_validate_complete_config(hass, complete_config)
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config))
registry = er.async_get(hass)
config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_OPTIONS][CONF_ENTITY_ID]
)
return config
if not (zone_state := hass.states.get(zone_entity_id)):
_LOGGER.warning(
(
"Automation '%s' is referencing non-existing zone '%s' in a zone"
" trigger"
),
trigger_info["name"],
zone_entity_id,
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
entity_id: list[str] = self._options[CONF_ENTITY_ID]
zone_entity_id: str = self._options[CONF_ZONE]
event: str = self._options[CONF_EVENT]
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
if not (zone_state := self._hass.states.get(zone_entity_id)):
_LOGGER.warning(
"Non-existing zone '%s' in a zone trigger",
zone_entity_id,
)
return
from_match = (
condition.zone(self._hass, zone_state, from_s) if from_s else False
)
return
to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False
from_match = condition.zone(hass, zone_state, from_s) if from_s else False
to_match = condition.zone(hass, zone_state, to_s) if to_s else False
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = (
f"{entity} {_EVENT_DESCRIPTION[event]}"
f" {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
)
hass.async_run_hass_job(
job,
{
"trigger": {
**trigger_data,
"platform": platform_type,
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
run_action(
{
"entity_id": entity,
"from_state": from_s,
"to_state": to_s,
"zone": zone_state,
"event": event,
"description": description,
}
},
to_s.context if to_s else None,
)
},
description,
to_s.context if to_s else None,
)
return async_track_state_change_event(hass, entity_id, zone_automation_listener)
return async_track_state_change_event(
self._hass, entity_id, zone_automation_listener
)
class ZoneTriggerBase(EntityTriggerBase):
"""Base for zone-based triggers targeting person and device_tracker entities."""
_domain_specs = _DOMAIN_SPECS
_schema = _ZONE_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._zones: set[str] = set(self._options[CONF_ZONE])
def _in_target_zones(self, state: State) -> bool:
"""Check if the entity is in any of the selected zones."""
in_zones = set(state.attributes.get(ATTR_IN_ZONES) or [])
return bool(in_zones.intersection(self._zones))
class EnteredZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity enters one of the selected zones."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was not already in any of the selected zones."""
return not self._in_target_zones(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is now in at least one of the selected zones."""
return self._in_target_zones(state)
class LeftZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity leaves all of the selected zones."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was previously in at least one of the selected zones."""
return self._in_target_zones(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is no longer in any of the selected zones."""
return not self._in_target_zones(state)
TRIGGERS: dict[str, type[Trigger]] = {
"_": LegacyZoneTrigger,
"entered": EnteredZoneTrigger,
"left": LeftZoneTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for zones."""
return TRIGGERS
@@ -0,0 +1,27 @@
.trigger_zone: &trigger_zone
target:
entity:
domain:
- person
- device_tracker
fields:
behavior:
required: true
default: any
selector:
automation_behavior:
mode: trigger
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
multiple: true
entered: *trigger_zone
left: *trigger_zone
+215 -7
View File
@@ -1,5 +1,7 @@
"""The tests for the location automation."""
from typing import Any
import pytest
from homeassistant.components import automation, zone
@@ -9,6 +11,16 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import mock_component
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_options_supported,
parametrize_target_entities,
parametrize_trigger_states,
target_entities,
)
@pytest.fixture(autouse=True)
@@ -343,10 +355,7 @@ async def test_unknown_zone(
},
)
assert (
"Automation 'My Automation' is referencing non-existing zone"
" 'zone.no_such_zone' in a zone trigger" not in caplog.text
)
assert "Non-existing zone 'zone.no_such_zone' in a zone trigger" not in caplog.text
hass.states.async_set(
"test.entity",
@@ -356,7 +365,206 @@ async def test_unknown_zone(
)
await hass.async_block_till_done()
assert (
"Automation 'My Automation' is referencing non-existing zone"
" 'zone.no_such_zone' in a zone trigger" in caplog.text
assert "Non-existing zone 'zone.no_such_zone' in a zone trigger" in caplog.text
# --- New-style zone trigger tests ---
ZONE_HOME = "zone.home"
ZONE_WORK = "zone.work"
IN_ZONES_HOME = {"in_zones": [ZONE_HOME]}
IN_ZONES_WORK = {"in_zones": [ZONE_WORK]}
IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []}
TRIGGER_ZONES = [ZONE_HOME, ZONE_WORK]
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("zone.entered", {"zone": TRIGGER_ZONES}, True, True),
("zone.left", {"zone": TRIGGER_ZONES}, True, True),
],
)
async def test_zone_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
) -> None:
"""Test that zone triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
)
@pytest.fixture
async def target_persons(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple person entities associated with different targets."""
return await target_entities(hass, "person", domain_excluded="sensor")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("person"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="zone.entered",
trigger_options={"zone": TRIGGER_ZONES},
target_states=[
("home", IN_ZONES_HOME),
("Work", IN_ZONES_WORK),
],
other_states=[
("not_home", IN_ZONES_NONE),
],
),
*parametrize_trigger_states(
trigger="zone.left",
trigger_options={"zone": TRIGGER_ZONES},
target_states=[
("not_home", IN_ZONES_NONE),
],
other_states=[
("home", IN_ZONES_HOME),
("Work", IN_ZONES_WORK),
],
),
],
)
async def test_zone_trigger_behavior_any(
hass: HomeAssistant,
target_persons: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test zone triggers fire when any targeted entity changes."""
await assert_trigger_behavior_any(
hass,
target_entities=target_persons,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("person"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="zone.entered",
trigger_options={"zone": TRIGGER_ZONES},
target_states=[
("home", IN_ZONES_HOME),
("Work", IN_ZONES_WORK),
],
other_states=[
("not_home", IN_ZONES_NONE),
],
),
*parametrize_trigger_states(
trigger="zone.left",
trigger_options={"zone": TRIGGER_ZONES},
target_states=[
("not_home", IN_ZONES_NONE),
],
other_states=[
("home", IN_ZONES_HOME),
("Work", IN_ZONES_WORK),
],
),
],
)
async def test_zone_trigger_behavior_first(
hass: HomeAssistant,
target_persons: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test zone triggers fire when first targeted entity changes."""
await assert_trigger_behavior_first(
hass,
target_entities=target_persons,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("person"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="zone.entered",
trigger_options={"zone": TRIGGER_ZONES},
target_states=[
("home", IN_ZONES_HOME),
("Work", IN_ZONES_WORK),
],
other_states=[
("not_home", IN_ZONES_NONE),
],
),
*parametrize_trigger_states(
trigger="zone.left",
trigger_options={"zone": TRIGGER_ZONES},
target_states=[
("not_home", IN_ZONES_NONE),
],
other_states=[
("home", IN_ZONES_HOME),
("Work", IN_ZONES_WORK),
],
),
],
)
async def test_zone_trigger_behavior_last(
hass: HomeAssistant,
target_persons: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test zone triggers fire when last targeted entity changes."""
await assert_trigger_behavior_last(
hass,
target_entities=target_persons,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)