Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 67 additions & 8 deletions tests/test_platform_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
TriggeredEvent,
)
from zha.application.platforms.event.const import (
ATTR_MULTI_PRESS_COUNT,
ATTR_PRESS_COUNT,
ButtonEventType,
EventDeviceClass,
)
Expand All @@ -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(
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -107,22 +121,22 @@ 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,
unique_id=entity.unique_id,
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]

Expand Down Expand Up @@ -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"]
35 changes: 34 additions & 1 deletion zha/application/platforms/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand All @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions zha/application/platforms/event/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from enum import StrEnum

ATTR_MULTI_PRESS_COUNT = "multi_press_count"
ATTR_PRESS_COUNT = "press_count"


class EventDeviceClass(StrEnum):
Expand All @@ -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"
Loading