From 62dc48ddd3029a8a59ee2d5edb8ee0d99d77ffaf Mon Sep 17 00:00:00 2001 From: abmantis Date: Sat, 25 Apr 2026 00:30:05 +0100 Subject: [PATCH 01/21] Add infrared receiver entity --- homeassistant/components/infrared/__init__.py | 163 ++++++++++++++++-- homeassistant/components/infrared/icons.json | 3 + .../components/infrared/strings.json | 11 ++ tests/components/infrared/conftest.py | 35 +++- tests/components/infrared/test_init.py | 128 +++++++++++--- 5 files changed, 292 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 44adbe154cc868..fcc3e246560847 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -3,7 +3,10 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum import logging from typing import final @@ -11,10 +14,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -25,15 +28,30 @@ __all__ = [ "DOMAIN", - "InfraredEntity", - "InfraredEntityDescription", + "InfraredEmitterEntity", + "InfraredEmitterEntityDescription", + "InfraredReceivedSignal", + "InfraredReceiverEntity", + "InfraredReceiverEntityDescription", "async_get_emitters", + "async_get_receivers", "async_send_command", + "async_subscribe_receiver", ] + +class InfraredDeviceClass(StrEnum): + """Device class for infrared entities.""" + + RECEIVER = "receiver" + EMITTER = "emitter" + + _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[ + EntityComponent[InfraredEmitterEntity | InfraredReceiverEntity] +] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -42,9 +60,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the infrared domain.""" - component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity]( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL - ) + component = hass.data[DATA_COMPONENT] = EntityComponent[ + InfraredEmitterEntity | InfraredReceiverEntity + ](_LOGGER, DOMAIN, hass, SCAN_INTERVAL) await component.async_setup(config) return True @@ -67,7 +85,25 @@ def async_get_emitters(hass: HomeAssistant) -> list[str]: if component is None: return [] - return [entity.entity_id for entity in component.entities] + return [ + entity.entity_id + for entity in component.entities + if isinstance(entity, InfraredEmitterEntity) + ] + + +@callback +def async_get_receivers(hass: HomeAssistant) -> list[str]: + """Get all infrared receiver entity IDs.""" + component = hass.data.get(DATA_COMPONENT) + if component is None: + return [] + + return [ + entity.entity_id + for entity in component.entities + if isinstance(entity, InfraredReceiverEntity) + ] async def async_send_command( @@ -91,7 +127,7 @@ async def async_send_command( ent_reg = er.async_get(hass) entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) entity = component.get_entity(entity_id) - if entity is None: + if entity is None or not isinstance(entity, InfraredEmitterEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="entity_not_found", @@ -104,14 +140,54 @@ async def async_send_command( await entity.async_send_command_internal(command) -class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True): - """Describes infrared entities.""" +@callback +def async_subscribe_receiver( + hass: HomeAssistant, + entity_id_or_uuid: str, + signal_callback: Callable[[InfraredReceivedSignal], None], +) -> CALLBACK_TYPE: + """Subscribe to IR signals from a specific receiver entity. + + Raises: + HomeAssistantError: If the receiver entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None or not isinstance(entity, InfraredReceiverEntity): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="receiver_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + return entity.async_subscribe_received_signal(signal_callback) + + +@dataclass(frozen=True, slots=True) +class InfraredReceivedSignal: + """Represents a received IR signal.""" + + timings: list[int] + modulation: int | None = None -class InfraredEntity(RestoreEntity): - """Base class for infrared transmitter entities.""" +class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True): + """Describes infrared emitter entities.""" - entity_description: InfraredEntityDescription + +class InfraredEmitterEntity(RestoreEntity): + """Base class for infrared emitter entities.""" + + entity_description: InfraredEmitterEntityDescription + _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER _attr_should_poll = False _attr_state: None = None @@ -151,3 +227,60 @@ async def async_send_command(self, command: InfraredCommand) -> None: Raises: HomeAssistantError: If transmission fails. """ + + +class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True): + """Describes infrared receiver entities.""" + + +class InfraredReceiverEntity(Entity): + """Base class for infrared receiver entities.""" + + entity_description: InfraredReceiverEntityDescription + _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER + _attr_should_poll = False + _attr_state: None = None + + __last_signal_received: str | None = None + + def __init__(self) -> None: + """Initialize the receiver entity.""" + super().__init__() + self.__signal_callbacks: set[Callable[[InfraredReceivedSignal], None]] = set() + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_signal_received + + @final + def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal. + + Called by platform implementations when a signal is received. + Updates entity state and notifies subscribers. + """ + self.__last_signal_received = dt_util.utcnow().isoformat( + timespec="milliseconds" + ) + self.async_write_ha_state() + for signal_callback in self.__signal_callbacks: + signal_callback(signal) + + @callback + def async_subscribe_received_signal( + self, + signal_callback: Callable[[InfraredReceivedSignal], None], + ) -> CALLBACK_TYPE: + """Subscribe to received IR signals. + + Returns a callable to unsubscribe. + """ + self.__signal_callbacks.add(signal_callback) + + @callback + def remove_callback() -> None: + self.__signal_callbacks.discard(signal_callback) + + return remove_callback diff --git a/homeassistant/components/infrared/icons.json b/homeassistant/components/infrared/icons.json index 3a12eb7d0b5025..7d3693b357318f 100644 --- a/homeassistant/components/infrared/icons.json +++ b/homeassistant/components/infrared/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:led-on" + }, + "receiver": { + "default": "mdi:led-off" } } } diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json index c4cf75cf1f3cb9..09d705d53cc0ff 100644 --- a/homeassistant/components/infrared/strings.json +++ b/homeassistant/components/infrared/strings.json @@ -1,10 +1,21 @@ { + "entity_component": { + "_": { + "name": "Infrared emitter" + }, + "receiver": { + "name": "Infrared receiver" + } + }, "exceptions": { "component_not_loaded": { "message": "Infrared component not loaded" }, "entity_not_found": { "message": "Infrared entity `{entity_id}` not found" + }, + "receiver_not_found": { + "message": "Infrared receiver entity `{entity_id}` not found" } } } diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index b1df1681893cbd..deed5701ed686b 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -3,7 +3,10 @@ from infrared_protocols import Command as InfraredCommand import pytest -from homeassistant.components.infrared import InfraredEntity +from homeassistant.components.infrared import ( + InfraredEmitterEntity, + InfraredReceiverEntity, +) from homeassistant.components.infrared.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -16,11 +19,11 @@ async def init_integration(hass: HomeAssistant) -> None: await hass.async_block_till_done() -class MockInfraredEntity(InfraredEntity): - """Mock infrared entity for testing.""" +class MockInfraredEmitterEntity(InfraredEmitterEntity): + """Mock infrared emitter entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR transmitter" + _attr_name = "Test IR emitter" def __init__(self, unique_id: str) -> None: """Initialize mock entity.""" @@ -33,6 +36,24 @@ async def async_send_command(self, command: InfraredCommand) -> None: @pytest.fixture -def mock_infrared_entity() -> MockInfraredEntity: - """Return a mock infrared entity.""" - return MockInfraredEntity("test_ir_transmitter") +def mock_infrared_emitter_entity() -> MockInfraredEmitterEntity: + """Return a mock infrared emitter entity.""" + return MockInfraredEmitterEntity("test_ir_emitter") + + +class MockInfraredReceiverEntity(InfraredReceiverEntity): + """Mock infrared receiver entity for testing.""" + + _attr_has_entity_name = True + _attr_name = "Test IR receiver" + + def __init__(self, unique_id: str) -> None: + """Initialize mock receiver entity.""" + super().__init__() + self._attr_unique_id = unique_id + + +@pytest.fixture +def mock_infrared_receiver_entity() -> MockInfraredReceiverEntity: + """Return a mock infrared receiver entity.""" + return MockInfraredReceiverEntity("test_ir_receiver") diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index d8653db986cef2..01bc97f169764b 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -1,6 +1,6 @@ """Tests for the Infrared integration setup.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from freezegun.api import FrozenDateTimeFactory from infrared_protocols import NECCommand @@ -9,8 +9,11 @@ from homeassistant.components.infrared import ( DATA_COMPONENT, DOMAIN, + InfraredReceivedSignal, async_get_emitters, + async_get_receivers, async_send_command, + async_subscribe_receiver, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -18,7 +21,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .conftest import MockInfraredEntity +from .conftest import MockInfraredEmitterEntity, MockInfraredReceiverEntity from tests.common import mock_restore_cache @@ -26,49 +29,56 @@ async def test_get_entities_integration_setup(hass: HomeAssistant) -> None: """Test getting entities when the integration is not setup.""" assert async_get_emitters(hass) == [] + assert async_get_receivers(hass) == [] @pytest.mark.usefixtures("init_integration") async def test_get_entities_empty(hass: HomeAssistant) -> None: """Test getting entities when none are registered.""" assert async_get_emitters(hass) == [] + assert async_get_receivers(hass) == [] @pytest.mark.usefixtures("init_integration") -async def test_infrared_entity_initial_state( - hass: HomeAssistant, mock_infrared_entity: MockInfraredEntity +async def test_infrared_entities_initial_state( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, ) -> None: - """Test infrared entity has no state before any command is sent.""" + """Test infrared entities have no state before any command is sent.""" component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_entity]) + await component.async_add_entities( + [mock_infrared_emitter_entity, mock_infrared_receiver_entity] + ) - state = hass.states.get("infrared.test_ir_transmitter") - assert state is not None - assert state.state == STATE_UNKNOWN + assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None + assert emitter_state.state == STATE_UNKNOWN + assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None + assert receiver_state.state == STATE_UNKNOWN @pytest.mark.usefixtures("init_integration") async def test_async_send_command_success( hass: HomeAssistant, - mock_infrared_entity: MockInfraredEntity, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test sending command via async_send_command helper.""" # Add the mock entity to the component component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_entity]) + await component.async_add_entities([mock_infrared_emitter_entity]) # Freeze time so we can verify the state update now = dt_util.utcnow() freezer.move_to(now) command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) - await async_send_command(hass, mock_infrared_entity.entity_id, command) + await async_send_command(hass, mock_infrared_emitter_entity.entity_id, command) - assert len(mock_infrared_entity.send_command_calls) == 1 - assert mock_infrared_entity.send_command_calls[0] is command + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0] is command - state = hass.states.get("infrared.test_ir_transmitter") + state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == now.isoformat(timespec="milliseconds") @@ -76,27 +86,27 @@ async def test_async_send_command_success( @pytest.mark.usefixtures("init_integration") async def test_async_send_command_error_does_not_update_state( hass: HomeAssistant, - mock_infrared_entity: MockInfraredEntity, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, ) -> None: """Test that state is not updated when async_send_command raises an error.""" component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_entity]) + await component.async_add_entities([mock_infrared_emitter_entity]) - state = hass.states.get("infrared.test_ir_transmitter") + state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == STATE_UNKNOWN command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) - mock_infrared_entity.async_send_command = AsyncMock( + mock_infrared_emitter_entity.async_send_command = AsyncMock( side_effect=HomeAssistantError("Transmission failed") ) with pytest.raises(HomeAssistantError, match="Transmission failed"): - await async_send_command(hass, mock_infrared_entity.entity_id, command) + await async_send_command(hass, mock_infrared_emitter_entity.entity_id, command) # Verify state was not updated after the error - state = hass.states.get("infrared.test_ir_transmitter") + state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == STATE_UNKNOWN @@ -134,19 +144,85 @@ async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> N ) async def test_infrared_entity_state_restore( hass: HomeAssistant, - mock_infrared_entity: MockInfraredEntity, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, restored_value: str, expected_state: str, ) -> None: """Test infrared entity state restore.""" - mock_restore_cache(hass, [State("infrared.test_ir_transmitter", restored_value)]) + mock_restore_cache( + hass, + [ + State("infrared.test_ir_emitter", restored_value), + State("infrared.test_ir_receiver", restored_value), + ], + ) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_entity]) + await component.async_add_entities( + [mock_infrared_emitter_entity, mock_infrared_receiver_entity] + ) + + assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None + assert emitter_state.state == expected_state + + # Receiver entity does not restore state + assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None + assert receiver_state.state == STATE_UNKNOWN - state = hass.states.get("infrared.test_ir_transmitter") + +@pytest.mark.usefixtures("init_integration") +async def test_async_subscribe_receiver_success( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + freezer: FrozenDateTimeFactory, +) -> None: + """Test subscribing to a receiver via async_subscribe_receiver helper.""" + # Add the mock entity to the component + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([mock_infrared_receiver_entity]) + + # Freeze time so we can verify the state update + now = dt_util.utcnow() + freezer.move_to(now) + + signal_callback = Mock() + unsubscribe = async_subscribe_receiver( + hass, mock_infrared_receiver_entity.entity_id, signal_callback + ) + + signal = InfraredReceivedSignal(timings=[100, 200, 300], modulation=38000) + mock_infrared_receiver_entity._handle_received_signal(signal) + + assert signal_callback.call_count == 1 + assert signal_callback.call_args[0][0] is signal + + state = hass.states.get("infrared.test_ir_receiver") assert state is not None - assert state.state == expected_state + assert state.state == now.isoformat(timespec="milliseconds") + + # Verify unsubscribe stops further callbacks + unsubscribe() + mock_infrared_receiver_entity._handle_received_signal(signal) + assert signal_callback.call_count == 1 + + +@pytest.mark.usefixtures("init_integration") +async def test_async_subscribe_receiver_entity_not_found(hass: HomeAssistant) -> None: + """Test async_subscribe_receiver raises error when entity not found.""" + with pytest.raises( + HomeAssistantError, + match="Infrared receiver entity `infrared.nonexistent_entity` not found", + ): + async_subscribe_receiver(hass, "infrared.nonexistent_entity", lambda _: None) + + +async def test_async_subscribe_receiver_component_not_loaded( + hass: HomeAssistant, +) -> None: + """Test async_subscribe_receiver raises error when component not loaded.""" + with pytest.raises(HomeAssistantError, match="component_not_loaded"): + async_subscribe_receiver(hass, "infrared.some_entity", lambda _: None) From 4086d43a1b6dd9a219b6806bce644c05627dc6b0 Mon Sep 17 00:00:00 2001 From: abmantis Date: Thu, 30 Apr 2026 17:59:27 +0100 Subject: [PATCH 02/21] Minor improvements; update kitchen_sink --- homeassistant/components/infrared/__init__.py | 16 ++++- .../components/kitchen_sink/infrared.py | 66 ++++++++++++++----- .../components/kitchen_sink/test_infrared.py | 30 +++++---- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index fcc3e246560847..d8aaf8d63ed074 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -11,6 +11,7 @@ from typing import final from infrared_protocols import Command as InfraredCommand +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE @@ -159,7 +160,15 @@ def async_subscribe_receiver( ) ent_reg = er.async_get(hass) - entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + try: + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + except vol.Invalid as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="receiver_not_found", + translation_placeholders={"entity_id": entity_id}, + ) from err + entity = component.get_entity(entity_id) if entity is None or not isinstance(entity, InfraredReceiverEntity): raise HomeAssistantError( @@ -266,7 +275,10 @@ def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None: ) self.async_write_ha_state() for signal_callback in self.__signal_callbacks: - signal_callback(signal) + try: + signal_callback(signal) + except Exception: + _LOGGER.exception("Error in signal callback for %s", self.entity_id) @callback def async_subscribe_received_signal( diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py index 437a993559a8f0..e7114e95f653ad 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -5,16 +5,27 @@ import infrared_protocols from homeassistant.components import persistent_notification -from homeassistant.components.infrared import InfraredEntity +from homeassistant.components.infrared import ( + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN PARALLEL_UPDATES = 0 +INFRARED_COMMAND_SIGNAL = f"{DOMAIN}_infrared_command_signal" + async def async_setup_entry( hass: HomeAssistant, @@ -24,37 +35,60 @@ async def async_setup_entry( """Set up the demo infrared platform.""" async_add_entities( [ - DemoInfrared( - unique_id="ir_transmitter", - device_name="IR Blaster", - entity_name="Infrared Transmitter", + DemoInfraredEmitter( + unique_id="ir_emitter", + entity_name="Infrared Emitter", + ), + DemoInfraredReceiver( + unique_id="ir_receiver", + entity_name="Infrared Receiver", ), ] ) -class DemoInfrared(InfraredEntity): +# pylint: disable=hass-enforce-class-module +class DemoInfraredEntityBase(Entity): """Representation of a demo infrared entity.""" _attr_has_entity_name = True _attr_should_poll = False - def __init__( - self, - unique_id: str, - device_name: str, - entity_name: str, - ) -> None: + def __init__(self, unique_id: str, entity_name: str) -> None: """Initialize the demo infrared entity.""" + super().__init__() self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=device_name, + identifiers={(DOMAIN, "infrared")}, name="IR Blaster" ) self._attr_name = entity_name + +class DemoInfraredEmitter(DemoInfraredEntityBase, InfraredEmitterEntity): + """Representation of a demo infrared emitter entity.""" + async def async_send_command(self, command: infrared_protocols.Command) -> None: """Send an IR command.""" + raw_timings = command.get_raw_timings() persistent_notification.async_create( - self.hass, str(command.get_raw_timings()), title="Infrared Command" + self.hass, str(raw_timings), title="Infrared Command Sent" + ) + async_dispatcher_send(self.hass, INFRARED_COMMAND_SIGNAL, raw_timings) + + +class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity): + """Representation of a demo infrared receiver entity.""" + + @callback + def _on_dispacher_signal(self, raw_timings: list[int]) -> None: + """Handle received infrared command signal.""" + self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings)) + + async def async_added_to_hass(self) -> None: + """Called when entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispacher_signal + ) ) diff --git a/tests/components/kitchen_sink/test_infrared.py b/tests/components/kitchen_sink/test_infrared.py index 0783087dc210a5..9f055c82b67f74 100644 --- a/tests/components/kitchen_sink/test_infrared.py +++ b/tests/components/kitchen_sink/test_infrared.py @@ -1,19 +1,23 @@ """The tests for the kitchen_sink infrared platform.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import infrared_protocols import pytest -from homeassistant.components.infrared import async_send_command +from homeassistant.components.infrared import ( + async_send_command, + async_subscribe_receiver, +) from homeassistant.components.kitchen_sink import DOMAIN -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -ENTITY_IR_TRANSMITTER = "infrared.ir_blaster_infrared_transmitter" +ENTITY_IR_EMITTER = "infrared.ir_blaster_infrared_emitter" +ENTITY_IR_RECEIVER = "infrared.ir_blaster_infrared_receiver" @pytest.fixture @@ -33,13 +37,12 @@ async def setup_comp(hass: HomeAssistant, infrared_only: None) -> None: await hass.async_block_till_done() -async def test_send_command( +async def test_send_receive( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: - """Test sending an infrared command.""" - state = hass.states.get(ENTITY_IR_TRANSMITTER) - assert state - assert state.state == STATE_UNKNOWN + """Test the receiver picks up commands sent by the emitter via dispatcher.""" + signal_callback = Mock() + async_subscribe_receiver(hass, ENTITY_IR_RECEIVER, signal_callback) now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") assert now is not None @@ -48,8 +51,13 @@ async def test_send_command( command = infrared_protocols.NECCommand( address=0x04, command=0x08, modulation=38000 ) - await async_send_command(hass, ENTITY_IR_TRANSMITTER, command) + await async_send_command(hass, ENTITY_IR_EMITTER, command) + await hass.async_block_till_done() - state = hass.states.get(ENTITY_IR_TRANSMITTER) + state = hass.states.get(ENTITY_IR_RECEIVER) assert state assert state.state == now.isoformat(timespec="milliseconds") + + assert signal_callback.call_count == 1 + received_signal = signal_callback.call_args[0][0] + assert received_signal.timings == command.get_raw_timings() From 7eeea9060df50eeeeead94944011e8d404b43885 Mon Sep 17 00:00:00 2001 From: abmantis Date: Thu, 30 Apr 2026 19:07:35 +0100 Subject: [PATCH 03/21] Update integrations --- homeassistant/components/esphome/infrared.py | 8 +++++--- homeassistant/components/smlight/infrared.py | 6 +++--- tests/components/lg_infrared/conftest.py | 6 +++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/esphome/infrared.py b/homeassistant/components/esphome/infrared.py index 34bfdcf6f89121..99f117360c9737 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -7,7 +7,7 @@ from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo -from homeassistant.components.infrared import InfraredCommand, InfraredEntity +from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity from homeassistant.core import callback from .entity import ( @@ -21,8 +21,10 @@ PARALLEL_UPDATES = 0 -class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity): - """ESPHome infrared entity using native API.""" +class EsphomeInfraredEntity( + EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity +): + """ESPHome infrared emitter entity using native API.""" @callback def _on_device_update(self) -> None: diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py index 6f6cd185173872..f584e310216aa1 100644 --- a/homeassistant/components/smlight/infrared.py +++ b/homeassistant/components/smlight/infrared.py @@ -5,7 +5,7 @@ from pysmlight.exceptions import SmlightError from pysmlight.models import IRPayload -from homeassistant.components.infrared import InfraredCommand, InfraredEntity +from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -29,8 +29,8 @@ async def async_setup_entry( async_add_entities([SmInfraredEntity(coordinator)]) -class SmInfraredEntity(SmEntity, InfraredEntity): - """Representation of a SLZB-Ultima infrared.""" +class SmInfraredEntity(SmEntity, InfraredEmitterEntity): + """Representation of a SLZB-Ultima infrared emitter.""" _attr_translation_key = "infrared_emitter" diff --git a/tests/components/lg_infrared/conftest.py b/tests/components/lg_infrared/conftest.py index ffb68fb35d4c74..1511fa3805a6db 100644 --- a/tests/components/lg_infrared/conftest.py +++ b/tests/components/lg_infrared/conftest.py @@ -11,7 +11,7 @@ from homeassistant.components.infrared import ( DATA_COMPONENT as INFRARED_DATA_COMPONENT, DOMAIN as INFRARED_DOMAIN, - InfraredEntity, + InfraredEmitterEntity, ) from homeassistant.components.lg_infrared import PLATFORMS from homeassistant.components.lg_infrared.const import ( @@ -29,8 +29,8 @@ MOCK_INFRARED_ENTITY_ID = "infrared.test_ir_transmitter" -class MockInfraredEntity(InfraredEntity): - """Mock infrared entity for testing.""" +class MockInfraredEntity(InfraredEmitterEntity): + """Mock infrared emitter entity for testing.""" _attr_has_entity_name = True _attr_name = "Test IR transmitter" From 49ab12c950902701f8a74a2cfbfcce506789a25d Mon Sep 17 00:00:00 2001 From: abmantis Date: Thu, 30 Apr 2026 19:20:44 +0100 Subject: [PATCH 04/21] Update broadlink --- homeassistant/components/broadlink/infrared.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/broadlink/infrared.py b/homeassistant/components/broadlink/infrared.py index 29238f08e1bd63..583a463e8a390a 100644 --- a/homeassistant/components/broadlink/infrared.py +++ b/homeassistant/components/broadlink/infrared.py @@ -7,7 +7,7 @@ from broadlink.exceptions import BroadlinkException from broadlink.remote import pulses_to_data as _bl_pulses_to_data -from homeassistant.components.infrared import InfraredCommand, InfraredEntity +from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -45,8 +45,8 @@ async def async_setup_entry( async_add_entities([BroadlinkInfraredEntity(device)]) -class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity): - """Broadlink infrared transmitter entity.""" +class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEmitterEntity): + """Broadlink infrared emitter entity.""" _attr_has_entity_name = True _attr_translation_key = "infrared_emitter" From 7e7590c8e2ff2a27d152a7f94c60898fc78e1d44 Mon Sep 17 00:00:00 2001 From: abmantis Date: Thu, 30 Apr 2026 19:29:28 +0100 Subject: [PATCH 05/21] Address Copilot feedback Co-Authored-By: Claude Opus 4.7 (1M context) --- homeassistant/components/infrared/__init__.py | 4 ++-- homeassistant/components/kitchen_sink/infrared.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index d8aaf8d63ed074..f7a45ae1188efd 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -166,7 +166,7 @@ def async_subscribe_receiver( raise HomeAssistantError( translation_domain=DOMAIN, translation_key="receiver_not_found", - translation_placeholders={"entity_id": entity_id}, + translation_placeholders={"entity_id": entity_id_or_uuid}, ) from err entity = component.get_entity(entity_id) @@ -274,7 +274,7 @@ def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None: timespec="milliseconds" ) self.async_write_ha_state() - for signal_callback in self.__signal_callbacks: + for signal_callback in tuple(self.__signal_callbacks): try: signal_callback(signal) except Exception: diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py index e7114e95f653ad..4f1f4f41831e81 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -80,7 +80,7 @@ class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity): """Representation of a demo infrared receiver entity.""" @callback - def _on_dispacher_signal(self, raw_timings: list[int]) -> None: + def _on_dispatcher_signal(self, raw_timings: list[int]) -> None: """Handle received infrared command signal.""" self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings)) @@ -89,6 +89,6 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( - self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispacher_signal + self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispatcher_signal ) ) From 309afb3efbb036a5c781d17f19c22bdf37258fe4 Mon Sep 17 00:00:00 2001 From: abmantis Date: Thu, 30 Apr 2026 19:58:10 +0100 Subject: [PATCH 06/21] RestoreEntity + tests --- homeassistant/components/infrared/__init__.py | 22 ++- tests/components/infrared/conftest.py | 1 + tests/components/infrared/test_init.py | 168 ++++++++++++++---- 3 files changed, 155 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index f7a45ae1188efd..91927de16433d0 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -242,8 +242,12 @@ class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True """Describes infrared receiver entities.""" -class InfraredReceiverEntity(Entity): - """Base class for infrared receiver entities.""" +class InfraredReceiverEntity(RestoreEntity): + """Base class for infrared receiver entities. + + Subclasses overriding `__init__` must call `super().__init__()` so the + internal subscriber set is initialized. + """ entity_description: InfraredReceiverEntityDescription _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER @@ -263,12 +267,20 @@ def state(self) -> str | None: """Return the entity state.""" return self.__last_signal_received + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the infrared entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__last_signal_received = state.state + @final def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None: """Handle a received IR signal. - Called by platform implementations when a signal is received. - Updates entity state and notifies subscribers. + Should not be overridden. To be called by platform implementations when a + signal is received. """ self.__last_signal_received = dt_util.utcnow().isoformat( timespec="milliseconds" diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index deed5701ed686b..30348ae41a5dda 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -27,6 +27,7 @@ class MockInfraredEmitterEntity(InfraredEmitterEntity): def __init__(self, unique_id: str) -> None: """Initialize mock entity.""" + super().__init__() self._attr_unique_id = unique_id self.send_command_calls: list[InfraredCommand] = [] diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 01bc97f169764b..b615289ac0b463 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -25,9 +25,11 @@ from tests.common import mock_restore_cache +TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) -async def test_get_entities_integration_setup(hass: HomeAssistant) -> None: - """Test getting entities when the integration is not setup.""" + +async def test_get_entities_component_not_loaded(hass: HomeAssistant) -> None: + """Test getting entities when the component is not loaded.""" assert async_get_emitters(hass) == [] assert async_get_receivers(hass) == [] @@ -39,6 +41,22 @@ async def test_get_entities_empty(hass: HomeAssistant) -> None: assert async_get_receivers(hass) == [] +@pytest.mark.usefixtures("init_integration") +async def test_get_entities_filters_by_type( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test get_emitters/get_receivers return only entities of the matching type.""" + component = hass.data[DATA_COMPONENT] + await component.async_add_entities( + [mock_infrared_emitter_entity, mock_infrared_receiver_entity] + ) + + assert async_get_emitters(hass) == [mock_infrared_emitter_entity.entity_id] + assert async_get_receivers(hass) == [mock_infrared_receiver_entity.entity_id] + + @pytest.mark.usefixtures("init_integration") async def test_infrared_entities_initial_state( hass: HomeAssistant, @@ -64,19 +82,16 @@ async def test_async_send_command_success( freezer: FrozenDateTimeFactory, ) -> None: """Test sending command via async_send_command helper.""" - # Add the mock entity to the component component = hass.data[DATA_COMPONENT] await component.async_add_entities([mock_infrared_emitter_entity]) - # Freeze time so we can verify the state update now = dt_util.utcnow() freezer.move_to(now) - command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) - await async_send_command(hass, mock_infrared_emitter_entity.entity_id, command) + await async_send_command(hass, mock_infrared_emitter_entity.entity_id, TEST_COMMAND) assert len(mock_infrared_emitter_entity.send_command_calls) == 1 - assert mock_infrared_emitter_entity.send_command_calls[0] is command + assert mock_infrared_emitter_entity.send_command_calls[0] is TEST_COMMAND state = hass.states.get("infrared.test_ir_emitter") assert state is not None @@ -96,16 +111,15 @@ async def test_async_send_command_error_does_not_update_state( assert state is not None assert state.state == STATE_UNKNOWN - command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) - mock_infrared_emitter_entity.async_send_command = AsyncMock( side_effect=HomeAssistantError("Transmission failed") ) with pytest.raises(HomeAssistantError, match="Transmission failed"): - await async_send_command(hass, mock_infrared_emitter_entity.entity_id, command) + await async_send_command( + hass, mock_infrared_emitter_entity.entity_id, TEST_COMMAND + ) - # Verify state was not updated after the error state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == STATE_UNKNOWN @@ -114,25 +128,35 @@ async def test_async_send_command_error_does_not_update_state( @pytest.mark.usefixtures("init_integration") async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None: """Test async_send_command raises error when entity not found.""" - command = NECCommand( - address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1 - ) - with pytest.raises( HomeAssistantError, match="Infrared entity `infrared.nonexistent_entity` not found", ): - await async_send_command(hass, "infrared.nonexistent_entity", command) + await async_send_command(hass, "infrared.nonexistent_entity", TEST_COMMAND) + + +@pytest.mark.usefixtures("init_integration") +async def test_async_send_command_rejects_receiver( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test async_send_command rejects a receiver entity.""" + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([mock_infrared_receiver_entity]) + + with pytest.raises( + HomeAssistantError, + match=f"Infrared entity `{mock_infrared_receiver_entity.entity_id}` not found", + ): + await async_send_command( + hass, mock_infrared_receiver_entity.entity_id, TEST_COMMAND + ) async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None: """Test async_send_command raises error when component not loaded.""" - command = NECCommand( - address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1 - ) - with pytest.raises(HomeAssistantError, match="component_not_loaded"): - await async_send_command(hass, "infrared.some_entity", command) + await async_send_command(hass, "infrared.some_entity", TEST_COMMAND) @pytest.mark.parametrize( @@ -168,10 +192,8 @@ async def test_infrared_entity_state_restore( assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None assert emitter_state.state == expected_state - - # Receiver entity does not restore state assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None - assert receiver_state.state == STATE_UNKNOWN + assert receiver_state.state == expected_state @pytest.mark.usefixtures("init_integration") @@ -181,11 +203,9 @@ async def test_async_subscribe_receiver_success( freezer: FrozenDateTimeFactory, ) -> None: """Test subscribing to a receiver via async_subscribe_receiver helper.""" - # Add the mock entity to the component component = hass.data[DATA_COMPONENT] await component.async_add_entities([mock_infrared_receiver_entity]) - # Freeze time so we can verify the state update now = dt_util.utcnow() freezer.move_to(now) @@ -204,20 +224,106 @@ async def test_async_subscribe_receiver_success( assert state is not None assert state.state == now.isoformat(timespec="milliseconds") - # Verify unsubscribe stops further callbacks unsubscribe() mock_infrared_receiver_entity._handle_received_signal(signal) assert signal_callback.call_count == 1 @pytest.mark.usefixtures("init_integration") -async def test_async_subscribe_receiver_entity_not_found(hass: HomeAssistant) -> None: - """Test async_subscribe_receiver raises error when entity not found.""" +async def test_handle_received_signal_isolates_callback_errors( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a failing subscriber does not prevent other subscribers from running.""" + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([mock_infrared_receiver_entity]) + + failing_callback = Mock(side_effect=RuntimeError("boom")) + working_callback = Mock() + async_subscribe_receiver( + hass, mock_infrared_receiver_entity.entity_id, failing_callback + ) + async_subscribe_receiver( + hass, mock_infrared_receiver_entity.entity_id, working_callback + ) + + signal = InfraredReceivedSignal(timings=[100, 200, 300]) + mock_infrared_receiver_entity._handle_received_signal(signal) + + failing_callback.assert_called_once_with(signal) + working_callback.assert_called_once_with(signal) + assert "Error in signal callback" in caplog.text + + +@pytest.mark.usefixtures("init_integration") +async def test_handle_received_signal_unsubscribe_during_dispatch( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test a subscriber can unsubscribe itself during dispatch without error.""" + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([mock_infrared_receiver_entity]) + + other_callback = Mock() + + def unsubscribing_callback(signal: InfraredReceivedSignal) -> None: + unsubscribe() + + self_unsub_mock = Mock(side_effect=unsubscribing_callback) + unsubscribe = async_subscribe_receiver( + hass, mock_infrared_receiver_entity.entity_id, self_unsub_mock + ) + async_subscribe_receiver( + hass, mock_infrared_receiver_entity.entity_id, other_callback + ) + + signal = InfraredReceivedSignal(timings=[100, 200, 300]) + mock_infrared_receiver_entity._handle_received_signal(signal) + + self_unsub_mock.assert_called_once_with(signal) + other_callback.assert_called_once_with(signal) + + mock_infrared_receiver_entity._handle_received_signal(signal) + self_unsub_mock.assert_called_once_with(signal) + assert other_callback.call_count == 2 + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "entity_id_or_uuid", + ["infrared.nonexistent_entity", "invalid-id"], +) +async def test_async_subscribe_receiver_not_found( + hass: HomeAssistant, entity_id_or_uuid: str +) -> None: + """Test async_subscribe_receiver raises when the entity is missing or invalid.""" + with pytest.raises( + HomeAssistantError, + match=f"Infrared receiver entity `{entity_id_or_uuid}` not found", + ): + async_subscribe_receiver(hass, entity_id_or_uuid, lambda _: None) + + +@pytest.mark.usefixtures("init_integration") +async def test_async_subscribe_receiver_rejects_emitter( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test async_subscribe_receiver rejects an emitter entity.""" + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([mock_infrared_emitter_entity]) + with pytest.raises( HomeAssistantError, - match="Infrared receiver entity `infrared.nonexistent_entity` not found", + match=( + f"Infrared receiver entity `{mock_infrared_emitter_entity.entity_id}`" + " not found" + ), ): - async_subscribe_receiver(hass, "infrared.nonexistent_entity", lambda _: None) + async_subscribe_receiver( + hass, mock_infrared_emitter_entity.entity_id, lambda _: None + ) async def test_async_subscribe_receiver_component_not_loaded( From a9bcf42388adfa6131ad1822422be9f03693b549 Mon Sep 17 00:00:00 2001 From: abmantis Date: Thu, 30 Apr 2026 20:13:02 +0100 Subject: [PATCH 07/21] Lazy init __signal_callbacks --- homeassistant/components/infrared/__init__.py | 20 +++++++++---------- tests/components/infrared/conftest.py | 1 - 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 91927de16433d0..c20a86221097b3 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -11,6 +11,7 @@ from typing import final from infrared_protocols import Command as InfraredCommand +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -243,11 +244,7 @@ class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True class InfraredReceiverEntity(RestoreEntity): - """Base class for infrared receiver entities. - - Subclasses overriding `__init__` must call `super().__init__()` so the - internal subscriber set is initialized. - """ + """Base class for infrared receiver entities.""" entity_description: InfraredReceiverEntityDescription _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER @@ -256,10 +253,10 @@ class InfraredReceiverEntity(RestoreEntity): __last_signal_received: str | None = None - def __init__(self) -> None: - """Initialize the receiver entity.""" - super().__init__() - self.__signal_callbacks: set[Callable[[InfraredReceivedSignal], None]] = set() + @cached_property + def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]: + """Subscriber callback set, lazily initialized on first access.""" + return set() @property @final @@ -301,10 +298,11 @@ def async_subscribe_received_signal( Returns a callable to unsubscribe. """ - self.__signal_callbacks.add(signal_callback) + callbacks = self.__signal_callbacks + callbacks.add(signal_callback) @callback def remove_callback() -> None: - self.__signal_callbacks.discard(signal_callback) + callbacks.discard(signal_callback) return remove_callback diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index 30348ae41a5dda..6596ba2dab5c7b 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -50,7 +50,6 @@ class MockInfraredReceiverEntity(InfraredReceiverEntity): def __init__(self, unique_id: str) -> None: """Initialize mock receiver entity.""" - super().__init__() self._attr_unique_id = unique_id From 7a7b0e294ca224874f1e34b601e588ff346c5b19 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 4 May 2026 22:24:29 +0100 Subject: [PATCH 08/21] Update kitchen_sink --- .../components/kitchen_sink/__init__.py | 1 + .../components/kitchen_sink/config_flow.py | 45 +++++--- .../components/kitchen_sink/const.py | 1 + .../components/kitchen_sink/event.py | 109 ++++++++++++++++++ .../components/kitchen_sink/strings.json | 7 +- .../kitchen_sink/test_config_flow.py | 6 +- 6 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/kitchen_sink/event.py diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 52d79e37f43ba2..df2b69123e9e55 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -57,6 +57,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.BUTTON, Platform.FAN, + Platform.EVENT, Platform.IMAGE, Platform.INFRARED, Platform.LAWN_MOWER, diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 2fbceef306222a..f47ff10bc052cc 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.infrared import ( DOMAIN as INFRARED_DOMAIN, async_get_emitters, + async_get_receivers, ) from homeassistant.config_entries import ( ConfigEntry, @@ -24,7 +25,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig -from .const import CONF_INFRARED_ENTITY_ID, DOMAIN +from .const import CONF_INFRARED_ENTITY_ID, CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN CONF_BOOLEAN = "bool" CONF_INT = "int" @@ -180,25 +181,33 @@ async def async_step_user( ) -> SubentryFlowResult: """User flow to add an infrared fan.""" - entities = async_get_emitters(self.hass) - if not entities: - return self.async_abort(reason="no_emitters") - if user_input is not None: title = user_input.pop("name") return self.async_create_entry(data=user_input, title=title) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required("name"): str, - vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( - EntitySelectorConfig( - domain=INFRARED_DOMAIN, - include_entities=entities, - ) - ), - } + emitter_entities = async_get_emitters(self.hass) + if not emitter_entities: + return self.async_abort(reason="no_emitters") + + schema_dict: dict[vol.Marker, Any] = { + vol.Required("name"): str, + vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entities, + ) ), - ) + } + + receiver_entities = async_get_receivers(self.hass) + if receiver_entities: + schema_dict[vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID)] = ( + EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=receiver_entities, + ) + ) + ) + + return self.async_show_form(step_id="user", data_schema=vol.Schema(schema_dict)) diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py index bce291bd5d661e..21e3b90a64efa2 100644 --- a/homeassistant/components/kitchen_sink/const.py +++ b/homeassistant/components/kitchen_sink/const.py @@ -8,6 +8,7 @@ DOMAIN = "kitchen_sink" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id" DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( f"{DOMAIN}.backup_agent_listeners" ) diff --git a/homeassistant/components/kitchen_sink/event.py b/homeassistant/components/kitchen_sink/event.py new file mode 100644 index 00000000000000..ab4ec7f992bdfc --- /dev/null +++ b/homeassistant/components/kitchen_sink/event.py @@ -0,0 +1,109 @@ +"""Demo platform that offers a fake infrared receiver event entity.""" + +from homeassistant.components.event import EventEntity +from homeassistant.components.infrared import ( + InfraredReceivedSignal, + async_subscribe_receiver, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the demo infrared event platform.""" + for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "infrared_fan": + continue + if subentry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID) is None: + continue + async_add_entities( + [ + DemoInfraredEvent( + subentry_id=subentry_id, + device_name=subentry.title, + infrared_receiver_entity_id=subentry.data[ + CONF_INFRARED_RECEIVER_ENTITY_ID + ], + ) + ], + config_subentry_id=subentry_id, + ) + + +class DemoInfraredEvent(EventEntity): + """Representation of a demo infrared event entity.""" + + _attr_has_entity_name = True + _attr_name = "Received IR Event" + _attr_should_poll = False + _attr_event_types = ["unknown"] + + def __init__( + self, subentry_id: str, device_name: str, infrared_receiver_entity_id: str + ) -> None: + """Initialize the demo infrared event entity.""" + self._receiver_entity_id = infrared_receiver_entity_id + self._attr_unique_id = subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry_id)}, name=device_name + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to the IR receiver when added to hass.""" + await super().async_added_to_hass() + + @callback + def _handle_signal(signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal.""" + self._trigger_event("unknown", {"raw_code": signal.timings}) + self.async_write_ha_state() + + remove_signal_subscription: CALLBACK_TYPE | None = None + + @callback + def _async_subscribe_when_available() -> None: + """Subscribe to the IR receiver when it becomes available.""" + nonlocal remove_signal_subscription + + ir_state = self.hass.states.get(self._receiver_entity_id) + self._attr_available = ( + ir_state is not None and ir_state.state != STATE_UNAVAILABLE + ) + if not self._attr_available: + return + if remove_signal_subscription is not None: + return + remove_signal_subscription = async_subscribe_receiver( + self.hass, self._receiver_entity_id, _handle_signal + ) + self.async_on_remove(remove_signal_subscription) + + @callback + def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle infrared entity state changes.""" + _async_subscribe_when_available() + + _async_subscribe_when_available() + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._receiver_entity_id], _async_ir_state_changed + ) + ) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index e369e0942bdc77..c0e4612373c1ef 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -35,7 +35,7 @@ }, "infrared_fan": { "abort": { - "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + "no_emitters": "No infrared emitter entities found. Please set up an infrared device first." }, "entry_type": "Infrared fan", "initiate_flow": { @@ -44,10 +44,11 @@ "step": { "user": { "data": { - "infrared_entity_id": "Infrared transmitter", + "infrared_entity_id": "Infrared emitter", + "infrared_receiver_entity_id": "Infrared receiver", "name": "[%key:common::config_flow::data::name%]" }, - "description": "Select an infrared transmitter to control the fan." + "description": "Select an infrared emitter to control the fan." } } } diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 77733a7f4a0d29..d30541d676c0e9 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry -ENTITY_IR_TRANSMITTER = "infrared.ir_blaster_infrared_transmitter" +ENTITY_IR_EMITTER = "infrared.ir_blaster_infrared_emitter" @pytest.fixture @@ -227,7 +227,7 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={ "name": "Living Room Fan", - "infrared_entity_id": ENTITY_IR_TRANSMITTER, + "infrared_entity_id": ENTITY_IR_EMITTER, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -237,7 +237,7 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None: if s.subentry_type == "infrared_fan" ][0] assert config_entry.subentries[subentry_id] == config_entries.ConfigSubentry( - data={"infrared_entity_id": ENTITY_IR_TRANSMITTER}, + data={"infrared_entity_id": ENTITY_IR_EMITTER}, subentry_id=subentry_id, subentry_type="infrared_fan", title="Living Room Fan", From 2b65c8c992af44254c9a5fba0bb0f4e514bee881 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 4 May 2026 22:46:21 +0100 Subject: [PATCH 09/21] Fix subscription; update test --- .../components/kitchen_sink/event.py | 41 +++++++++++++------ .../kitchen_sink/test_config_flow.py | 7 +++- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/kitchen_sink/event.py b/homeassistant/components/kitchen_sink/event.py index ab4ec7f992bdfc..9dcef82976cff1 100644 --- a/homeassistant/components/kitchen_sink/event.py +++ b/homeassistant/components/kitchen_sink/event.py @@ -79,29 +79,46 @@ def _handle_signal(signal: InfraredReceivedSignal) -> None: remove_signal_subscription: CALLBACK_TYPE | None = None @callback - def _async_subscribe_when_available() -> None: - """Subscribe to the IR receiver when it becomes available.""" + def _async_unsubscribe_receiver() -> None: + """Unsubscribe from the current IR receiver.""" + nonlocal remove_signal_subscription + + if remove_signal_subscription is None: + return + remove_signal_subscription() + remove_signal_subscription = None + + @callback + def _async_update_receiver_subscription(write_state: bool = True) -> None: + """Update the IR receiver subscription when availability changes.""" nonlocal remove_signal_subscription ir_state = self.hass.states.get(self._receiver_entity_id) - self._attr_available = ( + receiver_available = ( ir_state is not None and ir_state.state != STATE_UNAVAILABLE ) - if not self._attr_available: - return - if remove_signal_subscription is not None: + + if not receiver_available: + _async_unsubscribe_receiver() + elif remove_signal_subscription is None: + remove_signal_subscription = async_subscribe_receiver( + self.hass, self._receiver_entity_id, _handle_signal + ) + + if self._attr_available == receiver_available: return - remove_signal_subscription = async_subscribe_receiver( - self.hass, self._receiver_entity_id, _handle_signal - ) - self.async_on_remove(remove_signal_subscription) + + self._attr_available = receiver_available + if write_state: + self.async_write_ha_state() @callback def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None: """Handle infrared entity state changes.""" - _async_subscribe_when_available() + _async_update_receiver_subscription() - _async_subscribe_when_available() + _async_update_receiver_subscription(write_state=False) + self.async_on_remove(_async_unsubscribe_receiver) self.async_on_remove( async_track_state_change_event( self.hass, [self._receiver_entity_id], _async_ir_state_changed diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index d30541d676c0e9..400b374c8f748e 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -15,6 +15,7 @@ from tests.common import MockConfigEntry ENTITY_IR_EMITTER = "infrared.ir_blaster_infrared_emitter" +ENTITY_IR_RECEIVER = "infrared.ir_blaster_infrared_receiver" @pytest.fixture @@ -228,6 +229,7 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None: user_input={ "name": "Living Room Fan", "infrared_entity_id": ENTITY_IR_EMITTER, + "infrared_receiver_entity_id": ENTITY_IR_RECEIVER, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -237,7 +239,10 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None: if s.subentry_type == "infrared_fan" ][0] assert config_entry.subentries[subentry_id] == config_entries.ConfigSubentry( - data={"infrared_entity_id": ENTITY_IR_EMITTER}, + data={ + "infrared_entity_id": ENTITY_IR_EMITTER, + "infrared_receiver_entity_id": ENTITY_IR_RECEIVER, + }, subentry_id=subentry_id, subentry_type="infrared_fan", title="Living Room Fan", From b03ab003cab58fe0165bf9fd3f3be9011d5b067d Mon Sep 17 00:00:00 2001 From: abmantis Date: Fri, 8 May 2026 17:47:23 +0100 Subject: [PATCH 10/21] Fix deprecated_class to work with inheritance --- homeassistant/helpers/deprecation.py | 35 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 8f4dfca21102cf..0be8dfeb2b1546 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -6,7 +6,7 @@ import functools import inspect import logging -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast def deprecated_substitute[_ObjectT: object]( @@ -86,27 +86,44 @@ def get_deprecated( return config.get(new_name, default) -def deprecated_class[**_P, _R]( +def deprecated_class[_T]( replacement: str, *, breaks_in_ha_version: str | None = None -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: +) -> Callable[[type[_T]], type[_T]]: """Mark class as deprecated and provide a replacement class to be used instead. If the deprecated function was called from a custom integration, ask the user to report an issue. """ - def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: + def deprecated_decorator(cls: type[_T]) -> type[_T]: """Decorate class as deprecated.""" + base_meta = type(cls) - @functools.wraps(cls) - def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: - """Wrap for the original class.""" + def __call__(self: type[Any], *args: Any, **kwargs: Any) -> Any: _print_deprecation_warning( cls, replacement, "class", "instantiated", breaks_in_ha_version ) - return cls(*args, **kwargs) + return base_meta.__call__(self, *args, **kwargs) + + deprecated_meta = type( + f"Deprecated{base_meta.__name__}", + (base_meta,), + {"__call__": __call__}, + ) + + deprecated_cls = deprecated_meta( + cls.__name__, + (cls,), + { + "__module__": cls.__module__, + "__qualname__": cls.__qualname__, + "__doc__": cls.__doc__, + "__slots__": (), + "__wrapped__": cls, + }, + ) - return deprecated_cls + return cast(type[_T], deprecated_cls) return deprecated_decorator From e9b36fa841789c191b1dc90d3530a9f98f00848e Mon Sep 17 00:00:00 2001 From: abmantis Date: Fri, 8 May 2026 18:06:05 +0100 Subject: [PATCH 11/21] Address review feedback --- homeassistant/components/infrared/__init__.py | 29 +++++++++++++++++-- tests/components/infrared/conftest.py | 26 +++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 43bb0e2f194307..6e7206895b46cc 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -13,10 +13,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.deprecation import deprecated_class from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity @@ -30,6 +31,8 @@ "DOMAIN", "InfraredEmitterEntity", "InfraredEmitterEntityDescription", + "InfraredEntity", + "InfraredEntityDescription", "InfraredReceivedSignal", "InfraredReceiverEntity", "InfraredReceiverEntityDescription", @@ -43,8 +46,8 @@ class InfraredDeviceClass(StrEnum): """Device class for infrared entities.""" - RECEIVER = "receiver" EMITTER = "emitter" + RECEIVER = "receiver" _LOGGER = logging.getLogger(__name__) @@ -267,7 +270,11 @@ async def async_internal_added_to_hass(self) -> None: """Call when the infrared entity is added to hass.""" await super().async_internal_added_to_hass() state = await self.async_get_last_state() - if state is not None and state.state not in (STATE_UNAVAILABLE, None): + if state is not None and state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + None, + ): self.__last_signal_received = state.state @final @@ -304,3 +311,19 @@ def remove_callback() -> None: callbacks.discard(signal_callback) return remove_callback + + +@deprecated_class( + "homeassistant.components.infrared.InfraredEmitterEntityDescription", + breaks_in_ha_version="2027.5", +) +class InfraredEntityDescription(InfraredEmitterEntityDescription): + """Deprecated alias for InfraredEmitterEntityDescription.""" + + +@deprecated_class( + "homeassistant.components.infrared.InfraredEmitterEntity", + breaks_in_ha_version="2027.5", +) +class InfraredEntity(InfraredEmitterEntity): + """Deprecated alias for InfraredEmitterEntity.""" diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index 7163a97b49e005..975ef9391ed0d5 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -5,6 +5,7 @@ from homeassistant.components.infrared import ( InfraredEmitterEntity, + InfraredEntity, InfraredReceiverEntity, ) from homeassistant.components.infrared.const import DOMAIN @@ -19,6 +20,23 @@ async def init_integration(hass: HomeAssistant) -> None: await hass.async_block_till_done() +class MockInfraredEntity(InfraredEntity): + """Mock deprecated infrared emitter entity for testing.""" + + _attr_has_entity_name = True + _attr_name = "Test IR emitter" + + def __init__(self, unique_id: str) -> None: + """Initialize mock entity.""" + super().__init__() + self._attr_unique_id = unique_id + self.send_command_calls: list[InfraredCommand] = [] + + async def async_send_command(self, command: InfraredCommand) -> None: + """Mock send command.""" + self.send_command_calls.append(command) + + class MockInfraredEmitterEntity(InfraredEmitterEntity): """Mock infrared emitter entity for testing.""" @@ -36,10 +54,12 @@ async def async_send_command(self, command: InfraredCommand) -> None: self.send_command_calls.append(command) -@pytest.fixture -def mock_infrared_emitter_entity() -> MockInfraredEmitterEntity: +@pytest.fixture(params=[MockInfraredEntity, MockInfraredEmitterEntity]) +def mock_infrared_emitter_entity( + request: pytest.FixtureRequest, +) -> MockInfraredEntity | MockInfraredEmitterEntity: """Return a mock infrared emitter entity.""" - return MockInfraredEmitterEntity("test_ir_emitter") + return request.param("test_ir_emitter") class MockInfraredReceiverEntity(InfraredReceiverEntity): From f3350dd7361a0577fd17d3f9c1365f350b556c28 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 11 May 2026 16:46:38 +0100 Subject: [PATCH 12/21] Guard agasint no-emitter kitchen sink fan --- homeassistant/components/kitchen_sink/fan.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/kitchen_sink/fan.py b/homeassistant/components/kitchen_sink/fan.py index 838f31d4dd8833..ee935e441ec415 100644 --- a/homeassistant/components/kitchen_sink/fan.py +++ b/homeassistant/components/kitchen_sink/fan.py @@ -34,6 +34,8 @@ async def async_setup_entry( for subentry_id, subentry in config_entry.subentries.items(): if subentry.subentry_type != "infrared_fan": continue + if subentry.data.get(CONF_INFRARED_ENTITY_ID) is None: + continue async_add_entities( [ DemoInfraredFan( From e8a3e54cc202c6d424ef2819637438253bb64a48 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 11 May 2026 17:11:17 +0100 Subject: [PATCH 13/21] Decode raw timings in kitchen sink event --- .../components/kitchen_sink/const.py | 7 +++++ .../components/kitchen_sink/event.py | 31 +++++++++++++++++-- homeassistant/components/kitchen_sink/fan.py | 31 ++++++++++--------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py index 6e1eec9b6b0c27..249cb8bb8f0f15 100644 --- a/homeassistant/components/kitchen_sink/const.py +++ b/homeassistant/components/kitchen_sink/const.py @@ -10,3 +10,10 @@ DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( f"{DOMAIN}.backup_agent_listeners" ) + +INFRARED_FAN_ADDRESS = 0x1234 +INFRARED_CMD_POWER_ON = 0x01 +INFRARED_CMD_POWER_OFF = 0x02 +INFRARED_CMD_SPEED_LOW = 0x03 +INFRARED_CMD_SPEED_MEDIUM = 0x04 +INFRARED_CMD_SPEED_HIGH = 0x05 diff --git a/homeassistant/components/kitchen_sink/event.py b/homeassistant/components/kitchen_sink/event.py index 9dcef82976cff1..5250cf75845dec 100644 --- a/homeassistant/components/kitchen_sink/event.py +++ b/homeassistant/components/kitchen_sink/event.py @@ -1,5 +1,7 @@ """Demo platform that offers a fake infrared receiver event entity.""" +from infrared_protocols.commands.nec import NECCommand + from homeassistant.components.event import EventEntity from homeassistant.components.infrared import ( InfraredReceivedSignal, @@ -18,10 +20,27 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN +from .const import ( + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, + INFRARED_CMD_POWER_OFF, + INFRARED_CMD_POWER_ON, + INFRARED_CMD_SPEED_HIGH, + INFRARED_CMD_SPEED_LOW, + INFRARED_CMD_SPEED_MEDIUM, + INFRARED_FAN_ADDRESS, +) PARALLEL_UPDATES = 0 +COMMAND_EVENTS = { + INFRARED_CMD_POWER_ON: "power_on", + INFRARED_CMD_POWER_OFF: "power_off", + INFRARED_CMD_SPEED_LOW: "speed_low", + INFRARED_CMD_SPEED_MEDIUM: "speed_medium", + INFRARED_CMD_SPEED_HIGH: "speed_high", +} + async def async_setup_entry( hass: HomeAssistant, @@ -54,7 +73,7 @@ class DemoInfraredEvent(EventEntity): _attr_has_entity_name = True _attr_name = "Received IR Event" _attr_should_poll = False - _attr_event_types = ["unknown"] + _attr_event_types = list(COMMAND_EVENTS.values()) def __init__( self, subentry_id: str, device_name: str, infrared_receiver_entity_id: str @@ -73,7 +92,13 @@ async def async_added_to_hass(self) -> None: @callback def _handle_signal(signal: InfraredReceivedSignal) -> None: """Handle a received IR signal.""" - self._trigger_event("unknown", {"raw_code": signal.timings}) + command = NECCommand.from_raw_timings(signal.timings) + if command is None or command.address != INFRARED_FAN_ADDRESS: + return + event_type = COMMAND_EVENTS.get(command.command) + if event_type is None: + return + self._trigger_event(event_type, {"raw_code": signal.timings}) self.async_write_ha_state() remove_signal_subscription: CALLBACK_TYPE | None = None diff --git a/homeassistant/components/kitchen_sink/fan.py b/homeassistant/components/kitchen_sink/fan.py index ee935e441ec415..f4ab1237d7b40b 100644 --- a/homeassistant/components/kitchen_sink/fan.py +++ b/homeassistant/components/kitchen_sink/fan.py @@ -13,17 +13,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_INFRARED_ENTITY_ID, DOMAIN +from .const import ( + CONF_INFRARED_ENTITY_ID, + DOMAIN, + INFRARED_CMD_POWER_OFF, + INFRARED_CMD_POWER_ON, + INFRARED_CMD_SPEED_HIGH, + INFRARED_CMD_SPEED_LOW, + INFRARED_CMD_SPEED_MEDIUM, + INFRARED_FAN_ADDRESS, +) PARALLEL_UPDATES = 0 -DUMMY_FAN_ADDRESS = 0x1234 -DUMMY_CMD_POWER_ON = 0x01 -DUMMY_CMD_POWER_OFF = 0x02 -DUMMY_CMD_SPEED_LOW = 0x03 -DUMMY_CMD_SPEED_MEDIUM = 0x04 -DUMMY_CMD_SPEED_HIGH = 0x05 - async def async_setup_entry( hass: HomeAssistant, @@ -105,7 +107,7 @@ def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None: async def _send_command(self, command_code: int) -> None: """Send an IR command using the NEC protocol.""" command = NECCommand( - address=DUMMY_FAN_ADDRESS, + address=INFRARED_FAN_ADDRESS, command=command_code, modulation=38000, ) @@ -123,13 +125,13 @@ async def async_turn_on( if percentage is not None: await self.async_set_percentage(percentage) return - await self._send_command(DUMMY_CMD_POWER_ON) + await self._send_command(INFRARED_CMD_POWER_ON) self._attr_percentage = 33 self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self._send_command(DUMMY_CMD_POWER_OFF) + await self._send_command(INFRARED_CMD_POWER_OFF) self._attr_percentage = 0 self.async_write_ha_state() @@ -140,11 +142,10 @@ async def async_set_percentage(self, percentage: int) -> None: return if percentage <= 33: - await self._send_command(DUMMY_CMD_SPEED_LOW) + await self._send_command(INFRARED_CMD_SPEED_LOW) elif percentage <= 66: - await self._send_command(DUMMY_CMD_SPEED_MEDIUM) + await self._send_command(INFRARED_CMD_SPEED_MEDIUM) else: - await self._send_command(DUMMY_CMD_SPEED_HIGH) - + await self._send_command(INFRARED_CMD_SPEED_HIGH) self._attr_percentage = percentage self.async_write_ha_state() From 6dceaba20fc4564dfe4f9dbe6fdd11c23b49a599 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 11 May 2026 17:41:34 +0100 Subject: [PATCH 14/21] Update common IR fixtures to new naming --- tests/components/conftest.py | 28 ++++++++++++++----- tests/components/infrared/__init__.py | 3 +- tests/components/infrared/common.py | 20 +++++++++---- tests/components/infrared/conftest.py | 8 ++++-- tests/components/infrared/test_init.py | 9 ------ tests/components/lg_infrared/conftest.py | 8 +++--- tests/components/lg_infrared/test_button.py | 8 +++--- .../lg_infrared/test_config_flow.py | 28 +++++++++++-------- .../lg_infrared/test_media_player.py | 8 +++--- tests/components/lg_infrared/utils.py | 2 +- 10 files changed, 73 insertions(+), 49 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index bb8297ca9eaa51..aa34dbecc5e04f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -95,7 +95,7 @@ from .conversation import MockAgent from .device_tracker.common import MockScanner - from .infrared.common import MockInfraredEntity + from .infrared.common import MockInfraredEmitterEntity, MockInfraredReceiverEntity from .light.common import MockLight from .radio_frequency.common import MockRadioFrequencyEntity from .sensor.common import MockSensor @@ -234,14 +234,28 @@ async def init_infrared_fixture(hass: HomeAssistant) -> None: await init_infrared_fixture_helper(hass) -@pytest.fixture(name="mock_infrared_entity") -async def mock_infrared_entity_fixture( +@pytest.fixture(name="mock_infrared_emitter_entity") +async def mock_infrared_emitter_entity_fixture( hass: HomeAssistant, init_infrared: None -) -> MockInfraredEntity: - """Return a mock infrared entity.""" - from .infrared.common import mock_infrared_entity_fixture_helper # noqa: PLC0415 +) -> MockInfraredEmitterEntity: + """Return a mock infrared emitter entity.""" + from .infrared.common import ( # noqa: PLC0415 + mock_infrared_emitter_entity_fixture_helper, + ) + + return await mock_infrared_emitter_entity_fixture_helper(hass) + + +@pytest.fixture(name="mock_infrared_receiver_entity") +async def mock_infrared_receiver_entity_fixture( + hass: HomeAssistant, init_infrared: None +) -> MockInfraredReceiverEntity: + """Return a mock infrared receiver entity.""" + from .infrared.common import ( # noqa: PLC0415 + mock_infrared_receiver_entity_fixture_helper, + ) - return await mock_infrared_entity_fixture_helper(hass) + return await mock_infrared_receiver_entity_fixture_helper(hass) @pytest.fixture(scope="session", autouse=find_spec("haffmpeg") is not None) diff --git a/tests/components/infrared/__init__.py b/tests/components/infrared/__init__.py index 14b40a4f731894..0bbcdbee6a9a32 100644 --- a/tests/components/infrared/__init__.py +++ b/tests/components/infrared/__init__.py @@ -1,3 +1,4 @@ """Tests for the Infrared integration.""" -ENTITY_ID = "infrared.test_ir_transmitter" +EMITTER_ENTITY_ID = "infrared.test_ir_emitter" +RECEIVER_ENTITY_ID = "infrared.test_ir_receiver" diff --git a/tests/components/infrared/common.py b/tests/components/infrared/common.py index 9b524a8cec2e04..14df8099222419 100644 --- a/tests/components/infrared/common.py +++ b/tests/components/infrared/common.py @@ -17,7 +17,7 @@ class MockInfraredEntity(InfraredEntity): """Mock deprecated infrared entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR transmitter" + _attr_name = "Test IR transmitter (deprecated)" def __init__(self, unique_id: str) -> None: """Initialize mock entity.""" @@ -62,11 +62,21 @@ async def init_infrared_fixture_helper(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def mock_infrared_entity_fixture_helper( +async def mock_infrared_emitter_entity_fixture_helper( hass: HomeAssistant, -) -> MockInfraredEntity: - """Add a mock infrared entity to the running integration.""" - entity = MockInfraredEntity("test_ir_transmitter") +) -> MockInfraredEmitterEntity: + """Add a mock infrared emitter entity to the running integration.""" + entity = MockInfraredEmitterEntity("test_ir_emitter") + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([entity]) + return entity + + +async def mock_infrared_receiver_entity_fixture_helper( + hass: HomeAssistant, +) -> MockInfraredReceiverEntity: + """Add a mock infrared receiver entity to the running integration.""" + entity = MockInfraredReceiverEntity("test_ir_receiver") component = hass.data[DATA_COMPONENT] await component.async_add_entities([entity]) return entity diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index b29a2f391831f4..4a10bce1833967 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -2,10 +2,14 @@ import pytest -from .common import MockInfraredEmitterEntity, MockInfraredReceiverEntity +from .common import ( + MockInfraredEmitterEntity, + MockInfraredEntity, + MockInfraredReceiverEntity, +) -@pytest.fixture +@pytest.fixture(params=[MockInfraredEntity, MockInfraredEmitterEntity]) def mock_infrared_emitter_entity() -> MockInfraredEmitterEntity: """Return a mock infrared emitter entity.""" return MockInfraredEmitterEntity("test_ir_emitter") diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index ebf15e8b6e6815..05b13af3682fea 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -21,7 +21,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import ENTITY_ID from .common import MockInfraredEmitterEntity, MockInfraredReceiverEntity from tests.common import mock_restore_cache @@ -58,14 +57,6 @@ async def test_get_entities_filters_by_type( assert async_get_receivers(hass) == [mock_infrared_receiver_entity.entity_id] -@pytest.mark.usefixtures("mock_infrared_entity") -async def test_infrared_entity_initial_state(hass: HomeAssistant) -> None: - """Test deprecated infrared entity has no state before any command is sent.""" - state = hass.states.get(ENTITY_ID) - assert state is not None - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("init_infrared") async def test_infrared_entities_initial_state( hass: HomeAssistant, diff --git a/tests/components/lg_infrared/conftest.py b/tests/components/lg_infrared/conftest.py index 4fe112aecaa416..50b338df146ba8 100644 --- a/tests/components/lg_infrared/conftest.py +++ b/tests/components/lg_infrared/conftest.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.infrared import ENTITY_ID as MOCK_INFRARED_ENTITY_ID -from tests.components.infrared.common import MockInfraredEntity +from tests.components.infrared import EMITTER_ENTITY_ID as MOCK_INFRARED_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity @pytest.fixture @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, entry_id="01JTEST0000000000000000000", - title="LG TV via Test IR transmitter", + title="LG TV via Test IR emitter", data={ CONF_DEVICE_TYPE: LGDeviceType.TV, CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, @@ -60,7 +60,7 @@ def mock_lg_tv_code_to_command() -> Generator[None]: async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_infrared_entity: MockInfraredEntity, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, mock_lg_tv_code_to_command: None, platforms: list[Platform], ) -> MockConfigEntry: diff --git a/tests/components/lg_infrared/test_button.py b/tests/components/lg_infrared/test_button.py index b4ab5bcfca999a..1b35dbb1ce3e1f 100644 --- a/tests/components/lg_infrared/test_button.py +++ b/tests/components/lg_infrared/test_button.py @@ -12,7 +12,7 @@ from .utils import check_availability_follows_ir_entity from tests.common import MockConfigEntry, snapshot_platform -from tests.components.infrared.common import MockInfraredEntity +from tests.components.infrared.common import MockInfraredEmitterEntity @pytest.fixture @@ -80,7 +80,7 @@ async def test_entities( @pytest.mark.usefixtures("init_integration") async def test_button_press_sends_correct_code( hass: HomeAssistant, - mock_infrared_entity: MockInfraredEntity, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, entity_id: str, expected_code: LGTVCode, ) -> None: @@ -92,8 +92,8 @@ async def test_button_press_sends_correct_code( blocking=True, ) - assert len(mock_infrared_entity.send_command_calls) == 1 - assert mock_infrared_entity.send_command_calls[0] == expected_code + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/lg_infrared/test_config_flow.py b/tests/components/lg_infrared/test_config_flow.py index 9b800a1081af32..508836c406d7c4 100644 --- a/tests/components/lg_infrared/test_config_flow.py +++ b/tests/components/lg_infrared/test_config_flow.py @@ -14,10 +14,12 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -from tests.components.infrared import ENTITY_ID as MOCK_INFRARED_ENTITY_ID +from tests.components.infrared import ( + EMITTER_ENTITY_ID as mock_infrared_emitter_entity_ID, +) -@pytest.mark.usefixtures("mock_infrared_entity") +@pytest.mark.usefixtures("mock_infrared_emitter_entity") async def test_user_flow_success( hass: HomeAssistant, ) -> None: @@ -33,20 +35,20 @@ async def test_user_flow_success( result["flow_id"], user_input={ CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "LG TV via Test IR transmitter" + assert result["title"] == "LG TV via Test IR emitter" assert result["data"] == { CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, } - assert result["result"].unique_id == f"lg_ir_tv_{MOCK_INFRARED_ENTITY_ID}" + assert result["result"].unique_id == f"lg_ir_tv_{mock_infrared_emitter_entity_ID}" -@pytest.mark.usefixtures("mock_infrared_entity") +@pytest.mark.usefixtures("mock_infrared_emitter_entity") async def test_user_flow_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -63,7 +65,7 @@ async def test_user_flow_already_configured( result["flow_id"], user_input={ CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, }, ) @@ -82,11 +84,11 @@ async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: assert result["reason"] == "no_emitters" -@pytest.mark.usefixtures("mock_infrared_entity") +@pytest.mark.usefixtures("mock_infrared_emitter_entity") @pytest.mark.parametrize( ("entity_name", "expected_title"), [ - (None, "LG TV via Test IR transmitter"), + (None, "LG TV via Test IR emitter"), ("AC IR emitter", "LG TV via AC IR emitter"), ], ) @@ -97,7 +99,9 @@ async def test_user_flow_title_from_entity_name( expected_title: str, ) -> None: """Test config entry title uses the entity name.""" - entity_registry.async_update_entity(MOCK_INFRARED_ENTITY_ID, name=entity_name) + entity_registry.async_update_entity( + mock_infrared_emitter_entity_ID, name=entity_name + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -106,7 +110,7 @@ async def test_user_flow_title_from_entity_name( result["flow_id"], user_input={ CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, }, ) diff --git a/tests/components/lg_infrared/test_media_player.py b/tests/components/lg_infrared/test_media_player.py index 066534c604ba72..4ccd3f1daaa07e 100644 --- a/tests/components/lg_infrared/test_media_player.py +++ b/tests/components/lg_infrared/test_media_player.py @@ -24,7 +24,7 @@ from .utils import check_availability_follows_ir_entity from tests.common import MockConfigEntry, snapshot_platform -from tests.components.infrared.common import MockInfraredEntity +from tests.components.infrared.common import MockInfraredEmitterEntity MEDIA_PLAYER_ENTITY_ID = "media_player.lg_tv" @@ -76,7 +76,7 @@ async def test_entities( @pytest.mark.usefixtures("init_integration") async def test_media_player_action_sends_correct_code( hass: HomeAssistant, - mock_infrared_entity: MockInfraredEntity, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, service: str, service_data: dict[str, bool], expected_code: LGTVCode, @@ -89,8 +89,8 @@ async def test_media_player_action_sends_correct_code( blocking=True, ) - assert len(mock_infrared_entity.send_command_calls) == 1 - assert mock_infrared_entity.send_command_calls[0] == expected_code + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/lg_infrared/utils.py b/tests/components/lg_infrared/utils.py index eb6f5d08491828..0586280169a420 100644 --- a/tests/components/lg_infrared/utils.py +++ b/tests/components/lg_infrared/utils.py @@ -3,7 +3,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from tests.components.infrared import ENTITY_ID as MOCK_INFRARED_ENTITY_ID +from tests.components.infrared import EMITTER_ENTITY_ID as MOCK_INFRARED_ENTITY_ID async def check_availability_follows_ir_entity( From 0843c5c5fc5f17e38689dfed296f6a665ab0b7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 11 May 2026 17:42:21 +0100 Subject: [PATCH 15/21] Update homeassistant/components/infrared/__init__.py Co-authored-by: Martin Hjelmare --- homeassistant/components/infrared/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 6e7206895b46cc..d516926261299f 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -315,7 +315,7 @@ def remove_callback() -> None: @deprecated_class( "homeassistant.components.infrared.InfraredEmitterEntityDescription", - breaks_in_ha_version="2027.5", + breaks_in_ha_version="2027.6", ) class InfraredEntityDescription(InfraredEmitterEntityDescription): """Deprecated alias for InfraredEmitterEntityDescription.""" From fe07b05095281470158825d2e43ba4456adb6065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 11 May 2026 17:42:40 +0100 Subject: [PATCH 16/21] Update homeassistant/components/infrared/__init__.py Co-authored-by: Martin Hjelmare --- homeassistant/components/infrared/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index d516926261299f..964312de7bf5b9 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -323,7 +323,7 @@ class InfraredEntityDescription(InfraredEmitterEntityDescription): @deprecated_class( "homeassistant.components.infrared.InfraredEmitterEntity", - breaks_in_ha_version="2027.5", + breaks_in_ha_version="2027.6", ) class InfraredEntity(InfraredEmitterEntity): """Deprecated alias for InfraredEmitterEntity.""" From c0e6dc8679ce64bd7024792497b5e88bbe3ba898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 11 May 2026 18:31:03 +0100 Subject: [PATCH 17/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/components/infrared/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index 4a10bce1833967..dad02b18d69a77 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -10,9 +10,11 @@ @pytest.fixture(params=[MockInfraredEntity, MockInfraredEmitterEntity]) -def mock_infrared_emitter_entity() -> MockInfraredEmitterEntity: +def mock_infrared_emitter_entity( + request: pytest.FixtureRequest, +) -> MockInfraredEntity | MockInfraredEmitterEntity: """Return a mock infrared emitter entity.""" - return MockInfraredEmitterEntity("test_ir_emitter") + return request.param("test_ir_emitter") @pytest.fixture From 701096493d8d41c0128b7e970f78cce6d403c078 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 11 May 2026 18:32:24 +0100 Subject: [PATCH 18/21] Fix deprecated entity tests --- tests/components/infrared/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/infrared/common.py b/tests/components/infrared/common.py index 14df8099222419..793dcc681d073c 100644 --- a/tests/components/infrared/common.py +++ b/tests/components/infrared/common.py @@ -17,7 +17,7 @@ class MockInfraredEntity(InfraredEntity): """Mock deprecated infrared entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR transmitter (deprecated)" + _attr_name = "Test IR emitter" def __init__(self, unique_id: str) -> None: """Initialize mock entity.""" From fe00883d8a9fe41ba55b27abde0bee0453b30b77 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 11 May 2026 19:23:41 +0100 Subject: [PATCH 19/21] Add tests --- tests/components/kitchen_sink/test_event.py | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/components/kitchen_sink/test_event.py diff --git a/tests/components/kitchen_sink/test_event.py b/tests/components/kitchen_sink/test_event.py new file mode 100644 index 00000000000000..f6948da8f7fb35 --- /dev/null +++ b/tests/components/kitchen_sink/test_event.py @@ -0,0 +1,126 @@ +"""The tests for the kitchen_sink event platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from infrared_protocols.commands.nec import NECCommand +import pytest + +from homeassistant import config_entries +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.infrared import InfraredReceivedSignal +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.kitchen_sink.const import ( + CONF_INFRARED_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + INFRARED_CMD_POWER_ON, + INFRARED_FAN_ADDRESS, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID, RECEIVER_ENTITY_ID +from tests.components.infrared.common import MockInfraredReceiverEntity + +ENTITY_RECEIVED_IR_EVENT = "event.living_room_fan_received_ir_event" + + +@pytest.fixture +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.EVENT], + ): + yield + + +@pytest.fixture +async def config_entry( + hass: HomeAssistant, + event_only: None, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> MockConfigEntry: + """Set up a kitchen_sink config entry with the event platform only.""" + entry = MockConfigEntry( + domain=DOMAIN, + subentries_data=[ + config_entries.ConfigSubentryData( + data={ + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID: RECEIVER_ENTITY_ID, + }, + subentry_id="living_room_fan", + subentry_type="infrared_fan", + title="Living Room Fan", + unique_id=None, + ) + ], + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +@pytest.mark.usefixtures("config_entry") +async def test_event_receives_signal( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the event entity fires for IR signals from the receiver.""" + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + assert now is not None + freezer.move_to(now) + + command = NECCommand( + address=INFRARED_FAN_ADDRESS, command=INFRARED_CMD_POWER_ON, modulation=38000 + ) + raw_timings = command.get_raw_timings() + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=raw_timings) + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_RECEIVED_IR_EVENT)) is not None + assert state.state == now.isoformat(timespec="milliseconds") + assert state.attributes[ATTR_EVENT_TYPE] == "power_on" + assert state.attributes["raw_code"] == raw_timings + + +@pytest.mark.usefixtures("config_entry") +async def test_event_resubscribes_after_receiver_reload( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the event entity resubscribes after the receiver is reloaded.""" + assert (state := hass.states.get(ENTITY_RECEIVED_IR_EVENT)) is not None + assert state.state != STATE_UNAVAILABLE + + hass.states.async_set(RECEIVER_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert (state := hass.states.get(ENTITY_RECEIVED_IR_EVENT)) is not None + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(RECEIVER_ENTITY_ID, STATE_UNKNOWN) + await hass.async_block_till_done() + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + assert now is not None + freezer.move_to(now) + + command = NECCommand( + address=INFRARED_FAN_ADDRESS, command=INFRARED_CMD_POWER_ON, modulation=38000 + ) + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_RECEIVED_IR_EVENT)) is not None + assert state.state == now.isoformat(timespec="milliseconds") From 6fbc239a6bc2fdbf495eb375039c4e36261b93f8 Mon Sep 17 00:00:00 2001 From: abmantis Date: Tue, 12 May 2026 15:29:08 +0100 Subject: [PATCH 20/21] Use common mocks --- tests/components/infrared/conftest.py | 27 ++++++------ tests/components/infrared/test_init.py | 60 ++++---------------------- 2 files changed, 23 insertions(+), 64 deletions(-) diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index dad02b18d69a77..41273c25491b0d 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -2,22 +2,23 @@ import pytest -from .common import ( - MockInfraredEmitterEntity, - MockInfraredEntity, - MockInfraredReceiverEntity, -) +from homeassistant.components.infrared import DATA_COMPONENT +from homeassistant.core import HomeAssistant + +from .common import MockInfraredEmitterEntity, MockInfraredEntity @pytest.fixture(params=[MockInfraredEntity, MockInfraredEmitterEntity]) -def mock_infrared_emitter_entity( +async def mock_infrared_emitter_entity( + hass: HomeAssistant, + init_infrared: None, request: pytest.FixtureRequest, ) -> MockInfraredEntity | MockInfraredEmitterEntity: - """Return a mock infrared emitter entity.""" - return request.param("test_ir_emitter") - + """Return a mock infrared emitter entity. -@pytest.fixture -def mock_infrared_receiver_entity() -> MockInfraredReceiverEntity: - """Return a mock infrared receiver entity.""" - return MockInfraredReceiverEntity("test_ir_receiver") + This overrides the default common fixture to also test the deprecated MockInfraredEntity. + """ + entity = request.param("test_ir_emitter") + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([entity]) + return entity diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 05b13af3682fea..fa98a118c5d4b1 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -41,50 +41,33 @@ async def test_get_entities_empty(hass: HomeAssistant) -> None: assert async_get_receivers(hass) == [] -@pytest.mark.usefixtures("init_infrared") async def test_get_entities_filters_by_type( hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity, mock_infrared_receiver_entity: MockInfraredReceiverEntity, ) -> None: """Test get_emitters/get_receivers return only entities of the matching type.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities( - [mock_infrared_emitter_entity, mock_infrared_receiver_entity] - ) - assert async_get_emitters(hass) == [mock_infrared_emitter_entity.entity_id] assert async_get_receivers(hass) == [mock_infrared_receiver_entity.entity_id] -@pytest.mark.usefixtures("init_infrared") -async def test_infrared_entities_initial_state( - hass: HomeAssistant, - mock_infrared_emitter_entity: MockInfraredEmitterEntity, - mock_infrared_receiver_entity: MockInfraredReceiverEntity, -) -> None: +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", "mock_infrared_receiver_entity" +) +async def test_infrared_entities_initial_state(hass: HomeAssistant) -> None: """Test infrared entities have no state before any command is sent.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities( - [mock_infrared_emitter_entity, mock_infrared_receiver_entity] - ) - assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None assert emitter_state.state == STATE_UNKNOWN assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None assert receiver_state.state == STATE_UNKNOWN -@pytest.mark.usefixtures("init_infrared") async def test_async_send_command_success( hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test sending command via async_send_command helper.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_emitter_entity]) - now = dt_util.utcnow() freezer.move_to(now) @@ -98,15 +81,11 @@ async def test_async_send_command_success( assert state.state == now.isoformat(timespec="milliseconds") -@pytest.mark.usefixtures("init_infrared") async def test_async_send_command_error_does_not_update_state( hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity, ) -> None: """Test that state is not updated when async_send_command raises an error.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_emitter_entity]) - state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == STATE_UNKNOWN @@ -135,15 +114,11 @@ async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None: await async_send_command(hass, "infrared.nonexistent_entity", TEST_COMMAND) -@pytest.mark.usefixtures("init_infrared") async def test_async_send_command_rejects_receiver( hass: HomeAssistant, mock_infrared_receiver_entity: MockInfraredReceiverEntity, ) -> None: """Test async_send_command rejects a receiver entity.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_receiver_entity]) - with pytest.raises( HomeAssistantError, match=f"Infrared entity `{mock_infrared_receiver_entity.entity_id}` not found", @@ -167,11 +142,7 @@ async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> N ], ) async def test_infrared_entity_state_restore( - hass: HomeAssistant, - mock_infrared_emitter_entity: MockInfraredEmitterEntity, - mock_infrared_receiver_entity: MockInfraredReceiverEntity, - restored_value: str, - expected_state: str, + hass: HomeAssistant, restored_value: str, expected_state: str ) -> None: """Test infrared entity state restore.""" mock_restore_cache( @@ -187,7 +158,10 @@ async def test_infrared_entity_state_restore( component = hass.data[DATA_COMPONENT] await component.async_add_entities( - [mock_infrared_emitter_entity, mock_infrared_receiver_entity] + [ + MockInfraredEmitterEntity("test_ir_emitter"), + MockInfraredReceiverEntity("test_ir_receiver"), + ] ) assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None @@ -196,16 +170,12 @@ async def test_infrared_entity_state_restore( assert receiver_state.state == expected_state -@pytest.mark.usefixtures("init_infrared") async def test_async_subscribe_receiver_success( hass: HomeAssistant, mock_infrared_receiver_entity: MockInfraredReceiverEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test subscribing to a receiver via async_subscribe_receiver helper.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_receiver_entity]) - now = dt_util.utcnow() freezer.move_to(now) @@ -229,16 +199,12 @@ async def test_async_subscribe_receiver_success( assert signal_callback.call_count == 1 -@pytest.mark.usefixtures("init_infrared") async def test_handle_received_signal_isolates_callback_errors( hass: HomeAssistant, mock_infrared_receiver_entity: MockInfraredReceiverEntity, caplog: pytest.LogCaptureFixture, ) -> None: """Test a failing subscriber does not prevent other subscribers from running.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_receiver_entity]) - failing_callback = Mock(side_effect=RuntimeError("boom")) working_callback = Mock() async_subscribe_receiver( @@ -256,15 +222,11 @@ async def test_handle_received_signal_isolates_callback_errors( assert "Error in signal callback" in caplog.text -@pytest.mark.usefixtures("init_infrared") async def test_handle_received_signal_unsubscribe_during_dispatch( hass: HomeAssistant, mock_infrared_receiver_entity: MockInfraredReceiverEntity, ) -> None: """Test a subscriber can unsubscribe itself during dispatch without error.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_receiver_entity]) - other_callback = Mock() def unsubscribing_callback(signal: InfraredReceivedSignal) -> None: @@ -305,15 +267,11 @@ async def test_async_subscribe_receiver_not_found( async_subscribe_receiver(hass, entity_id_or_uuid, lambda _: None) -@pytest.mark.usefixtures("init_infrared") async def test_async_subscribe_receiver_rejects_emitter( hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity, ) -> None: """Test async_subscribe_receiver rejects an emitter entity.""" - component = hass.data[DATA_COMPONENT] - await component.async_add_entities([mock_infrared_emitter_entity]) - with pytest.raises( HomeAssistantError, match=( From c7f116dc0355b50d8d5a194173ae7ce9f4d29461 Mon Sep 17 00:00:00 2001 From: abmantis Date: Tue, 12 May 2026 15:36:51 +0100 Subject: [PATCH 21/21] Fix case --- tests/components/lg_infrared/test_config_flow.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/components/lg_infrared/test_config_flow.py b/tests/components/lg_infrared/test_config_flow.py index 508836c406d7c4..df6df625138f94 100644 --- a/tests/components/lg_infrared/test_config_flow.py +++ b/tests/components/lg_infrared/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry from tests.components.infrared import ( - EMITTER_ENTITY_ID as mock_infrared_emitter_entity_ID, + EMITTER_ENTITY_ID as mock_infrared_emitter_entity_id, ) @@ -35,7 +35,7 @@ async def test_user_flow_success( result["flow_id"], user_input={ CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id, }, ) @@ -43,9 +43,9 @@ async def test_user_flow_success( assert result["title"] == "LG TV via Test IR emitter" assert result["data"] == { CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id, } - assert result["result"].unique_id == f"lg_ir_tv_{mock_infrared_emitter_entity_ID}" + assert result["result"].unique_id == f"lg_ir_tv_{mock_infrared_emitter_entity_id}" @pytest.mark.usefixtures("mock_infrared_emitter_entity") @@ -65,7 +65,7 @@ async def test_user_flow_already_configured( result["flow_id"], user_input={ CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id, }, ) @@ -100,7 +100,7 @@ async def test_user_flow_title_from_entity_name( ) -> None: """Test config entry title uses the entity name.""" entity_registry.async_update_entity( - mock_infrared_emitter_entity_ID, name=entity_name + mock_infrared_emitter_entity_id, name=entity_name ) result = await hass.config_entries.flow.async_init( @@ -110,7 +110,7 @@ async def test_user_flow_title_from_entity_name( result["flow_id"], user_input={ CONF_DEVICE_TYPE: LGDeviceType.TV, - CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_ID, + CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id, }, )