diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index c2f712ea6e14..14f95166f05b 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -10,7 +10,9 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,6 +25,8 @@ from homeassistant.helpers.automation import ( move_top_level_schema_fields_to_options, ) from homeassistant.helpers.condition import ( + ATTR_BEHAVIOR, + BEHAVIOR_ANY, ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionCheckParams, @@ -204,10 +208,75 @@ class NotInZoneCondition(_ZoneTargetConditionBase): 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("zone"), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyConditionBase(EntityConditionBase): + """Base for zone occupancy conditions (single zone, no behavior).""" + + _domain_specs = {"zone": 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, } diff --git a/homeassistant/components/zone/conditions.yaml b/homeassistant/components/zone/conditions.yaml index 3853bc9beb75..2294ecd2c2b4 100644 --- a/homeassistant/components/zone/conditions.yaml +++ b/homeassistant/components/zone/conditions.yaml @@ -24,3 +24,19 @@ in_zone: *condition_zone not_in_zone: *condition_zone + +.condition_occupancy: &condition_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_is_detected: *condition_occupancy +occupancy_is_not_detected: *condition_occupancy diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index 7d082d5f0d1d..5ff8e4944319 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -5,6 +5,12 @@ }, "not_in_zone": { "condition": "mdi:map-marker-remove" + }, + "occupancy_is_detected": { + "condition": "mdi:account-group" + }, + "occupancy_is_not_detected": { + "condition": "mdi:account-off" } }, "services": { diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 133bf0e15ab6..912cbff16e76 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -41,6 +41,32 @@ } }, "name": "Is not in zone" + }, + "occupancy_is_detected": { + "description": "Tests if a zone is occupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is detected" + }, + "occupancy_is_not_detected": { + "description": "Tests if a zone is empty.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is not detected" } }, "services": { diff --git a/tests/components/common.py b/tests/components/common.py index c542c0399908..d3f53c5b1a60 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -1485,12 +1485,12 @@ async def _validate_condition_options( options: dict[str, Any] | None, *, valid: bool, + supports_target: bool = True, ) -> None: """Assert that a condition accepts or rejects the given options.""" - config: dict[str, Any] = { - CONF_CONDITION: condition, - CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, - } + config: dict[str, Any] = {CONF_CONDITION: condition} + if supports_target: + config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"} if options is not None: config[CONF_OPTIONS] = options if valid: @@ -1536,6 +1536,7 @@ async def assert_condition_options_supported( *, supports_behavior: bool, supports_duration: bool, + supports_target: bool = True, ) -> None: """Assert which options a condition supports. @@ -1555,9 +1556,15 @@ async def assert_condition_options_supported( # Minimal config should always be valid # If there are no base options, also test that options can be omitted or be empty supports_empty = not bool(base_options) - await _validate_condition_options(hass, condition, None, valid=supports_empty) - await _validate_condition_options(hass, condition, {}, valid=supports_empty) - await _validate_condition_options(hass, condition, base_options, valid=True) + await _validate_condition_options( + hass, condition, None, valid=supports_empty, supports_target=supports_target + ) + await _validate_condition_options( + hass, condition, {}, valid=supports_empty, supports_target=supports_target + ) + await _validate_condition_options( + hass, condition, base_options, valid=True, supports_target=supports_target + ) def _merge(extra: dict[str, Any]) -> dict[str, Any]: return {**(base_options or {}), **extra} @@ -1565,18 +1572,30 @@ async def assert_condition_options_supported( # Behavior for behavior in ("any", "all"): await _validate_condition_options( - hass, condition, _merge({"behavior": behavior}), valid=supports_behavior + hass, + condition, + _merge({"behavior": behavior}), + valid=supports_behavior, + supports_target=supports_target, ) # Duration for for_value in ({"seconds": 5}, "00:00:05", 5): await _validate_condition_options( - hass, condition, _merge({"for": for_value}), valid=supports_duration + hass, + condition, + _merge({"for": for_value}), + valid=supports_duration, + supports_target=supports_target, ) # Unknown option should always be rejected await _validate_condition_options( - hass, condition, _merge({"unknown_option": True}), valid=False + hass, + condition, + _merge({"unknown_option": True}), + valid=False, + supports_target=supports_target, ) diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index 4a8d7408f216..d2b944b8862b 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -8,6 +8,7 @@ import pytest import voluptuous as vol from homeassistant.components.zone import condition as zone_condition +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv @@ -235,10 +236,18 @@ TARGET_ZONE = ZONE_HOME @pytest.mark.parametrize( - ("condition_key", "base_options", "supports_behavior", "supports_duration"), + ( + "condition_key", + "base_options", + "supports_behavior", + "supports_duration", + "supports_target", + ), [ - ("zone.in_zone", {"zone": TARGET_ZONE}, True, True), - ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True), + ("zone.in_zone", {"zone": TARGET_ZONE}, True, True, True), + ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True, True), + ("zone.occupancy_is_detected", {"zone": ZONE_HOME}, False, True, False), + ("zone.occupancy_is_not_detected", {"zone": ZONE_HOME}, False, True, False), ], ) async def test_zone_condition_options_validation( @@ -247,6 +256,7 @@ async def test_zone_condition_options_validation( base_options: dict[str, Any] | None, supports_behavior: bool, supports_duration: bool, + supports_target: bool, ) -> None: """Test that zone conditions support the expected options.""" await assert_condition_options_supported( @@ -255,22 +265,39 @@ async def test_zone_condition_options_validation( base_options, supports_behavior=supports_behavior, supports_duration=supports_duration, + supports_target=supports_target, ) -@pytest.mark.parametrize("condition_key", ["zone.in_zone", "zone.not_in_zone"]) +@pytest.mark.parametrize( + ("condition_key", "config"), + [ + ( + "zone.in_zone", + {"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}}, + ), + ( + "zone.not_in_zone", + {"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}}, + ), + ( + "zone.occupancy_is_detected", + {"options": {"zone": "light.x"}}, + ), + ( + "zone.occupancy_is_not_detected", + {"options": {"zone": "light.x"}}, + ), + ], +) async def test_zone_condition_rejects_non_zone_entity_id( - hass: HomeAssistant, condition_key: str + hass: HomeAssistant, condition_key: str, config: dict[str, Any] ) -> None: """Test that the zone option must reference entities in the zone domain.""" with pytest.raises(vol.Invalid): await condition.async_validate_condition_config( hass, - { - "condition": condition_key, - "target": {"entity_id": "person.alice"}, - "options": {"zone": "person.alice"}, - }, + {"condition": condition_key, **config}, ) @@ -461,3 +488,58 @@ async def test_in_zone_condition_for_attribute_only_change( # After the duration elapses, the condition is satisfied. freezer.tick(timedelta(minutes=6)) assert test.async_check() is True + + +# --- Zone occupancy condition tests --- + + +@pytest.mark.parametrize( + ("condition_key", "zone_state", "expected"), + [ + # occupancy_is_detected — true when count >= 1 + pytest.param("zone.occupancy_is_detected", "1", True, id="detected_1"), + pytest.param("zone.occupancy_is_detected", "3", True, id="detected_3"), + pytest.param("zone.occupancy_is_detected", "0", False, id="detected_0"), + pytest.param( + "zone.occupancy_is_detected", + STATE_UNAVAILABLE, + False, + id="detected_unavailable", + ), + pytest.param( + "zone.occupancy_is_detected", STATE_UNKNOWN, False, id="detected_unknown" + ), + # occupancy_is_not_detected — true only when count == 0 + pytest.param("zone.occupancy_is_not_detected", "0", True, id="empty_0"), + pytest.param("zone.occupancy_is_not_detected", "1", False, id="empty_1"), + pytest.param("zone.occupancy_is_not_detected", "3", False, id="empty_3"), + # Unavailable / unknown are not "empty" — they're indeterminate. + pytest.param( + "zone.occupancy_is_not_detected", + STATE_UNAVAILABLE, + False, + id="empty_unavailable", + ), + pytest.param( + "zone.occupancy_is_not_detected", + STATE_UNKNOWN, + False, + id="empty_unknown", + ), + ], +) +async def test_zone_occupancy_condition_evaluates( + hass: HomeAssistant, + condition_key: str, + zone_state: str, + expected: bool, +) -> None: + """Test occupancy conditions evaluate against the zone's integer state.""" + hass.states.async_set(ZONE_HOME, zone_state) + await hass.async_block_till_done() + + config = await condition.async_validate_condition_config( + hass, {"condition": condition_key, "options": {"zone": ZONE_HOME}} + ) + test = await condition.async_from_config(hass, config) + assert test.async_check() is expected