Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
62dc48d
Add infrared receiver entity
abmantis Apr 24, 2026
4086d43
Minor improvements; update kitchen_sink
abmantis Apr 30, 2026
7eeea90
Update integrations
abmantis Apr 30, 2026
5d65d3e
Merge branch 'dev' of github.com:home-assistant/core into ir_receiver
abmantis Apr 30, 2026
49ab12c
Update broadlink
abmantis Apr 30, 2026
7e7590c
Address Copilot feedback
abmantis Apr 30, 2026
309afb3
RestoreEntity + tests
abmantis Apr 30, 2026
a9bcf42
Lazy init __signal_callbacks
abmantis Apr 30, 2026
7a7b0e2
Update kitchen_sink
abmantis May 4, 2026
2b65c8c
Fix subscription; update test
abmantis May 4, 2026
07f1ce7
Merge branch 'dev' into ir_receiver
abmantis May 5, 2026
c6f8d9e
Merge branch 'dev' of github.com:home-assistant/core into ir_receiver
abmantis May 8, 2026
b03ab00
Fix deprecated_class to work with inheritance
abmantis May 8, 2026
e9b36fa
Address review feedback
abmantis May 8, 2026
82378b3
Merge branch 'dev' into ir_receiver and resolve conflicts
Copilot May 11, 2026
f3350dd
Guard agasint no-emitter kitchen sink fan
abmantis May 11, 2026
e8a3e54
Decode raw timings in kitchen sink event
abmantis May 11, 2026
6dceaba
Update common IR fixtures to new naming
abmantis May 11, 2026
0843c5c
Update homeassistant/components/infrared/__init__.py
abmantis May 11, 2026
fe07b05
Update homeassistant/components/infrared/__init__.py
abmantis May 11, 2026
c0e6dc8
Potential fix for pull request finding
abmantis May 11, 2026
7010964
Fix deprecated entity tests
abmantis May 11, 2026
fe00883
Add tests
abmantis May 11, 2026
6fbc239
Use common mocks
abmantis May 12, 2026
c7f116d
Fix case
abmantis May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions homeassistant/components/broadlink/infrared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Comment thread
abmantis marked this conversation as resolved.
"""Broadlink infrared emitter entity."""

_attr_has_entity_name = True
_attr_translation_key = "infrared_emitter"
Expand Down
8 changes: 5 additions & 3 deletions homeassistant/components/esphome/infrared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Comment thread
abmantis marked this conversation as resolved.
):
"""ESPHome infrared emitter entity using native API."""

@callback
def _on_device_update(self) -> None:
Expand Down
204 changes: 191 additions & 13 deletions homeassistant/components/infrared/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Comment thread
abmantis marked this conversation as resolved.


_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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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)
Comment thread
abmantis marked this conversation as resolved.
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",
Expand All @@ -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.
"""
Comment thread
abmantis marked this conversation as resolved.
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
Comment thread
abmantis marked this conversation as resolved.
Comment thread
abmantis marked this conversation as resolved.
_attr_should_poll = False
_attr_state: None = None

Comment thread
abmantis marked this conversation as resolved.
Expand Down Expand Up @@ -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"
)
Comment thread
abmantis marked this conversation as resolved.
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)
Comment thread
abmantis marked this conversation as resolved.

@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):
Comment thread
abmantis marked this conversation as resolved.
"""Deprecated alias for InfraredEmitterEntity."""
3 changes: 3 additions & 0 deletions homeassistant/components/infrared/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"entity_component": {
"_": {
"default": "mdi:led-on"
},
"receiver": {
"default": "mdi:led-off"
}
}
}
11 changes: 11 additions & 0 deletions homeassistant/components/infrared/strings.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
{
"entity_component": {
"_": {
"name": "Infrared emitter"
},
Comment thread
abmantis marked this conversation as resolved.
"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"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/components/kitchen_sink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
Expand Down
Loading