diff --git a/homeassistant/components/broadlink/infrared.py b/homeassistant/components/broadlink/infrared.py index 32ec791b85786f..0fba9e5beffd5a 100644 --- a/homeassistant/components/broadlink/infrared.py +++ b/homeassistant/components/broadlink/infrared.py @@ -5,7 +5,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 @@ -43,8 +43,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" diff --git a/homeassistant/components/esphome/infrared.py b/homeassistant/components/esphome/infrared.py index de11e421b6fe49..27e557926c6d46 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -5,7 +5,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 ( @@ -19,8 +19,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/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 7a57b4a04c4187..964312de7bf5b9 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -1,17 +1,23 @@ """Provides functionality to interact with infrared devices.""" 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 from infrared_protocols.commands import Command as InfraredCommand +from propcache.api import cached_property +import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Context, HomeAssistant, callback +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 @@ -23,15 +29,32 @@ __all__ = [ "DOMAIN", + "InfraredEmitterEntity", + "InfraredEmitterEntityDescription", "InfraredEntity", "InfraredEntityDescription", + "InfraredReceivedSignal", + "InfraredReceiverEntity", + "InfraredReceiverEntityDescription", "async_get_emitters", + "async_get_receivers", "async_send_command", + "async_subscribe_receiver", ] + +class InfraredDeviceClass(StrEnum): + """Device class for infrared entities.""" + + EMITTER = "emitter" + RECEIVER = "receiver" + + _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 @@ -40,9 +63,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 @@ -65,7 +88,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( @@ -89,7 +130,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", @@ -102,14 +143,62 @@ 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) + 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_or_uuid}, + ) from err + + 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.""" - entity_description: InfraredEntityDescription +class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True): + """Describes infrared emitter entities.""" + + +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 @@ -149,3 +238,92 @@ 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(RestoreEntity): + """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 + + @cached_property + def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]: + """Subscriber callback set, lazily initialized on first access.""" + return set() + + @property + @final + 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, + STATE_UNKNOWN, + None, + ): + self.__last_signal_received = state.state + + @final + def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal. + + 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" + ) + self.async_write_ha_state() + for signal_callback in tuple(self.__signal_callbacks): + try: + signal_callback(signal) + except Exception: + _LOGGER.exception("Error in signal callback for %s", self.entity_id) + + @callback + def async_subscribe_received_signal( + self, + signal_callback: Callable[[InfraredReceivedSignal], None], + ) -> CALLBACK_TYPE: + """Subscribe to received IR signals. + + Returns a callable to unsubscribe. + """ + callbacks = self.__signal_callbacks + callbacks.add(signal_callback) + + @callback + def remove_callback() -> None: + callbacks.discard(signal_callback) + + return remove_callback + + +@deprecated_class( + "homeassistant.components.infrared.InfraredEmitterEntityDescription", + breaks_in_ha_version="2027.6", +) +class InfraredEntityDescription(InfraredEmitterEntityDescription): + """Deprecated alias for InfraredEmitterEntityDescription.""" + + +@deprecated_class( + "homeassistant.components.infrared.InfraredEmitterEntity", + breaks_in_ha_version="2027.6", +) +class InfraredEntity(InfraredEmitterEntity): + """Deprecated alias for InfraredEmitterEntity.""" 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/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 6441130f6c89b0..6a6959477fcf22 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -55,6 +55,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 5fa493b2f16a73..d5eca451bda0db 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components.infrared import ( DOMAIN as INFRARED_DOMAIN, async_get_emitters, + async_get_receivers, ) from homeassistant.config_entries import ( ConfigEntry, @@ -22,7 +23,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" @@ -178,25 +179,36 @@ 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) + receiver_entities = async_get_receivers(self.hass) + + if not emitter_entities and not receiver_entities: + return self.async_abort(reason="no_infrared_entities") + + schema_dict: dict[vol.Marker, Any] = { + vol.Required("name"): str, + } + + if emitter_entities: + schema_dict[vol.Optional(CONF_INFRARED_ENTITY_ID)] = EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entities, + ) + ) + + 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 6e506f7c334220..249cb8bb8f0f15 100644 --- a/homeassistant/components/kitchen_sink/const.py +++ b/homeassistant/components/kitchen_sink/const.py @@ -6,6 +6,14 @@ 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" ) + +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 new file mode 100644 index 00000000000000..5250cf75845dec --- /dev/null +++ b/homeassistant/components/kitchen_sink/event.py @@ -0,0 +1,151 @@ +"""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, + 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, + 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, + 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 = list(COMMAND_EVENTS.values()) + + 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.""" + 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 + + @callback + 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) + receiver_available = ( + ir_state is not None and ir_state.state != STATE_UNAVAILABLE + ) + + 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 + + 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_update_receiver_subscription() + + _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/homeassistant/components/kitchen_sink/fan.py b/homeassistant/components/kitchen_sink/fan.py index 838f31d4dd8833..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, @@ -34,6 +36,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( @@ -103,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, ) @@ -121,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() @@ -138,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() diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py index 6ba021bf2d2cfd..b65d8ab3cfbe2d 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -3,16 +3,27 @@ from infrared_protocols.commands import Command as InfraredCommand 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, @@ -22,37 +33,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: InfraredCommand) -> 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_dispatcher_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_dispatcher_signal + ) ) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index e369e0942bdc77..01a2788f3c58e0 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_infrared_entities": "No infrared 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 infrared devices for the fan." } } } diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py index 063d4f3bab9cca..26ecd6f0779e53 100644 --- a/homeassistant/components/smlight/infrared.py +++ b/homeassistant/components/smlight/infrared.py @@ -3,7 +3,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 @@ -27,8 +27,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/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 7d07763e013b42..793dcc681d073c 100644 --- a/tests/components/infrared/common.py +++ b/tests/components/infrared/common.py @@ -2,17 +2,22 @@ from infrared_protocols.commands import Command as InfraredCommand -from homeassistant.components.infrared import DATA_COMPONENT, InfraredEntity +from homeassistant.components.infrared import ( + DATA_COMPONENT, + InfraredEmitterEntity, + InfraredEntity, + InfraredReceiverEntity, +) from homeassistant.components.infrared.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component class MockInfraredEntity(InfraredEntity): - """Mock infrared entity for testing.""" + """Mock deprecated infrared 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.""" @@ -24,17 +29,54 @@ async def async_send_command(self, command: InfraredCommand) -> None: self.send_command_calls.append(command) +class MockInfraredEmitterEntity(InfraredEmitterEntity): + """Mock 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.""" + 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 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.""" + self._attr_unique_id = unique_id + + async def init_infrared_fixture_helper(hass: HomeAssistant) -> None: """Set up the Infrared integration for testing.""" assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() -async def mock_infrared_entity_fixture_helper( +async def mock_infrared_emitter_entity_fixture_helper( + hass: HomeAssistant, +) -> 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, -) -> MockInfraredEntity: - """Add a mock infrared entity to the running integration.""" - entity = MockInfraredEntity("test_ir_transmitter") +) -> 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 new file mode 100644 index 00000000000000..41273c25491b0d --- /dev/null +++ b/tests/components/infrared/conftest.py @@ -0,0 +1,24 @@ +"""Common fixtures for the Infrared tests.""" + +import pytest + +from homeassistant.components.infrared import DATA_COMPONENT +from homeassistant.core import HomeAssistant + +from .common import MockInfraredEmitterEntity, MockInfraredEntity + + +@pytest.fixture(params=[MockInfraredEntity, MockInfraredEmitterEntity]) +async def mock_infrared_emitter_entity( + hass: HomeAssistant, + init_infrared: None, + request: pytest.FixtureRequest, +) -> MockInfraredEntity | MockInfraredEmitterEntity: + """Return a mock infrared emitter entity. + + 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 5a1e8f37e83ec0..fa98a118c5d4b1 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.commands.nec 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,70 +21,85 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import ENTITY_ID -from .common import MockInfraredEntity +from .common import MockInfraredEmitterEntity, MockInfraredReceiverEntity 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) == [] @pytest.mark.usefixtures("init_infrared") 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("mock_infrared_entity") -async def test_infrared_entity_initial_state(hass: HomeAssistant) -> None: - """Test 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 +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.""" + 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( + "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.""" + 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 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.""" now = dt_util.utcnow() freezer.move_to(now) - command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) - await async_send_command(hass, ENTITY_ID, command) + await async_send_command(hass, mock_infrared_emitter_entity.entity_id, TEST_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 TEST_COMMAND - state = hass.states.get(ENTITY_ID) + state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == now.isoformat(timespec="milliseconds") 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.""" - state = hass.states.get(ENTITY_ID) + 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, ENTITY_ID, command) + await async_send_command( + hass, mock_infrared_emitter_entity.entity_id, TEST_COMMAND + ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get("infrared.test_ir_emitter") assert state is not None assert state.state == STATE_UNKNOWN @@ -89,25 +107,31 @@ async def test_async_send_command_error_does_not_update_state( @pytest.mark.usefixtures("init_infrared") 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) + + +async def test_async_send_command_rejects_receiver( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test async_send_command rejects a 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( @@ -118,19 +142,151 @@ async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> N ], ) async def test_infrared_entity_state_restore( - hass: HomeAssistant, - restored_value: str, - expected_state: str, + hass: HomeAssistant, restored_value: str, expected_state: str ) -> None: """Test infrared entity state restore.""" - mock_restore_cache(hass, [State(ENTITY_ID, 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([MockInfraredEntity("test_ir_transmitter")]) + await component.async_add_entities( + [ + MockInfraredEmitterEntity("test_ir_emitter"), + MockInfraredReceiverEntity("test_ir_receiver"), + ] + ) + + assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None + assert emitter_state.state == expected_state + assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None + assert receiver_state.state == expected_state + - state = hass.states.get(ENTITY_ID) +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.""" + 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") + + unsubscribe() + mock_infrared_receiver_entity._handle_received_signal(signal) + assert signal_callback.call_count == 1 + + +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.""" + 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 + + +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.""" + 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_infrared") +@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) + + +async def test_async_subscribe_receiver_rejects_emitter( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test async_subscribe_receiver rejects an emitter entity.""" + with pytest.raises( + HomeAssistantError, + match=( + f"Infrared receiver entity `{mock_infrared_emitter_entity.entity_id}`" + " not found" + ), + ): + async_subscribe_receiver( + hass, mock_infrared_emitter_entity.entity_id, 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) diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 77733a7f4a0d29..a28d7c67ce5c38 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -14,7 +14,8 @@ from tests.common import MockConfigEntry -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 @@ -227,7 +228,8 @@ 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, + "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_TRANSMITTER}, + 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", @@ -246,8 +251,10 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("no_platforms") -async def test_infrared_fan_subentry_flow_no_emitters(hass: HomeAssistant) -> None: - """Test infrared fan subentry flow aborts when no emitters are available.""" +async def test_infrared_fan_subentry_flow_no_infrared_entities( + hass: HomeAssistant, +) -> None: + """Test infrared fan subentry flow aborts when no infrared entities are available.""" config_entry = MockConfigEntry(domain=DOMAIN) config_entry.add_to_hass(hass) @@ -259,4 +266,4 @@ async def test_infrared_fan_subentry_flow_no_emitters(hass: HomeAssistant) -> No context={"source": config_entries.SOURCE_USER}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_emitters" + assert result["reason"] == "no_infrared_entities" 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") diff --git a/tests/components/kitchen_sink/test_infrared.py b/tests/components/kitchen_sink/test_infrared.py index cdcc4a14a1517c..24a5f03c101779 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 from infrared_protocols.commands.nec import NECCommand 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,21 +37,25 @@ 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 freezer.move_to(now) command = 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() 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..df6df625138f94 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(