mirror of
https://github.com/home-assistant/core.git
synced 2026-07-03 20:56:06 +01:00
300 lines
9.3 KiB
Python
300 lines
9.3 KiB
Python
"""Offer zone automation rules."""
|
|
|
|
from typing import Any, Unpack, cast
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.device_tracker import (
|
|
ATTR_IN_ZONES,
|
|
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
|
)
|
|
from homeassistant.components.person import DOMAIN as PERSON_DOMAIN
|
|
from homeassistant.const import (
|
|
ATTR_GPS_ACCURACY,
|
|
ATTR_LATITUDE,
|
|
ATTR_LONGITUDE,
|
|
CONF_ENTITY_ID,
|
|
CONF_FOR,
|
|
CONF_OPTIONS,
|
|
CONF_TARGET,
|
|
CONF_ZONE,
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
)
|
|
from homeassistant.core import HomeAssistant, State
|
|
from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage
|
|
from homeassistant.helpers import config_validation as cv
|
|
from homeassistant.helpers.automation import (
|
|
DomainSpec,
|
|
move_top_level_schema_fields_to_options,
|
|
)
|
|
from homeassistant.helpers.condition import (
|
|
ATTR_BEHAVIOR,
|
|
BEHAVIOR_ANY,
|
|
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
|
|
Condition,
|
|
ConditionCheckParams,
|
|
ConditionConfig,
|
|
EntityConditionBase,
|
|
)
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
from . import in_zone
|
|
from .const import DOMAIN
|
|
|
|
_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
|
|
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
|
|
vol.Required("zone"): cv.entity_ids,
|
|
}
|
|
_CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT})
|
|
|
|
_IN_ZONES_DOMAINS = {DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN}
|
|
|
|
|
|
def zone(
|
|
hass: HomeAssistant,
|
|
zone_ent: str | State | None,
|
|
entity: str | State | None,
|
|
) -> bool:
|
|
"""Test if zone-condition matches.
|
|
|
|
Async friendly.
|
|
"""
|
|
if zone_ent is None:
|
|
raise ConditionErrorMessage("zone", "no zone specified")
|
|
|
|
if isinstance(zone_ent, str):
|
|
zone_ent_id = zone_ent
|
|
|
|
if (zone_ent := hass.states.get(zone_ent)) is None:
|
|
raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}")
|
|
|
|
if entity is None:
|
|
raise ConditionErrorMessage("zone", "no entity specified")
|
|
|
|
if isinstance(entity, str):
|
|
entity_id = entity
|
|
|
|
if (entity := hass.states.get(entity)) is None:
|
|
raise ConditionErrorMessage("zone", f"unknown entity {entity_id}")
|
|
else:
|
|
entity_id = entity.entity_id
|
|
|
|
if entity.state in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
return False
|
|
|
|
# Prefer the in_zones attribute reported by the entity (e.g. person,
|
|
# device_tracker) over recomputing membership from coordinates.
|
|
if (
|
|
entity.domain in _IN_ZONES_DOMAINS
|
|
and (in_zones := entity.attributes.get(ATTR_IN_ZONES)) is not None
|
|
):
|
|
return zone_ent.entity_id in in_zones
|
|
|
|
latitude = entity.attributes.get(ATTR_LATITUDE)
|
|
longitude = entity.attributes.get(ATTR_LONGITUDE)
|
|
|
|
if latitude is None:
|
|
raise ConditionErrorMessage(
|
|
"zone", f"entity {entity_id} has no 'latitude' attribute"
|
|
)
|
|
|
|
if longitude is None:
|
|
raise ConditionErrorMessage(
|
|
"zone", f"entity {entity_id} has no 'longitude' attribute"
|
|
)
|
|
|
|
return in_zone(
|
|
zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0)
|
|
)
|
|
|
|
|
|
class ZoneCondition(Condition):
|
|
"""Zone condition."""
|
|
|
|
_options: dict[str, Any]
|
|
|
|
@classmethod
|
|
async def async_validate_complete_config(
|
|
cls, hass: HomeAssistant, complete_config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate complete config."""
|
|
complete_config = move_top_level_schema_fields_to_options(
|
|
complete_config, _OPTIONS_SCHEMA_DICT
|
|
)
|
|
return await super().async_validate_complete_config(hass, complete_config)
|
|
|
|
@classmethod
|
|
async def async_validate_config(
|
|
cls, hass: HomeAssistant, config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate config."""
|
|
return cast(ConfigType, _CONDITION_SCHEMA(config))
|
|
|
|
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
|
"""Initialize condition."""
|
|
super().__init__(hass, config)
|
|
assert config.options is not None
|
|
self._options = config.options
|
|
self._entity_ids = self._options.get(CONF_ENTITY_ID, [])
|
|
self._zone_entity_ids = self._options.get(CONF_ZONE, [])
|
|
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
|
|
"""Test if condition."""
|
|
errors = []
|
|
|
|
all_ok = True
|
|
for entity_id in self._entity_ids:
|
|
entity_ok = False
|
|
for zone_entity_id in self._zone_entity_ids:
|
|
try:
|
|
if zone(self._hass, zone_entity_id, entity_id):
|
|
entity_ok = True
|
|
except ConditionErrorMessage as ex:
|
|
errors.append(
|
|
ConditionErrorMessage(
|
|
"zone",
|
|
(
|
|
f"error matching {entity_id} with {zone_entity_id}:"
|
|
f" {ex.message}"
|
|
),
|
|
)
|
|
)
|
|
|
|
if not entity_ok:
|
|
all_ok = False
|
|
|
|
# Raise the errors only if no definitive result was found
|
|
if errors and not all_ok:
|
|
raise ConditionErrorContainer("zone", errors=errors)
|
|
|
|
return all_ok
|
|
|
|
|
|
_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
|
"person": DomainSpec(value_source=ATTR_IN_ZONES),
|
|
"device_tracker": DomainSpec(value_source=ATTR_IN_ZONES),
|
|
}
|
|
|
|
_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
|
|
{
|
|
vol.Required(CONF_OPTIONS): {
|
|
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
class _ZoneTargetConditionBase(EntityConditionBase):
|
|
"""Base for zone-target conditions on person and device_tracker entities."""
|
|
|
|
_domain_specs = _DOMAIN_SPECS
|
|
_schema = _ZONE_CONDITION_SCHEMA
|
|
|
|
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
|
"""Initialize the condition."""
|
|
super().__init__(hass, config)
|
|
assert config.options is not None
|
|
self._zone: str = config.options[CONF_ZONE]
|
|
|
|
def _in_target_zone(self, entity_state: State) -> bool:
|
|
"""Check if the entity is currently in the selected zone."""
|
|
in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or ()
|
|
return self._zone in in_zones
|
|
|
|
|
|
class InZoneCondition(_ZoneTargetConditionBase):
|
|
"""Condition: targeted entity is in the selected zone."""
|
|
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check that the entity is in the selected zone."""
|
|
return self._in_target_zone(entity_state)
|
|
|
|
|
|
class NotInZoneCondition(_ZoneTargetConditionBase):
|
|
"""Condition: targeted entity is not in the selected zone."""
|
|
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check that the entity is not in the selected zone."""
|
|
return not self._in_target_zone(entity_state)
|
|
|
|
|
|
_OCCUPANCY_CONDITION_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(CONF_OPTIONS, default={}): {
|
|
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN),
|
|
vol.Optional(CONF_FOR): cv.positive_time_period,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
class _ZoneOccupancyConditionBase(EntityConditionBase):
|
|
"""Base for zone occupancy conditions (single zone, no behavior)."""
|
|
|
|
_domain_specs = {DOMAIN: DomainSpec()}
|
|
_schema = _OCCUPANCY_CONDITION_SCHEMA
|
|
|
|
@classmethod
|
|
async def async_validate_config(
|
|
cls, hass: HomeAssistant, config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate config and synthesize a target from the zone option.
|
|
|
|
We synthesize a target because we allow users to pick a single zone
|
|
to monitor, not a target.
|
|
"""
|
|
config = cast(ConfigType, cls._schema(config))
|
|
zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE]
|
|
config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]}
|
|
# `behavior` is needed by `EntityConditionBase.__init__`
|
|
config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY
|
|
return config
|
|
|
|
@staticmethod
|
|
def _occupancy_count(entity_state: State) -> int | None:
|
|
"""Return the zone's persons-in-zone count; None if unparsable."""
|
|
try:
|
|
return int(entity_state.state)
|
|
except TypeError, ValueError:
|
|
return None
|
|
|
|
@classmethod
|
|
def _is_occupied(cls, entity_state: State) -> bool:
|
|
"""Return True if the zone has at least one occupant."""
|
|
count = cls._occupancy_count(entity_state)
|
|
return count is not None and count >= 1
|
|
|
|
|
|
class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase):
|
|
"""Condition: the selected zone is occupied."""
|
|
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check that the zone is occupied."""
|
|
return self._is_occupied(entity_state)
|
|
|
|
|
|
class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase):
|
|
"""Condition: the selected zone is empty."""
|
|
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check that the zone is empty (count == 0)."""
|
|
return self._occupancy_count(entity_state) == 0
|
|
|
|
|
|
CONDITIONS: dict[str, type[Condition]] = {
|
|
"_": ZoneCondition,
|
|
"in_zone": InZoneCondition,
|
|
"not_in_zone": NotInZoneCondition,
|
|
"occupancy_is_detected": OccupancyIsDetectedCondition,
|
|
"occupancy_is_not_detected": OccupancyIsNotDetectedCondition,
|
|
}
|
|
|
|
|
|
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
|
"""Return the zone conditions."""
|
|
return CONDITIONS
|