"""Helper base entities for integrations that consume infrared emitters/receivers.""" from abc import abstractmethod from collections.abc import Callable import logging from typing import override from infrared_protocols.commands import Command as InfraredCommand import voluptuous as vol from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import ( CALLBACK_TYPE, Context, Event, EventStateChangedData, HomeAssistant, callback, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change_event from .const import DATA_COMPONENT, DOMAIN from .entity import ( InfraredEmitterEntity, InfraredReceivedSignal, InfraredReceiverEntity, ) _LOGGER = logging.getLogger(__name__) async def async_send_command( hass: HomeAssistant, entity_id_or_uuid: str, command: InfraredCommand, context: Context | None = None, ) -> None: """Send an IR command to the specified infrared entity. Raises: HomeAssistantError: If the infrared 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, InfraredEmitterEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="entity_not_found", translation_placeholders={"entity_id": entity_id}, ) if context is not None: entity.async_set_context(context) await entity.async_send_command_internal(command) @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) class InfraredConsumerEntity(Entity): """Base class for entities that track the availability of an infrared entity.""" @callback def _async_track_availability(self, infrared_entity_id: str) -> CALLBACK_TYPE: """Track the availability of an infrared entity. Sets initial availability and subscribes to state changes. Returns an unsubscribe callback. """ @callback def state_changed(event: Event[EventStateChangedData]) -> None: new_state = event.data["new_state"] ir_available = ( new_state is not None and new_state.state != STATE_UNAVAILABLE ) if ir_available != self.available: _LOGGER.info( "Infrared entity %s used by %s is %s", infrared_entity_id, self.entity_id, "available" if ir_available else "unavailable", ) self._async_infrared_availability_changed(ir_available) ir_state = self.hass.states.get(infrared_entity_id) self._attr_available = ( ir_state is not None and ir_state.state != STATE_UNAVAILABLE ) return async_track_state_change_event( self.hass, [infrared_entity_id], state_changed ) @callback def _async_infrared_availability_changed(self, available: bool) -> None: """Update availability. Override to react to changes.""" self._attr_available = available self.async_write_ha_state() class InfraredEmitterConsumerEntity(InfraredConsumerEntity): """Base entity for integrations that send commands via an infrared emitter. Tracks the availability of the underlying infrared emitter entity. """ _attr_should_poll = False _infrared_emitter_entity_id: str @override async def async_added_to_hass(self) -> None: """Subscribe to infrared entity state changes.""" await super().async_added_to_hass() self.async_on_remove( self._async_track_availability(self._infrared_emitter_entity_id) ) async def _send_command(self, command: InfraredCommand) -> None: """Send an IR command through the infrared emitter entity.""" await async_send_command( self.hass, self._infrared_emitter_entity_id, command, context=self._context ) class InfraredReceiverConsumerEntity(InfraredConsumerEntity): """Base entity for integrations that consume signals from an infrared receiver. Tracks the availability of the underlying infrared receiver entity and manages the subscription to received IR signals. """ _attr_should_poll = False _infrared_receiver_entity_id: str _remove_signal_subscription: CALLBACK_TYPE | None = None @override async def async_added_to_hass(self) -> None: """Subscribe to infrared entity state changes and receiver signals.""" await super().async_added_to_hass() self.async_on_remove( self._async_track_availability(self._infrared_receiver_entity_id) ) self._async_update_receiver_subscription() self.async_on_remove(self._async_unsubscribe_receiver) @override @callback def _async_infrared_availability_changed(self, available: bool) -> None: """Update availability and manage receiver subscription.""" super()._async_infrared_availability_changed(available) self._async_update_receiver_subscription() @callback @abstractmethod def _handle_signal(self, signal: InfraredReceivedSignal) -> None: """Handle a received IR signal.""" @callback def _async_unsubscribe_receiver(self) -> None: """Unsubscribe from the current IR receiver.""" if self._remove_signal_subscription is None: return self._remove_signal_subscription() self._remove_signal_subscription = None @callback def _async_update_receiver_subscription(self) -> None: """Update the IR receiver subscription when availability changes.""" if not self.available: self._async_unsubscribe_receiver() elif self._remove_signal_subscription is None: _LOGGER.debug( "Subscribing to infrared receiver entity %s for %s", self._infrared_receiver_entity_id, self.entity_id, ) self._remove_signal_subscription = async_subscribe_receiver( self.hass, self._infrared_receiver_entity_id, self._handle_signal )