From f9bef804b1d75bd521abe2ec63d7e0051fd29c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 25 May 2026 19:42:34 +0100 Subject: [PATCH] Add infrared receiver support to ESPHome (#171789) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/esphome/entity.py | 9 +- homeassistant/components/esphome/infrared.py | 99 ++++++++++++-- tests/components/esphome/test_infrared.py | 136 ++++++++++++++++--- 3 files changed, 209 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 572213300e57..20d44aa7cbf7 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -53,7 +53,7 @@ def async_static_info_updated( platform: entity_platform.EntityPlatform, async_add_entities: AddEntitiesCallback, info_type: type[_InfoT], - entity_type: type[_EntityT], + entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT], state_type: type[_StateT], infos: list[EntityInfo], ) -> None: @@ -188,7 +188,7 @@ async def platform_async_setup_entry( async_add_entities: AddEntitiesCallback, *, info_type: type[_InfoT], - entity_type: type[_EntityT], + entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT], state_type: type[_StateT], info_filter: Callable[[_InfoT], bool] | None = None, ) -> None: @@ -196,6 +196,11 @@ async def platform_async_setup_entry( This method is in charge of receiving, distributing and storing info and state updates. + + `entity_type` is any callable that builds an entity from + `(entry_data, info, state_type)`. A regular entity class satisfies this, + and platforms with multiple entity classes can pass a factory function + that picks the class per static info. """ entry_data = entry.runtime_data entry_data.info[info_type] = {} diff --git a/homeassistant/components/esphome/infrared.py b/homeassistant/components/esphome/infrared.py index 27e557926c6d..6221058752dc 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -1,28 +1,34 @@ """Infrared platform for ESPHome.""" -from functools import partial +import functools import logging +from typing import TYPE_CHECKING -from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo +from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo +from aioesphomeapi.client import InfraredRFReceiveEventModel -from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity -from homeassistant.core import callback +from homeassistant.components.infrared import ( + InfraredCommand, + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) +from homeassistant.core import CALLBACK_TYPE, callback from .entity import ( EsphomeEntity, convert_api_error_ha_error, platform_async_setup_entry, ) +from .entry_data import RuntimeEntryData _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -class EsphomeInfraredEntity( - EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity -): - """ESPHome infrared emitter entity using native API.""" +class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]): + """Common base for ESPHome infrared entities.""" @callback def _on_device_update(self) -> None: @@ -32,6 +38,10 @@ class EsphomeInfraredEntity( # Infrared entities should go available as soon as the device comes online self.async_write_ha_state() + +class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity): + """ESPHome infrared emitter entity using native API.""" + @convert_api_error_ha_error async def async_send_command(self, command: InfraredCommand) -> None: """Send an IR command.""" @@ -46,10 +56,77 @@ class EsphomeInfraredEntity( ) -async_setup_entry = partial( +class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity): + """ESPHome infrared receiver entity using native API.""" + + _unsub_receive: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Register callbacks including IR receive subscription.""" + await super().async_added_to_hass() + self._async_subscribe_receive() + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from the device on entity removal.""" + await super().async_will_remove_from_hass() + if self._unsub_receive is not None: + self._unsub_receive() + self._unsub_receive = None + + @callback + def _async_subscribe_receive(self) -> None: + """Subscribe to IR receive events if the device is connected.""" + # Subscribing requires an active API connection; defer to + # _on_device_update when the device is not (yet) available. + if self._unsub_receive is not None or not self._entry_data.available: + return + self._unsub_receive = self._client.subscribe_infrared_rf_receive( + self._on_infrared_rf_receive + ) + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + self._async_subscribe_receive() + elif self._unsub_receive is not None: + self._unsub_receive = None + + @callback + def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None: + """Handle a received IR signal from the device.""" + if ( + event.key != self._static_info.key + or event.device_id != self._static_info.device_id + ): + return + self._handle_received_signal(InfraredReceivedSignal(timings=event.timings)) + + +def _make_infrared_entity( + entry_data: RuntimeEntryData, + info: EntityInfo, + state_type: type[EntityState], +) -> _EsphomeInfraredEntity: + """Build the right infrared entity based on the InfraredInfo capabilities.""" + if TYPE_CHECKING: + assert isinstance(info, InfraredInfo) + cls = ( + EsphomeInfraredReceiverEntity + if info.capabilities & InfraredCapability.RECEIVER + else EsphomeInfraredEmitterEntity + ) + return cls(entry_data, info, state_type) + + +async_setup_entry = functools.partial( platform_async_setup_entry, info_type=InfraredInfo, - entity_type=EsphomeInfraredEntity, + entity_type=_make_infrared_entity, state_type=EntityState, - info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER), + info_filter=lambda info: bool( + info.capabilities + & (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER) + ), ) diff --git a/tests/components/esphome/test_infrared.py b/tests/components/esphome/test_infrared.py index 86afad26bc20..507707c8d7a0 100644 --- a/tests/components/esphome/test_infrared.py +++ b/tests/components/esphome/test_infrared.py @@ -6,10 +6,17 @@ from aioesphomeapi import ( InfraredCapability, InfraredInfo, ) +from aioesphomeapi.client import InfraredRFReceiveEventModel from infrared_protocols.commands.nec import NECCommand import pytest from homeassistant.components import infrared +from homeassistant.components.infrared import ( + DATA_COMPONENT, + InfraredDeviceClass, + InfraredReceivedSignal, + InfraredReceiverEntity, +) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -33,32 +40,47 @@ async def _mock_ir_device( @pytest.mark.parametrize( - ("capabilities", "entity_created"), + ("capabilities", "expected_device_class", "emitter_count", "receiver_count"), [ - (InfraredCapability.TRANSMITTER, True), - (InfraredCapability.RECEIVER, False), - (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True), - (InfraredCapability(0), False), + pytest.param( + InfraredCapability.TRANSMITTER, + InfraredDeviceClass.EMITTER, + 1, + 0, + id="transmitter", + ), + pytest.param( + InfraredCapability.RECEIVER, + InfraredDeviceClass.RECEIVER, + 0, + 1, + id="receiver", + ), ], ) -async def test_infrared_entity_transmitter( +async def test_infrared_entity_single_capability( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, capabilities: InfraredCapability, - entity_created: bool, + expected_device_class: InfraredDeviceClass, + emitter_count: int, + receiver_count: int, ) -> None: - """Test infrared entity with transmitter capability is created.""" + """Test infrared entity is created with the right device class per capability.""" await _mock_ir_device(mock_esphome_device, mock_client, capabilities) state = hass.states.get(ENTITY_ID) - assert (state is not None) == entity_created + assert (state is not None) == (expected_device_class is not None) + assert state.attributes["device_class"] == expected_device_class emitters = infrared.async_get_emitters(hass) - assert (len(emitters) == 1) == entity_created + assert len(emitters) == emitter_count + receivers = infrared.async_get_receivers(hass) + assert len(receivers) == receiver_count -async def test_infrared_multiple_entities_mixed_capabilities( +async def test_infrared_entity_dual_capability( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, @@ -77,12 +99,6 @@ async def test_infrared_multiple_entities_mixed_capabilities( name="IR Receiver", capabilities=InfraredCapability.RECEIVER, ), - InfraredInfo( - object_id="ir_transceiver", - key=3, - name="IR Transceiver", - capabilities=InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, - ), ] await mock_esphome_device( mock_client=mock_client, @@ -90,13 +106,18 @@ async def test_infrared_multiple_entities_mixed_capabilities( states=[], ) - # Only transmitter and transceiver should be created - assert hass.states.get("infrared.test_ir_transmitter") is not None - assert hass.states.get("infrared.test_ir_receiver") is None - assert hass.states.get("infrared.test_ir_transceiver") is not None + transmitter_state = hass.states.get("infrared.test_ir_transmitter") + assert transmitter_state is not None + assert transmitter_state.attributes["device_class"] == InfraredDeviceClass.EMITTER + + receiver_state = hass.states.get("infrared.test_ir_receiver") + assert receiver_state is not None + assert receiver_state.attributes["device_class"] == InfraredDeviceClass.RECEIVER emitters = infrared.async_get_emitters(hass) - assert len(emitters) == 2 + assert len(emitters) == 1 + receivers = infrared.async_get_receivers(hass) + assert len(receivers) == 1 async def test_infrared_send_command_success( @@ -146,6 +167,77 @@ async def test_infrared_send_command_failure( assert exc_info.value.translation_key == "error_communicating_with_device" +async def test_infrared_receiver_signal_dispatched( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test receiver subscribes to events and dispatches received signals.""" + await _mock_ir_device( + mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER + ) + + mock_client.subscribe_infrared_rf_receive.assert_called_once() + on_event = mock_client.subscribe_infrared_rf_receive.call_args[0][0] + + receiver = hass.data[DATA_COMPONENT].get_entity(ENTITY_ID) + assert isinstance(receiver, InfraredReceiverEntity) + received_signals: list[InfraredReceivedSignal] = [] + receiver.async_subscribe_received_signal(received_signals.append) + + timings = [100, -200, 300] + on_event(InfraredRFReceiveEventModel(key=1, device_id=0, timings=timings)) + await hass.async_block_till_done() + + assert received_signals == [InfraredReceivedSignal(timings=timings)] + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + # Test events with wrong key/device_id are ignored + on_event(InfraredRFReceiveEventModel(key=99, device_id=0, timings=timings)) + on_event(InfraredRFReceiveEventModel(key=1, device_id=42, timings=timings)) + await hass.async_block_till_done() + assert len(received_signals) == 1 + + +async def test_infrared_receiver_unsubscribes_on_unload( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test receiver unsubscribes from device events when its entry is unloaded.""" + mock_device = await _mock_ir_device( + mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER + ) + + unsub = mock_client.subscribe_infrared_rf_receive.return_value + unsub.assert_not_called() + + await hass.config_entries.async_unload(mock_device.entry.entry_id) + await hass.async_block_till_done() + + unsub.assert_called_once() + + +async def test_infrared_receiver_resubscribes_on_reconnect( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test receiver re-subscribes to events after a reconnect.""" + mock_device = await _mock_ir_device( + mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER + ) + + assert mock_client.subscribe_infrared_rf_receive.call_count == 1 + + await mock_device.mock_disconnect(False) + await hass.async_block_till_done() + await mock_device.mock_connect() + await hass.async_block_till_done() + + assert mock_client.subscribe_infrared_rf_receive.call_count == 2 + + async def test_infrared_entity_availability( hass: HomeAssistant, mock_client: APIClient,