From 2ce43eb082e45d339b69e1bfc719541e69fb0b51 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:53:25 +0000 Subject: [PATCH 1/6] Base IR platform --- tests/test_platform_infrared.py | 237 ++++++++++++++++++ zha/application/__init__.py | 1 + zha/application/discovery.py | 2 + .../platforms/infrared/__init__.py | 123 +++++++++ zha/application/platforms/infrared/const.py | 10 + 5 files changed, 373 insertions(+) create mode 100644 tests/test_platform_infrared.py create mode 100644 zha/application/platforms/infrared/__init__.py create mode 100644 zha/application/platforms/infrared/const.py diff --git a/tests/test_platform_infrared.py b/tests/test_platform_infrared.py new file mode 100644 index 000000000..26806f727 --- /dev/null +++ b/tests/test_platform_infrared.py @@ -0,0 +1,237 @@ +"""Test the ZHA infrared platform.""" + +from typing import Any + +import pytest +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from tests.common import ( + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, + create_mock_zigpy_device, + join_zigpy_device, +) +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import EntityStateChangedEvent +from zha.application.platforms.infrared import ( + BaseInfraredEmitter, + BaseInfraredReceiver, + EntityInfraredSignalReceivedEvent, + InfraredReceiverState, + InfraredSignal, +) +from zha.application.platforms.infrared.const import InfraredDeviceClass +from zha.zigbee.device import Device + + +class FakeEmitter(BaseInfraredEmitter): + """Emitter that records what it was asked to transmit.""" + + _unique_id_suffix = "fake_emitter" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the fake emitter.""" + super().__init__(*args, **kwargs) + self.sent: list[InfraredSignal] = [] + + async def async_send_command(self, signal: InfraredSignal) -> None: + """Record the signal instead of transmitting it.""" + self.sent.append(signal) + + +class FakeReceiver(BaseInfraredReceiver): + """Receiver that records the receive mode requests it was given.""" + + _unique_id_suffix = "fake_receiver" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the fake receiver.""" + super().__init__(*args, **kwargs) + self.requests: list[str] = [] + + async def _async_start_receiving(self) -> None: + """Record the request to enter receive mode.""" + self.requests.append("start") + + async def _async_stop_receiving(self) -> None: + """Record the request to leave receive mode.""" + self.requests.append("stop") + + def receive(self, signal: InfraredSignal) -> None: + """Capture a signal, as a concrete subclass would.""" + self._handle_received_signal(signal) + + def time_out(self) -> None: + """Report the device's own receive timeout lapsing.""" + self._handle_receive_mode_ended() + + +class FakeOneShotReceiver(FakeReceiver): + """Receiver whose device leaves receive mode as soon as it captures a signal.""" + + def receive(self, signal: InfraredSignal) -> None: + """Capture a signal and leave receive mode.""" + super().receive(signal) + self._handle_receive_mode_ended() + + +@pytest.fixture +async def zha_device(zha_gateway: Gateway) -> Device: + """Return a joined device to attach infrared entities to.""" + zigpy_device = create_mock_zigpy_device( + zha_gateway, + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zha.DeviceType.REMOTE_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + return await join_zigpy_device(zha_gateway, zigpy_device) + + +def create_infrared_entity[T: BaseInfraredEmitter | BaseInfraredReceiver]( + zha_device: Device, entity_class: type[T] +) -> T: + """Create an infrared entity on the first endpoint of a device.""" + endpoint = zha_device.endpoints[1] + + return entity_class( + endpoint=endpoint, + device=zha_device, + cluster=endpoint.zigpy_endpoint.on_off, + ) + + +@pytest.fixture +def emitter(zha_device: Device) -> FakeEmitter: + """Return an infrared emitter entity for a joined device.""" + return create_infrared_entity(zha_device, FakeEmitter) + + +@pytest.fixture +def receiver(zha_device: Device) -> FakeReceiver: + """Return an infrared receiver entity for a joined device.""" + return create_infrared_entity(zha_device, FakeReceiver) + + +async def test_emitter(emitter: FakeEmitter) -> None: + """Test that an emitter transmits raw signals.""" + assert emitter.PLATFORM == Platform.INFRARED + assert emitter.device_class == InfraredDeviceClass.EMITTER + + signal = InfraredSignal(timings=[9000, -4500, 560, -1690], modulation=38000) + await emitter.async_send_command(signal) + assert emitter.sent == [signal] + + +async def test_receiver_state(receiver: FakeReceiver) -> None: + """Test the state of a receiver entity.""" + assert receiver.PLATFORM == Platform.INFRARED + assert receiver.device_class == InfraredDeviceClass.RECEIVER + + state = receiver.state + assert isinstance(state, InfraredReceiverState) + assert state.receiving is False + + +async def test_start_and_stop_receiving(receiver: FakeReceiver) -> None: + """Test entering and leaving receive mode.""" + state_changes: list[EntityStateChangedEvent] = [] + receiver.subscribe_state(state_changes.append) + assert len(state_changes) == 1 + + await receiver.async_start_receiving() + assert receiver.requests == ["start"] + assert receiver.receiving is True + assert state_changes[-1].state_diff == {"receiving": True} + + await receiver.async_stop_receiving() + assert receiver.requests == ["start", "stop"] + assert receiver.receiving is False + assert state_changes[-1].state_diff == {"receiving": False} + + +async def test_stop_receiving_when_idle(receiver: FakeReceiver) -> None: + """Test that stopping when not receiving does not reach the device.""" + await receiver.async_stop_receiving() + assert receiver.requests == [] + assert receiver.receiving is False + + +async def test_receive_mode_ended_by_device(receiver: FakeReceiver) -> None: + """Test the device leaving receive mode on its own timeout.""" + await receiver.async_start_receiving() + + receiver.time_out() + assert receiver.receiving is False + + # The device stopped on its own, so it is not asked to + assert receiver.requests == ["start"] + + +async def test_received_signal(receiver: FakeReceiver) -> None: + """Test capturing signals.""" + signals: list[EntityInfraredSignalReceivedEvent] = [] + unsub = receiver.on_event(EntityInfraredSignalReceivedEvent.event, signals.append) + + # Nothing is delivered until a signal is actually captured + assert signals == [] + + await receiver.async_start_receiving() + receiver.receive(InfraredSignal(timings=[9000, -4500], modulation=38000)) + assert signals == [ + EntityInfraredSignalReceivedEvent( + platform=Platform.INFRARED, + unique_id=receiver.unique_id, + device_ieee=receiver.device.ieee, + endpoint_id=1, + signal=InfraredSignal(timings=[9000, -4500], modulation=38000), + ) + ] + + # An identical signal is still delivered + receiver.receive(InfraredSignal(timings=[9000, -4500], modulation=38000)) + assert len(signals) == 2 + assert signals[0] == signals[1] + + # Modulation is optional: devices need not report a carrier + receiver.receive(InfraredSignal(timings=[9000, -4500])) + assert signals[-1].signal == InfraredSignal(timings=[9000, -4500]) + + unsub() + receiver.receive(InfraredSignal(timings=[9000, -4500])) + assert len(signals) == 3 + + +async def test_capturing_signal_does_not_change_state(receiver: FakeReceiver) -> None: + """Test that capturing a signal is not a state change.""" + await receiver.async_start_receiving() + + state_changes: list[EntityStateChangedEvent] = [] + receiver.subscribe_state(state_changes.append) + assert len(state_changes) == 1 + + receiver.receive(InfraredSignal(timings=[9000, -4500])) + assert len(state_changes) == 1 + + +async def test_receive_mode_ends_on_capture(zha_device: Device) -> None: + """Test a device that leaves receive mode once it captures a signal.""" + receiver = create_infrared_entity(zha_device, FakeOneShotReceiver) + + signals: list[EntityInfraredSignalReceivedEvent] = [] + receiver.on_event(EntityInfraredSignalReceivedEvent.event, signals.append) + + await receiver.async_start_receiving() + receiver.receive(InfraredSignal(timings=[9000, -4500])) + + assert len(signals) == 1 + assert receiver.receiving is False + assert receiver.requests == ["start"] diff --git a/zha/application/__init__.py b/zha/application/__init__.py index d8c01161c..6c82ef8ef 100644 --- a/zha/application/__init__.py +++ b/zha/application/__init__.py @@ -14,6 +14,7 @@ class Platform(StrEnum): DEVICE_TRACKER = "device_tracker" EVENT = "event" FAN = "fan" + INFRARED = "infrared" LIGHT = "light" LOCK = "lock" NUMBER = "number" diff --git a/zha/application/discovery.py b/zha/application/discovery.py index c5ce8bc8f..c98f9f1af 100644 --- a/zha/application/discovery.py +++ b/zha/application/discovery.py @@ -28,6 +28,7 @@ cover, device_tracker, fan, + infrared, light, lock, number, @@ -54,6 +55,7 @@ Platform.COVER, Platform.DEVICE_TRACKER, Platform.FAN, + Platform.INFRARED, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, diff --git a/zha/application/platforms/infrared/__init__.py b/zha/application/platforms/infrared/__init__.py new file mode 100644 index 000000000..d8b95beaf --- /dev/null +++ b/zha/application/platforms/infrared/__init__.py @@ -0,0 +1,123 @@ +"""Infrared emitters and receivers on Zigbee Home Automation networks.""" + +from __future__ import annotations + +from abc import abstractmethod +import dataclasses +from typing import TYPE_CHECKING, Any, Final + +from zigpy.types.named import EUI64 + +from zha.application import Platform +from zha.application.platforms import BaseEntityState, PlatformEntity +from zha.application.platforms.infrared.const import InfraredDeviceClass + +if TYPE_CHECKING: + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class InfraredSignal: + """A raw infrared signal, sent by an emitter or captured by a receiver.""" + + timings: list[int] + modulation: int + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class InfraredReceiverState(BaseEntityState): + """State for infrared receiver entities.""" + + receiving: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EntityInfraredSignalReceivedEvent: + """Event for when an infrared receiver captures a signal.""" + + event_type: Final[str] = "entity" + event: Final[str] = "infrared_signal_received" + platform: str + unique_id: str + device_ieee: EUI64 | None = None + endpoint_id: int | None = None + group_id: int | None = None + signal: InfraredSignal + + +class BaseInfraredEmitter(PlatformEntity): + """Base representation of a ZHA infrared emitter entity.""" + + PLATFORM = Platform.INFRARED + + _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER + + @abstractmethod + async def async_send_command(self, signal: InfraredSignal) -> None: + """Transmit an infrared signal.""" + + +class BaseInfraredReceiver(PlatformEntity): + """Base representation of a ZHA infrared receiver entity.""" + + PLATFORM = Platform.INFRARED + + _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER + + def __init__(self, endpoint: Endpoint, device: Device, **kwargs: Any) -> None: + """Initialize the infrared receiver entity.""" + super().__init__(endpoint=endpoint, device=device, **kwargs) + + self._receiving = False + + @property + def receiving(self) -> bool: + """Return whether the device is currently in receive mode.""" + return self._receiving + + @property + def state(self) -> InfraredReceiverState: + """Return the state of the infrared receiver entity.""" + return InfraredReceiverState( + **super().state.__dict__, + receiving=self.receiving, + ) + + async def async_start_receiving(self) -> None: + """Put the device into receive mode.""" + await self._async_start_receiving() + self._receiving = True + self.maybe_emit_state_changed_event() + + async def async_stop_receiving(self) -> None: + """Take the device out of receive mode.""" + if not self._receiving: + return + + await self._async_stop_receiving() + self._receiving = False + self.maybe_emit_state_changed_event() + + def _handle_receive_mode_ended(self) -> None: + """Handle the device leaving receive mode, to be called by subclasses.""" + self._receiving = False + self.maybe_emit_state_changed_event() + + def _handle_received_signal(self, signal: InfraredSignal) -> None: + """Handle a captured signal, to be called by subclasses.""" + self.emit( + EntityInfraredSignalReceivedEvent.event, + EntityInfraredSignalReceivedEvent( + **self.identifiers.__dict__, + signal=signal, + ), + ) + + @abstractmethod + async def _async_start_receiving(self) -> None: + """Ask the device to enter receive mode.""" + + @abstractmethod + async def _async_stop_receiving(self) -> None: + """Ask the device to leave receive mode.""" diff --git a/zha/application/platforms/infrared/const.py b/zha/application/platforms/infrared/const.py new file mode 100644 index 000000000..f26e09462 --- /dev/null +++ b/zha/application/platforms/infrared/const.py @@ -0,0 +1,10 @@ +"""Constants for the infrared platform.""" + +from enum import StrEnum + + +class InfraredDeviceClass(StrEnum): + """Device class for infrared entities.""" + + EMITTER = "emitter" + RECEIVER = "receiver" From 85c9482b6f8064962fa5cb9c60483d0a263cc62f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:46:55 +0000 Subject: [PATCH 2/6] Add `repeat_count` --- tests/test_platform_infrared.py | 5 +++++ zha/application/platforms/infrared/__init__.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_platform_infrared.py b/tests/test_platform_infrared.py index 26806f727..da1a4d9e7 100644 --- a/tests/test_platform_infrared.py +++ b/tests/test_platform_infrared.py @@ -129,6 +129,11 @@ async def test_emitter(emitter: FakeEmitter) -> None: signal = InfraredSignal(timings=[9000, -4500, 560, -1690], modulation=38000) await emitter.async_send_command(signal) assert emitter.sent == [signal] + assert signal.repeat_count == 0 + + repeated = InfraredSignal(timings=[9000, -4500], modulation=38000, repeat_count=3) + await emitter.async_send_command(repeated) + assert emitter.sent == [signal, repeated] async def test_receiver_state(receiver: FakeReceiver) -> None: diff --git a/zha/application/platforms/infrared/__init__.py b/zha/application/platforms/infrared/__init__.py index d8b95beaf..db5c7ab56 100644 --- a/zha/application/platforms/infrared/__init__.py +++ b/zha/application/platforms/infrared/__init__.py @@ -22,7 +22,8 @@ class InfraredSignal: """A raw infrared signal, sent by an emitter or captured by a receiver.""" timings: list[int] - modulation: int + modulation: int | None = None + repeat_count: int = 0 @dataclasses.dataclass(frozen=True, kw_only=True) From 4e8bf93f551509fb497fa6bdfd2aa1f872ea0a6c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:50:04 +0000 Subject: [PATCH 3/6] Fix tests --- tests/test_platform_infrared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_platform_infrared.py b/tests/test_platform_infrared.py index da1a4d9e7..3aaf8a575 100644 --- a/tests/test_platform_infrared.py +++ b/tests/test_platform_infrared.py @@ -105,7 +105,7 @@ def create_infrared_entity[T: BaseInfraredEmitter | BaseInfraredReceiver]( return entity_class( endpoint=endpoint, device=zha_device, - cluster=endpoint.zigpy_endpoint.on_off, + cluster=endpoint.zigpy_endpoint.out_clusters[general.OnOff.cluster_id], ) From 6a27ec7bdf043e5f853c7bea83e710bb53f13116 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:55:02 +0000 Subject: [PATCH 4/6] Drop `_async_start_receiving` and `_async_stop_receiving` as the public API --- tests/test_platform_infrared.py | 20 +++++------ .../platforms/infrared/__init__.py | 36 +++---------------- 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/tests/test_platform_infrared.py b/tests/test_platform_infrared.py index 3aaf8a575..b988813bf 100644 --- a/tests/test_platform_infrared.py +++ b/tests/test_platform_infrared.py @@ -53,13 +53,17 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.requests: list[str] = [] - async def _async_start_receiving(self) -> None: + async def async_start_receiving(self) -> None: """Record the request to enter receive mode.""" self.requests.append("start") + self._receiving = True + self.maybe_emit_state_changed_event() - async def _async_stop_receiving(self) -> None: + async def async_stop_receiving(self) -> None: """Record the request to leave receive mode.""" self.requests.append("stop") + self._receiving = False + self.maybe_emit_state_changed_event() def receive(self, signal: InfraredSignal) -> None: """Capture a signal, as a concrete subclass would.""" @@ -67,7 +71,8 @@ def receive(self, signal: InfraredSignal) -> None: def time_out(self) -> None: """Report the device's own receive timeout lapsing.""" - self._handle_receive_mode_ended() + self._receiving = False + self.maybe_emit_state_changed_event() class FakeOneShotReceiver(FakeReceiver): @@ -76,7 +81,7 @@ class FakeOneShotReceiver(FakeReceiver): def receive(self, signal: InfraredSignal) -> None: """Capture a signal and leave receive mode.""" super().receive(signal) - self._handle_receive_mode_ended() + self.time_out() @pytest.fixture @@ -163,13 +168,6 @@ async def test_start_and_stop_receiving(receiver: FakeReceiver) -> None: assert state_changes[-1].state_diff == {"receiving": False} -async def test_stop_receiving_when_idle(receiver: FakeReceiver) -> None: - """Test that stopping when not receiving does not reach the device.""" - await receiver.async_stop_receiving() - assert receiver.requests == [] - assert receiver.receiving is False - - async def test_receive_mode_ended_by_device(receiver: FakeReceiver) -> None: """Test the device leaving receive mode on its own timeout.""" await receiver.async_start_receiving() diff --git a/zha/application/platforms/infrared/__init__.py b/zha/application/platforms/infrared/__init__.py index db5c7ab56..07a27240d 100644 --- a/zha/application/platforms/infrared/__init__.py +++ b/zha/application/platforms/infrared/__init__.py @@ -4,7 +4,7 @@ from abc import abstractmethod import dataclasses -from typing import TYPE_CHECKING, Any, Final +from typing import Final from zigpy.types.named import EUI64 @@ -12,10 +12,6 @@ from zha.application.platforms import BaseEntityState, PlatformEntity from zha.application.platforms.infrared.const import InfraredDeviceClass -if TYPE_CHECKING: - from zha.zigbee.device import Device - from zha.zigbee.endpoint import Endpoint - @dataclasses.dataclass(frozen=True, kw_only=True) class InfraredSignal: @@ -66,11 +62,7 @@ class BaseInfraredReceiver(PlatformEntity): _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER - def __init__(self, endpoint: Endpoint, device: Device, **kwargs: Any) -> None: - """Initialize the infrared receiver entity.""" - super().__init__(endpoint=endpoint, device=device, **kwargs) - - self._receiving = False + _receiving: bool = False @property def receiving(self) -> bool: @@ -85,25 +77,13 @@ def state(self) -> InfraredReceiverState: receiving=self.receiving, ) + @abstractmethod async def async_start_receiving(self) -> None: """Put the device into receive mode.""" - await self._async_start_receiving() - self._receiving = True - self.maybe_emit_state_changed_event() + @abstractmethod async def async_stop_receiving(self) -> None: """Take the device out of receive mode.""" - if not self._receiving: - return - - await self._async_stop_receiving() - self._receiving = False - self.maybe_emit_state_changed_event() - - def _handle_receive_mode_ended(self) -> None: - """Handle the device leaving receive mode, to be called by subclasses.""" - self._receiving = False - self.maybe_emit_state_changed_event() def _handle_received_signal(self, signal: InfraredSignal) -> None: """Handle a captured signal, to be called by subclasses.""" @@ -114,11 +94,3 @@ def _handle_received_signal(self, signal: InfraredSignal) -> None: signal=signal, ), ) - - @abstractmethod - async def _async_start_receiving(self) -> None: - """Ask the device to enter receive mode.""" - - @abstractmethod - async def _async_stop_receiving(self) -> None: - """Ask the device to leave receive mode.""" From 88aaf3367adc84792b404ee7b24f785047b2b827 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:01:28 +0000 Subject: [PATCH 5/6] Drop ZHA-specific `async_start_receiving` and `async_stop_receiving` --- tests/test_platform_infrared.py | 87 +------------------ .../platforms/infrared/__init__.py | 32 +------ 2 files changed, 4 insertions(+), 115 deletions(-) diff --git a/tests/test_platform_infrared.py b/tests/test_platform_infrared.py index b988813bf..00ae6989d 100644 --- a/tests/test_platform_infrared.py +++ b/tests/test_platform_infrared.py @@ -21,7 +21,6 @@ BaseInfraredEmitter, BaseInfraredReceiver, EntityInfraredSignalReceivedEvent, - InfraredReceiverState, InfraredSignal, ) from zha.application.platforms.infrared.const import InfraredDeviceClass @@ -44,45 +43,14 @@ async def async_send_command(self, signal: InfraredSignal) -> None: class FakeReceiver(BaseInfraredReceiver): - """Receiver that records the receive mode requests it was given.""" + """Receiver that captures signals on demand.""" _unique_id_suffix = "fake_receiver" - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Initialize the fake receiver.""" - super().__init__(*args, **kwargs) - self.requests: list[str] = [] - - async def async_start_receiving(self) -> None: - """Record the request to enter receive mode.""" - self.requests.append("start") - self._receiving = True - self.maybe_emit_state_changed_event() - - async def async_stop_receiving(self) -> None: - """Record the request to leave receive mode.""" - self.requests.append("stop") - self._receiving = False - self.maybe_emit_state_changed_event() - def receive(self, signal: InfraredSignal) -> None: """Capture a signal, as a concrete subclass would.""" self._handle_received_signal(signal) - def time_out(self) -> None: - """Report the device's own receive timeout lapsing.""" - self._receiving = False - self.maybe_emit_state_changed_event() - - -class FakeOneShotReceiver(FakeReceiver): - """Receiver whose device leaves receive mode as soon as it captures a signal.""" - - def receive(self, signal: InfraredSignal) -> None: - """Capture a signal and leave receive mode.""" - super().receive(signal) - self.time_out() - @pytest.fixture async def zha_device(zha_gateway: Gateway) -> Device: @@ -142,41 +110,10 @@ async def test_emitter(emitter: FakeEmitter) -> None: async def test_receiver_state(receiver: FakeReceiver) -> None: - """Test the state of a receiver entity.""" + """Test that the state of a receiver only describes its capabilities.""" assert receiver.PLATFORM == Platform.INFRARED assert receiver.device_class == InfraredDeviceClass.RECEIVER - - state = receiver.state - assert isinstance(state, InfraredReceiverState) - assert state.receiving is False - - -async def test_start_and_stop_receiving(receiver: FakeReceiver) -> None: - """Test entering and leaving receive mode.""" - state_changes: list[EntityStateChangedEvent] = [] - receiver.subscribe_state(state_changes.append) - assert len(state_changes) == 1 - - await receiver.async_start_receiving() - assert receiver.requests == ["start"] - assert receiver.receiving is True - assert state_changes[-1].state_diff == {"receiving": True} - - await receiver.async_stop_receiving() - assert receiver.requests == ["start", "stop"] - assert receiver.receiving is False - assert state_changes[-1].state_diff == {"receiving": False} - - -async def test_receive_mode_ended_by_device(receiver: FakeReceiver) -> None: - """Test the device leaving receive mode on its own timeout.""" - await receiver.async_start_receiving() - - receiver.time_out() - assert receiver.receiving is False - - # The device stopped on its own, so it is not asked to - assert receiver.requests == ["start"] + assert receiver.state.device_class == "receiver" async def test_received_signal(receiver: FakeReceiver) -> None: @@ -187,7 +124,6 @@ async def test_received_signal(receiver: FakeReceiver) -> None: # Nothing is delivered until a signal is actually captured assert signals == [] - await receiver.async_start_receiving() receiver.receive(InfraredSignal(timings=[9000, -4500], modulation=38000)) assert signals == [ EntityInfraredSignalReceivedEvent( @@ -215,26 +151,9 @@ async def test_received_signal(receiver: FakeReceiver) -> None: async def test_capturing_signal_does_not_change_state(receiver: FakeReceiver) -> None: """Test that capturing a signal is not a state change.""" - await receiver.async_start_receiving() - state_changes: list[EntityStateChangedEvent] = [] receiver.subscribe_state(state_changes.append) assert len(state_changes) == 1 receiver.receive(InfraredSignal(timings=[9000, -4500])) assert len(state_changes) == 1 - - -async def test_receive_mode_ends_on_capture(zha_device: Device) -> None: - """Test a device that leaves receive mode once it captures a signal.""" - receiver = create_infrared_entity(zha_device, FakeOneShotReceiver) - - signals: list[EntityInfraredSignalReceivedEvent] = [] - receiver.on_event(EntityInfraredSignalReceivedEvent.event, signals.append) - - await receiver.async_start_receiving() - receiver.receive(InfraredSignal(timings=[9000, -4500])) - - assert len(signals) == 1 - assert receiver.receiving is False - assert receiver.requests == ["start"] diff --git a/zha/application/platforms/infrared/__init__.py b/zha/application/platforms/infrared/__init__.py index 07a27240d..99743ef44 100644 --- a/zha/application/platforms/infrared/__init__.py +++ b/zha/application/platforms/infrared/__init__.py @@ -9,7 +9,7 @@ from zigpy.types.named import EUI64 from zha.application import Platform -from zha.application.platforms import BaseEntityState, PlatformEntity +from zha.application.platforms import PlatformEntity from zha.application.platforms.infrared.const import InfraredDeviceClass @@ -22,13 +22,6 @@ class InfraredSignal: repeat_count: int = 0 -@dataclasses.dataclass(frozen=True, kw_only=True) -class InfraredReceiverState(BaseEntityState): - """State for infrared receiver entities.""" - - receiving: bool - - @dataclasses.dataclass(frozen=True, kw_only=True) class EntityInfraredSignalReceivedEvent: """Event for when an infrared receiver captures a signal.""" @@ -62,29 +55,6 @@ class BaseInfraredReceiver(PlatformEntity): _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER - _receiving: bool = False - - @property - def receiving(self) -> bool: - """Return whether the device is currently in receive mode.""" - return self._receiving - - @property - def state(self) -> InfraredReceiverState: - """Return the state of the infrared receiver entity.""" - return InfraredReceiverState( - **super().state.__dict__, - receiving=self.receiving, - ) - - @abstractmethod - async def async_start_receiving(self) -> None: - """Put the device into receive mode.""" - - @abstractmethod - async def async_stop_receiving(self) -> None: - """Take the device out of receive mode.""" - def _handle_received_signal(self, signal: InfraredSignal) -> None: """Handle a captured signal, to be called by subclasses.""" self.emit( From 97c390353fd16acd297ff530caabb6aae52dc48f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:28:38 +0000 Subject: [PATCH 6/6] Drop `repeat_count` and address ABC issue --- tests/test_platform_infrared.py | 5 ----- zha/application/platforms/infrared/__init__.py | 7 +++---- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/test_platform_infrared.py b/tests/test_platform_infrared.py index 00ae6989d..ff09feaff 100644 --- a/tests/test_platform_infrared.py +++ b/tests/test_platform_infrared.py @@ -102,11 +102,6 @@ async def test_emitter(emitter: FakeEmitter) -> None: signal = InfraredSignal(timings=[9000, -4500, 560, -1690], modulation=38000) await emitter.async_send_command(signal) assert emitter.sent == [signal] - assert signal.repeat_count == 0 - - repeated = InfraredSignal(timings=[9000, -4500], modulation=38000, repeat_count=3) - await emitter.async_send_command(repeated) - assert emitter.sent == [signal, repeated] async def test_receiver_state(receiver: FakeReceiver) -> None: diff --git a/zha/application/platforms/infrared/__init__.py b/zha/application/platforms/infrared/__init__.py index 99743ef44..25561df22 100644 --- a/zha/application/platforms/infrared/__init__.py +++ b/zha/application/platforms/infrared/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import abstractmethod +from abc import ABC, abstractmethod import dataclasses from typing import Final @@ -19,7 +19,6 @@ class InfraredSignal: timings: list[int] modulation: int | None = None - repeat_count: int = 0 @dataclasses.dataclass(frozen=True, kw_only=True) @@ -36,7 +35,7 @@ class EntityInfraredSignalReceivedEvent: signal: InfraredSignal -class BaseInfraredEmitter(PlatformEntity): +class BaseInfraredEmitter(PlatformEntity, ABC): """Base representation of a ZHA infrared emitter entity.""" PLATFORM = Platform.INFRARED @@ -48,7 +47,7 @@ async def async_send_command(self, signal: InfraredSignal) -> None: """Transmit an infrared signal.""" -class BaseInfraredReceiver(PlatformEntity): +class BaseInfraredReceiver(PlatformEntity, ABC): """Base representation of a ZHA infrared receiver entity.""" PLATFORM = Platform.INFRARED