Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 5 additions & 3 deletions homeassistant/components/esphome/infrared.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo

from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.core import callback

from .entity import (
Expand All @@ -21,8 +21,10 @@
PARALLEL_UPDATES = 0


class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
Comment thread
abmantis marked this conversation as resolved.
):
"""ESPHome infrared emitter entity using native API."""

@callback
def _on_device_update(self) -> None:
Expand Down
175 changes: 160 additions & 15 deletions homeassistant/components/infrared/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
from __future__ import annotations

from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
import logging
from typing import final

from infrared_protocols import Command as InfraredCommand
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
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
Expand All @@ -25,15 +29,30 @@

__all__ = [
"DOMAIN",
"InfraredEntity",
"InfraredEntityDescription",
Comment thread
abmantis marked this conversation as resolved.
"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"
Comment thread
abmantis marked this conversation as resolved.
EMITTER = "emitter"
Comment thread
abmantis marked this conversation as resolved.
Outdated


_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 @@ -42,9 +61,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 @@ -67,7 +86,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 @@ -91,7 +128,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 @@ -104,14 +141,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},
Comment thread
abmantis marked this conversation as resolved.
Outdated
) from err

class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
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},
)

entity_description: InfraredEntityDescription
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 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 @@ -151,3 +236,63 @@ async def async_send_command(self, command: InfraredCommand) -> None:
Raises:
HomeAssistantError: If transmission fails.
"""


class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared receiver entities."""


class InfraredReceiverEntity(Entity):
"""Base class for infrared receiver entities."""

entity_description: InfraredReceiverEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER
_attr_should_poll = False
_attr_state: None = None

__last_signal_received: str | None = None

def __init__(self) -> None:
"""Initialize the receiver entity."""
super().__init__()
self.__signal_callbacks: set[Callable[[InfraredReceivedSignal], None]] = set()
Comment thread
abmantis marked this conversation as resolved.
Outdated

@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_signal_received

@final
def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal.

Called by platform implementations when a signal is received.
Updates entity state and notifies subscribers.
"""
self.__last_signal_received = dt_util.utcnow().isoformat(
timespec="milliseconds"
)
Comment thread
abmantis marked this conversation as resolved.
self.async_write_ha_state()
for signal_callback in 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.
"""
self.__signal_callbacks.add(signal_callback)

@callback
def remove_callback() -> None:
self.__signal_callbacks.discard(signal_callback)

return remove_callback
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"
}
}
}
66 changes: 50 additions & 16 deletions homeassistant/components/kitchen_sink/infrared.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,27 @@
import infrared_protocols

from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredEntity
from homeassistant.components.infrared import (
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import DOMAIN

PARALLEL_UPDATES = 0

INFRARED_COMMAND_SIGNAL = f"{DOMAIN}_infrared_command_signal"


async def async_setup_entry(
hass: HomeAssistant,
Expand All @@ -24,37 +35,60 @@ async def async_setup_entry(
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
DemoInfraredEmitter(
unique_id="ir_emitter",
entity_name="Infrared Emitter",
),
DemoInfraredReceiver(
unique_id="ir_receiver",
entity_name="Infrared Receiver",
),
]
)


class DemoInfrared(InfraredEntity):
# pylint: disable=hass-enforce-class-module
class DemoInfraredEntityBase(Entity):
"""Representation of a demo infrared entity."""

_attr_has_entity_name = True
_attr_should_poll = False

def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
def __init__(self, unique_id: str, entity_name: str) -> None:
"""Initialize the demo infrared entity."""
super().__init__()
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
identifiers={(DOMAIN, "infrared")}, name="IR Blaster"
)
self._attr_name = entity_name


class DemoInfraredEmitter(DemoInfraredEntityBase, InfraredEmitterEntity):
Comment thread
abmantis marked this conversation as resolved.
"""Representation of a demo infrared emitter entity."""

async def async_send_command(self, command: infrared_protocols.Command) -> None:
"""Send an IR command."""
raw_timings = command.get_raw_timings()
persistent_notification.async_create(
self.hass, str(command.get_raw_timings()), title="Infrared Command"
self.hass, str(raw_timings), title="Infrared Command Sent"
)
async_dispatcher_send(self.hass, INFRARED_COMMAND_SIGNAL, raw_timings)


class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity):
"""Representation of a demo infrared receiver entity."""

@callback
def _on_dispacher_signal(self, raw_timings: list[int]) -> None:
"""Handle received infrared command signal."""
self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings))
Comment thread
abmantis marked this conversation as resolved.

async def async_added_to_hass(self) -> None:
"""Called when entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispacher_signal
)
)
Loading
Loading