mirror of
https://github.com/home-assistant/core.git
synced 2026-04-07 16:06:02 +00:00
Compare commits
1 Commits
drop-ignor
...
add_device
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a7fd5b79 |
@@ -24,8 +24,14 @@
|
||||
"entered_home": {
|
||||
"trigger": "mdi:account-arrow-left"
|
||||
},
|
||||
"entered_zone": {
|
||||
"trigger": "mdi:map-marker-plus"
|
||||
},
|
||||
"left_home": {
|
||||
"trigger": "mdi:account-arrow-right"
|
||||
},
|
||||
"left_zone": {
|
||||
"trigger": "mdi:map-marker-minus"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,19 @@
|
||||
},
|
||||
"name": "Entered home"
|
||||
},
|
||||
"entered_zone": {
|
||||
"description": "Triggers when one or more device trackers enter a zone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "The zones to trigger on.",
|
||||
"name": "Zones"
|
||||
}
|
||||
},
|
||||
"name": "Entered zone"
|
||||
},
|
||||
"left_home": {
|
||||
"description": "Triggers when one or more device trackers leave home.",
|
||||
"fields": {
|
||||
@@ -138,6 +151,19 @@
|
||||
}
|
||||
},
|
||||
"name": "Left home"
|
||||
},
|
||||
"left_zone": {
|
||||
"description": "Triggers when one or more device trackers leave a zone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "The zones to trigger on.",
|
||||
"name": "Zones"
|
||||
}
|
||||
},
|
||||
"name": "Left zone"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,93 @@
|
||||
"""Provides triggers for device_trackers."""
|
||||
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_OPTIONS,
|
||||
CONF_ZONE,
|
||||
STATE_HOME,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
make_entity_origin_state_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import ATTR_IN_ZONES, DOMAIN
|
||||
|
||||
ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_ZONE): vol.All(
|
||||
cv.ensure_list,
|
||||
vol.Length(min=1),
|
||||
[cv.entity_domain("zone")],
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_IN_ZONES_SPEC = {DOMAIN: DomainSpec(value_source=ATTR_IN_ZONES)}
|
||||
|
||||
|
||||
class ZoneTriggerBase(EntityTriggerBase):
|
||||
"""Base for zone-based device tracker triggers."""
|
||||
|
||||
_domain_specs = _IN_ZONES_SPEC
|
||||
_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 _is_valid(self, state: State) -> bool:
|
||||
"""Check if the state is valid (not unavailable/unknown)."""
|
||||
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
|
||||
def _in_target_zones(self, state: State) -> bool:
|
||||
"""Check if the device is in any of the selected zones."""
|
||||
in_zones = set(self._get_tracked_value(state) or [])
|
||||
return bool(in_zones.intersection(self._zones))
|
||||
|
||||
|
||||
class EnteredZoneTrigger(ZoneTriggerBase):
|
||||
"""Trigger when a device tracker enters one of the selected zones."""
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the device was not already in any of the selected zones."""
|
||||
return self._is_valid(from_state) and not self._in_target_zones(from_state)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the device is now in at least one of the selected zones."""
|
||||
return self._is_valid(state) and self._in_target_zones(state)
|
||||
|
||||
|
||||
class LeftZoneTrigger(ZoneTriggerBase):
|
||||
"""Trigger when a device tracker leaves all of the selected zones."""
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the device was previously in at least one of the selected zones."""
|
||||
return self._is_valid(from_state) and self._in_target_zones(from_state)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the device is no longer in any of the selected zones."""
|
||||
return self._is_valid(state) and not self._in_target_zones(state)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
|
||||
"entered_zone": EnteredZoneTrigger,
|
||||
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
|
||||
"left_zone": LeftZoneTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,5 +14,27 @@
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
.trigger_zone: &trigger_zone
|
||||
<<: *trigger_common
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
zone:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: zone
|
||||
multiple: true
|
||||
|
||||
entered_home: *trigger_common
|
||||
entered_zone: *trigger_zone
|
||||
left_home: *trigger_common
|
||||
left_zone: *trigger_zone
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""Test device_tracker trigger."""
|
||||
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
|
||||
from homeassistant.components.device_tracker.const import ATTR_IN_ZONES
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
CONF_ZONE,
|
||||
STATE_HOME,
|
||||
STATE_NOT_HOME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import async_validate_trigger_config
|
||||
|
||||
from tests.components.common import (
|
||||
TriggerStateDescription,
|
||||
@@ -21,6 +32,51 @@ from tests.components.common import (
|
||||
STATE_WORK_ZONE = "work"
|
||||
|
||||
|
||||
def _dt_state(state: str, in_zones: list[str]) -> tuple[str, dict[str, list[str]]]:
|
||||
"""Create a device tracker state tuple with in_zones attribute."""
|
||||
return (state, {ATTR_IN_ZONES: in_zones})
|
||||
|
||||
|
||||
ZONE_TRIGGERS = [
|
||||
*parametrize_trigger_states(
|
||||
trigger="device_tracker.entered_zone",
|
||||
trigger_options={CONF_ZONE: ["zone.home", "zone.work"]},
|
||||
target_states=[
|
||||
# In zone.home
|
||||
_dt_state(STATE_HOME, ["zone.home"]),
|
||||
# In zone.work
|
||||
_dt_state(STATE_WORK_ZONE, ["zone.work"]),
|
||||
# In both zones
|
||||
_dt_state(STATE_HOME, ["zone.home", "zone.work"]),
|
||||
],
|
||||
other_states=[
|
||||
# Not in any selected zone
|
||||
_dt_state(STATE_NOT_HOME, []),
|
||||
# In an unrelated zone
|
||||
_dt_state("school", ["zone.school"]),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="device_tracker.left_zone",
|
||||
trigger_options={CONF_ZONE: ["zone.home", "zone.work"]},
|
||||
target_states=[
|
||||
# Not in any selected zone
|
||||
_dt_state(STATE_NOT_HOME, []),
|
||||
# In an unrelated zone only
|
||||
_dt_state("school", ["zone.school"]),
|
||||
],
|
||||
other_states=[
|
||||
# In zone.home
|
||||
_dt_state(STATE_HOME, ["zone.home"]),
|
||||
# In zone.work
|
||||
_dt_state(STATE_WORK_ZONE, ["zone.work"]),
|
||||
# In both zones
|
||||
_dt_state(STATE_HOME, ["zone.home", "zone.work"]),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_device_trackers(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple device_trackers entities associated with different targets."""
|
||||
@@ -29,7 +85,12 @@ async def target_device_trackers(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
["device_tracker.entered_home", "device_tracker.left_home"],
|
||||
[
|
||||
"device_tracker.entered_home",
|
||||
"device_tracker.entered_zone",
|
||||
"device_tracker.left_home",
|
||||
"device_tracker.left_zone",
|
||||
],
|
||||
)
|
||||
async def test_device_tracker_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
@@ -165,3 +226,149 @@ async def test_device_tracker_state_trigger_behavior_last(
|
||||
trigger_options=trigger_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "expected_result"),
|
||||
[
|
||||
# Valid configurations
|
||||
(
|
||||
"device_tracker.entered_zone",
|
||||
{CONF_ZONE: ["zone.home", "zone.work"]},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"device_tracker.entered_zone",
|
||||
{CONF_ZONE: "zone.home"},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"device_tracker.left_zone",
|
||||
{CONF_ZONE: ["zone.home"]},
|
||||
does_not_raise(),
|
||||
),
|
||||
# Invalid configurations
|
||||
(
|
||||
"device_tracker.entered_zone",
|
||||
{CONF_ZONE: []},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
"device_tracker.entered_zone",
|
||||
{},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
"device_tracker.entered_zone",
|
||||
# Not a zone entity
|
||||
{CONF_ZONE: ["light.living_room"]},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_tracker_zone_trigger_validation(
|
||||
hass: HomeAssistant,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
expected_result: AbstractContextManager,
|
||||
) -> None:
|
||||
"""Test device_tracker zone trigger config validation."""
|
||||
with expected_result:
|
||||
await async_validate_trigger_config(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
"platform": trigger,
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "device_tracker.test"},
|
||||
CONF_OPTIONS: trigger_options,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("device_tracker"),
|
||||
)
|
||||
@pytest.mark.parametrize(("trigger", "trigger_options", "states"), ZONE_TRIGGERS)
|
||||
async def test_device_tracker_zone_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_device_trackers: 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 that the zone triggers fire when any device_tracker enters/leaves a zone."""
|
||||
await assert_trigger_behavior_any(
|
||||
hass,
|
||||
target_entities=target_device_trackers,
|
||||
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.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("device_tracker"),
|
||||
)
|
||||
@pytest.mark.parametrize(("trigger", "trigger_options", "states"), ZONE_TRIGGERS)
|
||||
async def test_device_tracker_zone_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
target_device_trackers: 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 that the zone triggers fire when the first device_tracker enters/leaves a zone."""
|
||||
await assert_trigger_behavior_first(
|
||||
hass,
|
||||
target_entities=target_device_trackers,
|
||||
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.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("device_tracker"),
|
||||
)
|
||||
@pytest.mark.parametrize(("trigger", "trigger_options", "states"), ZONE_TRIGGERS)
|
||||
async def test_device_tracker_zone_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
target_device_trackers: 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 that the zone triggers fire when the last device_tracker enters/leaves a zone."""
|
||||
await assert_trigger_behavior_last(
|
||||
hass,
|
||||
target_entities=target_device_trackers,
|
||||
trigger_target_config=trigger_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
trigger=trigger,
|
||||
trigger_options=trigger_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user