diff --git a/tests/test_platform_event.py b/tests/test_platform_event.py index d00b3bb02..895cb8a95 100644 --- a/tests/test_platform_event.py +++ b/tests/test_platform_event.py @@ -24,7 +24,7 @@ TriggeredEvent, ) from zha.application.platforms.event.const import ( - ATTR_MULTI_PRESS_COUNT, + ATTR_PRESS_COUNT, ButtonEventType, EventDeviceClass, ) @@ -38,7 +38,8 @@ class FakeEvent(BaseEvent, ZclPlatformEntity): _attr_device_class = EventDeviceClass.BUTTON _attr_event_types = [ ButtonEventType.PRESS_END, - ButtonEventType.MULTI_PRESS_END, + ButtonEventType.SINGLE_PRESS, + ButtonEventType.MULTI_PRESS, ] def trigger( @@ -54,6 +55,19 @@ class FakeDoorbellEvent(FakeEvent): _attr_device_class = EventDeviceClass.DOORBELL +class FakeMultiPressOnlyEvent(FakeEvent): + """Button event entity with a multi press but no single press.""" + + _attr_event_types = [ButtonEventType.PRESS_END, ButtonEventType.MULTI_PRESS] + + +class FakeMultiPressExtraEvent(FakeEvent): + """Event entity without a device class using `multi_press` as its own type.""" + + _attr_device_class = None + _attr_event_types = [ButtonEventType.MULTI_PRESS] + + @pytest.fixture async def zha_device(zha_gateway: Gateway) -> Device: """Return a joined device to attach event entities to.""" @@ -92,11 +106,11 @@ async def test_event_state(entity: FakeEvent) -> None: """Test that the state of an event entity only describes its capabilities.""" assert entity.PLATFORM == Platform.EVENT assert entity.device_class == EventDeviceClass.BUTTON - assert entity.event_types == ["press_end", "multi_press_end"] + assert entity.event_types == ["press_end", "single_press", "multi_press"] state = entity.state assert isinstance(state, EventState) - assert state.event_types == ["press_end", "multi_press_end"] + assert state.event_types == ["press_end", "single_press", "multi_press"] async def test_trigger_event(entity: FakeEvent) -> None: @@ -107,7 +121,7 @@ async def test_trigger_event(entity: FakeEvent) -> None: # Nothing is delivered until an event is actually triggered assert events == [] - entity.trigger(ButtonEventType.MULTI_PRESS_END, {ATTR_MULTI_PRESS_COUNT: 2}) + entity.trigger(ButtonEventType.MULTI_PRESS, {ATTR_PRESS_COUNT: 2}) assert events == [ EntityEventTriggeredEvent( platform=Platform.EVENT, @@ -115,14 +129,14 @@ async def test_trigger_event(entity: FakeEvent) -> None: device_ieee=entity.device.ieee, endpoint_id=1, triggered=TriggeredEvent( - event_type="multi_press_end", - event_attributes={"multi_press_count": 2}, + event_type="multi_press", + event_attributes={"press_count": 2}, ), ) ] # An event identical to the previous one is still delivered - entity.trigger(ButtonEventType.MULTI_PRESS_END, {ATTR_MULTI_PRESS_COUNT: 2}) + entity.trigger(ButtonEventType.MULTI_PRESS, {ATTR_PRESS_COUNT: 2}) assert len(events) == 2 assert events[0] == events[1] @@ -162,3 +176,48 @@ async def test_trigger_unsupported_event(entity: FakeEvent) -> None: entity.trigger(ButtonEventType.LONG_PRESS_END) assert events == [] + + +async def test_multi_press_requires_single_press(zha_device: Device) -> None: + """Test that a button only declares `multi_press` together with `single_press`.""" + with pytest.raises( + ValueError, + match="supports the 'multi_press' event type without 'single_press'", + ): + create_event_entity(zha_device, FakeMultiPressOnlyEvent) + + +@pytest.mark.parametrize( + "event_attributes", + [ + None, + {}, + {ATTR_PRESS_COUNT: 1}, + {ATTR_PRESS_COUNT: 0}, + {ATTR_PRESS_COUNT: "2"}, + {ATTR_PRESS_COUNT: True}, + {ATTR_PRESS_COUNT: 2.0}, + ], +) +async def test_multi_press_requires_press_count( + entity: FakeEvent, event_attributes: dict[str, Any] | None +) -> None: + """Test that a button `multi_press` carries a press count of at least 2.""" + events: list[EntityEventTriggeredEvent] = [] + entity.on_event(EntityEventTriggeredEvent.event, events.append) + + with pytest.raises(ValueError, match="Invalid press_count"): + entity.trigger(ButtonEventType.MULTI_PRESS, event_attributes) + + assert events == [] + + +async def test_multi_press_rules_only_apply_to_buttons(zha_device: Device) -> None: + """Test that the button rules leave other event entities alone.""" + entity = create_event_entity(zha_device, FakeMultiPressExtraEvent) + + events: list[EntityEventTriggeredEvent] = [] + entity.on_event(EntityEventTriggeredEvent.event, events.append) + entity.trigger(ButtonEventType.MULTI_PRESS) + + assert [event.triggered.event_type for event in events] == ["multi_press"] diff --git a/zha/application/platforms/event/__init__.py b/zha/application/platforms/event/__init__.py index 265ed8a9f..3e673d8eb 100644 --- a/zha/application/platforms/event/__init__.py +++ b/zha/application/platforms/event/__init__.py @@ -9,7 +9,12 @@ from zha.application import Platform from zha.application.platforms import BaseEntityState, PlatformEntity -from zha.application.platforms.event.const import DoorbellEventType, EventDeviceClass +from zha.application.platforms.event.const import ( + ATTR_PRESS_COUNT, + ButtonEventType, + DoorbellEventType, + EventDeviceClass, +) @dataclasses.dataclass(frozen=True, kw_only=True) @@ -63,6 +68,18 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: f" '{DoorbellEventType.RING}' event type" ) + # `multi_press` keeps `button.pressed` exclusive only next to `single_press` + if ( + self.device_class == EventDeviceClass.BUTTON + and ButtonEventType.MULTI_PRESS in self.event_types + and ButtonEventType.SINGLE_PRESS not in self.event_types + ): + raise ValueError( + f"Button event entity {self.unique_id} supports the" + f" '{ButtonEventType.MULTI_PRESS}' event type without" + f" '{ButtonEventType.SINGLE_PRESS}'" + ) + @property def event_types(self) -> list[str]: """Return the event types this entity can trigger.""" @@ -80,6 +97,22 @@ def _trigger_event( if event_type not in self.event_types: raise ValueError(f"Invalid event type {event_type} for {self.unique_id}") + if ( + self.device_class == EventDeviceClass.BUTTON + and event_type == ButtonEventType.MULTI_PRESS + ): + press_count = (event_attributes or {}).get(ATTR_PRESS_COUNT) + if ( + not isinstance(press_count, int) + or isinstance(press_count, bool) + or press_count < 2 + ): + raise ValueError( + f"Invalid {ATTR_PRESS_COUNT} {press_count!r} for" + f" '{ButtonEventType.MULTI_PRESS}' on {self.unique_id}:" + " it must be an integer of at least 2" + ) + self.emit( EntityEventTriggeredEvent.event, EntityEventTriggeredEvent( diff --git a/zha/application/platforms/event/const.py b/zha/application/platforms/event/const.py index a9c6f8e12..a42238bfb 100644 --- a/zha/application/platforms/event/const.py +++ b/zha/application/platforms/event/const.py @@ -2,7 +2,7 @@ from enum import StrEnum -ATTR_MULTI_PRESS_COUNT = "multi_press_count" +ATTR_PRESS_COUNT = "press_count" class EventDeviceClass(StrEnum): @@ -20,11 +20,18 @@ class DoorbellEventType(StrEnum): class ButtonEventType(StrEnum): - """Standard event types for the button device class.""" + """Standard event types for the button device class. + None of these are mandatory: an entity declares only the types whose meaning + holds for every event the device sends under that name. + """ + + # Physical: the edges of the button PRESS_START = "press_start" PRESS_END = "press_end" + + # Gesture: what the device concluded LONG_PRESS_START = "long_press_start" LONG_PRESS_END = "long_press_end" - MULTI_PRESS_ONGOING = "multi_press_ongoing" - MULTI_PRESS_END = "multi_press_end" + SINGLE_PRESS = "single_press" + MULTI_PRESS = "multi_press"