Compare commits

...

2 Commits

Author SHA1 Message Date
Erik
6602375b70 Adjust strings 2026-04-15 08:01:25 +02:00
Erik
a00a34052e Add device_tracker conditions in_zone and not_in_zone 2026-04-14 18:28:32 +02:00
5 changed files with 359 additions and 7 deletions

View File

@@ -1,14 +1,88 @@
"""Provides conditions for device trackers."""
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.zone import ENTITY_ID_HOME as ENTITY_ID_HOME_ZONE
from homeassistant.const import CONF_OPTIONS, CONF_ZONE, STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityConditionBase,
make_entity_state_condition,
)
from .const import ATTR_IN_ZONES, DOMAIN
ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.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 ZoneConditionBase(EntityConditionBase):
"""Base for zone-based device tracker conditions."""
_domain_specs = _IN_ZONES_SPEC
_schema = ZONE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._zones: set[str] = set(config.options[CONF_ZONE])
def _in_target_zones(self, state: State) -> bool:
"""Check if the device is in any of the selected zones.
For GPS-based trackers, uses the in_zones attribute.
For scanner-based trackers (no in_zones attribute), infers from
state: 'home' means the device is in zone.home.
"""
if (in_zones := self._get_tracked_value(state)) is not None:
return bool(set(in_zones).intersection(self._zones))
# Scanner tracker: state 'home' means in zone.home
if state.state == STATE_HOME:
return ENTITY_ID_HOME_ZONE in self._zones
return False
class InZoneCondition(ZoneConditionBase):
"""Condition that tests if a device tracker is in one of the selected zones."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the device is in at least one of the selected zones."""
return self._in_target_zones(entity_state)
class NotInZoneCondition(ZoneConditionBase):
"""Condition that tests if a device tracker is not in any of the selected zones."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the device is not in any of the selected zones."""
return not self._in_target_zones(entity_state)
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"in_zone": InZoneCondition,
"is_home": make_entity_state_condition(DOMAIN, STATE_HOME),
"is_not_home": make_entity_state_condition(DOMAIN, STATE_NOT_HOME),
"not_in_zone": NotInZoneCondition,
}

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common
target:
target: &condition_target
entity:
domain: device_tracker
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
@@ -13,5 +13,18 @@
- all
- any
.condition_zone: &condition_zone
<<: *condition_common
fields:
behavior: *condition_behavior
zone:
required: true
selector:
entity:
domain: zone
multiple: true
in_zone: *condition_zone
is_home: *condition_common
is_not_home: *condition_common
not_in_zone: *condition_zone

View File

@@ -1,10 +1,16 @@
{
"conditions": {
"in_zone": {
"condition": "mdi:map-marker-check"
},
"is_home": {
"condition": "mdi:account"
},
"is_not_home": {
"condition": "mdi:account-arrow-right"
},
"not_in_zone": {
"condition": "mdi:map-marker-remove"
}
},
"entity_component": {

View File

@@ -1,9 +1,24 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_zone_description": "The zones to check for.",
"condition_zone_name": "Zone",
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"in_zone": {
"description": "Tests if one or more device trackers are in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
},
"zone": {
"description": "[%key:component::device_tracker::common::condition_zone_description%]",
"name": "[%key:component::device_tracker::common::condition_zone_name%]"
}
},
"name": "Device tracker is in zone"
},
"is_home": {
"description": "Tests if one or more device trackers are home.",
"fields": {
@@ -21,6 +36,19 @@
}
},
"name": "Device tracker is not home"
},
"not_in_zone": {
"description": "Tests if one or more device trackers are not in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
},
"zone": {
"description": "[%key:component::device_tracker::common::condition_zone_description%]",
"name": "[%key:component::device_tracker::common::condition_zone_name%]"
}
},
"name": "Device tracker is not in zone"
}
},
"device_automation": {

View File

@@ -1,11 +1,22 @@
"""Test device tracker conditions."""
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.condition import async_validate_condition_config
from tests.components.common import (
ConditionStateDescription,
@@ -18,6 +29,13 @@ from tests.components.common import (
target_entities,
)
STATE_WORK_ZONE = "work"
def _gps_state(state: str, in_zones: list[str]) -> tuple[str, dict[str, list[str]]]:
"""Create a GPS-based device tracker state with in_zones attribute."""
return (state, {ATTR_IN_ZONES: in_zones})
@pytest.fixture
async def target_device_trackers(hass: HomeAssistant) -> dict[str, list[str]]:
@@ -28,8 +46,10 @@ async def target_device_trackers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"condition",
[
"device_tracker.in_zone",
"device_tracker.is_home",
"device_tracker.is_not_home",
"device_tracker.not_in_zone",
],
)
async def test_device_tracker_conditions_gated_by_labs_flag(
@@ -123,3 +143,214 @@ async def test_device_tracker_state_condition_behavior_all(
condition_options=condition_options,
states=states,
)
# Zone conditions for GPS-based trackers (have in_zones attribute)
GPS_ZONE_CONDITIONS_ANY = [
*parametrize_condition_states_any(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
other_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
),
*parametrize_condition_states_any(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
other_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
),
]
GPS_ZONE_CONDITIONS_ALL = [
*parametrize_condition_states_all(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
other_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
),
*parametrize_condition_states_all(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
other_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
),
]
# Zone conditions for scanner-based trackers (no in_zones attribute)
SCANNER_ZONE_CONDITIONS_ANY = [
*parametrize_condition_states_any(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME],
),
*parametrize_condition_states_any(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_NOT_HOME],
other_states=[STATE_HOME],
),
]
SCANNER_ZONE_CONDITIONS_ALL = [
*parametrize_condition_states_all(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME],
),
*parametrize_condition_states_all(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_NOT_HOME],
other_states=[STATE_HOME],
),
]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger", "trigger_options", "expected_result"),
[
# Valid configurations
(
"device_tracker.in_zone",
{CONF_ZONE: ["zone.home", "zone.work"]},
does_not_raise(),
),
(
"device_tracker.in_zone",
{CONF_ZONE: "zone.home"},
does_not_raise(),
),
(
"device_tracker.not_in_zone",
{CONF_ZONE: ["zone.home"]},
does_not_raise(),
),
# Invalid configurations
(
"device_tracker.in_zone",
{CONF_ZONE: []},
pytest.raises(vol.Invalid),
),
(
"device_tracker.in_zone",
{},
pytest.raises(vol.Invalid),
),
(
"device_tracker.in_zone",
{CONF_ZONE: ["light.living_room"]},
pytest.raises(vol.Invalid),
),
],
)
async def test_device_tracker_zone_condition_validation(
hass: HomeAssistant,
trigger: str,
trigger_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test device_tracker zone condition config validation."""
with expected_result:
await async_validate_condition_config(
hass,
{
"condition": trigger,
CONF_TARGET: {CONF_ENTITY_ID: "device_tracker.test"},
CONF_OPTIONS: trigger_options,
},
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[*GPS_ZONE_CONDITIONS_ANY, *SCANNER_ZONE_CONDITIONS_ANY],
)
async def test_device_tracker_zone_condition_behavior_any(
hass: HomeAssistant,
target_device_trackers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker zone condition with the 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_device_trackers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[*GPS_ZONE_CONDITIONS_ALL, *SCANNER_ZONE_CONDITIONS_ALL],
)
async def test_device_tracker_zone_condition_behavior_all(
hass: HomeAssistant,
target_device_trackers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker zone condition with the 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_device_trackers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)