Compare commits

...

12 Commits

Author SHA1 Message Date
Abílio Costa 9a084cbe01 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-22 14:04:13 +01:00
abmantis 346d21d77b Simplify entity construction 2026-05-22 11:10:52 +01:00
abmantis 782ffc7675 Merge branch 'dev' of github.com:home-assistant/core into esphome_ir_receiver 2026-05-21 22:24:05 +01:00
abmantis f83adf8f22 Add infrared receiver support to ESPHome 2026-05-21 22:23:25 +01:00
abmantis 25b307924b Merge branch 'dev' of github.com:home-assistant/core into esphome_ir_receiver 2026-05-21 18:09:57 +01:00
abmantis 867b617b60 Merge branch 'dev' of github.com:home-assistant/core into esphome_ir_receiver 2026-05-20 14:57:58 +01:00
abmantis 7e7590c8e2 Address Copilot feedback
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:29:28 +01:00
abmantis 49ab12c950 Update broadlink 2026-04-30 19:20:44 +01:00
abmantis 5d65d3e27b Merge branch 'dev' of github.com:home-assistant/core into ir_receiver 2026-04-30 19:18:14 +01:00
abmantis 7eeea9060d Update integrations 2026-04-30 19:07:35 +01:00
abmantis 4086d43a1b Minor improvements; update kitchen_sink 2026-04-30 17:59:27 +01:00
abmantis 62dc48ddd3 Add infrared receiver entity 2026-04-25 00:30:05 +01:00
3 changed files with 209 additions and 35 deletions
+7 -2
View File
@@ -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] = {}
+88 -11
View File
@@ -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)
),
)
+114 -22
View File
@@ -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,