Compare commits

...

11 Commits

Author SHA1 Message Date
Abílio Costa 07f1ce7fe2 Merge branch 'dev' into ir_receiver 2026-05-05 10:13:43 +01:00
abmantis 2b65c8c992 Fix subscription; update test 2026-05-04 22:46:21 +01:00
abmantis 7a7b0e294c Update kitchen_sink 2026-05-04 22:24:29 +01:00
abmantis a9bcf42388 Lazy init __signal_callbacks 2026-04-30 20:13:02 +01:00
abmantis 309afb3efb RestoreEntity + tests 2026-04-30 19:58:10 +01:00
abmantis 7e7590c8e2 Address Copilot feedback
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:29:28 +01:00
abmantis 49ab12c950 Update broadlink 2026-04-30 19:20:44 +01:00
abmantis 5d65d3e27b Merge branch 'dev' of github.com:home-assistant/core into ir_receiver 2026-04-30 19:18:14 +01:00
abmantis 7eeea9060d Update integrations 2026-04-30 19:07:35 +01:00
abmantis 4086d43a1b Minor improvements; update kitchen_sink 2026-04-30 17:59:27 +01:00
abmantis 62dc48ddd3 Add infrared receiver entity 2026-04-25 00:30:05 +01:00
17 changed files with 687 additions and 128 deletions
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
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"
+5 -3
View File
@@ -5,7 +5,7 @@ import logging
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 @@ _LOGGER = logging.getLogger(__name__)
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:
+169 -14
View File
@@ -1,15 +1,20 @@
"""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 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.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
@@ -23,15 +28,30 @@ from .const import DOMAIN
__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
@@ -40,9 +60,9 @@ SCAN_INTERVAL = timedelta(seconds=30)
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 +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(
@@ -89,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",
@@ -102,14 +140,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)
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
@dataclass(frozen=True, slots=True)
class InfraredReceivedSignal:
"""Represents a received IR signal."""
entity_description: InfraredEntityDescription
timings: list[int]
modulation: int | None = None
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 +235,72 @@ class InfraredEntity(RestoreEntity):
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, 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
@@ -2,6 +2,9 @@
"entity_component": {
"_": {
"default": "mdi:led-on"
},
"receiver": {
"default": "mdi:led-off"
}
}
}
@@ -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"
}
}
}
@@ -55,6 +55,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
@@ -9,6 +9,7 @@ from homeassistant import data_entry_flow
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.core import callback
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,33 @@ class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
) -> 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))
@@ -6,6 +6,7 @@ from homeassistant.util.hass_dict import HassKey
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"
)
@@ -0,0 +1,126 @@
"""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_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
)
)
@@ -3,16 +3,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,
@@ -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: 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_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
)
)
@@ -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."
}
}
}
+3 -3
View File
@@ -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"
+28 -7
View File
@@ -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,14 +19,15 @@ 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."""
super().__init__()
self._attr_unique_id = unique_id
self.send_command_calls: list[InfraredCommand] = []
@@ -33,6 +37,23 @@ class MockInfraredEntity(InfraredEntity):
@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."""
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")
+226 -44
View File
@@ -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 @@ import pytest
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,57 +21,79 @@ from homeassistant.exceptions import HomeAssistantError
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
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_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_get_entities_filters_by_type(
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 get_emitters/get_receivers return only entities of the matching type."""
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 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,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> 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_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, 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("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 +101,26 @@ 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, TEST_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
@@ -104,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(
@@ -134,19 +168,167 @@ 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]
)
state = hass.states.get("infrared.test_ir_transmitter")
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
@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."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_receiver_entity])
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
@pytest.mark.usefixtures("init_integration")
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=(
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)
@@ -14,7 +14,8 @@ from homeassistant.setup import async_setup_component
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",
+19 -11
View File
@@ -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()
+3 -3
View File
@@ -9,7 +9,7 @@ import pytest
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 (
@@ -27,8 +27,8 @@ from tests.common import MockConfigEntry
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"