Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
163 changes: 148 additions & 15 deletions homeassistant/components/infrared/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
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

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 +28,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 +60,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 +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(
Expand All @@ -91,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)
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 +140,54 @@ 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)
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, 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."""
class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared emitter entities."""

entity_description: InfraredEntityDescription

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 +227,60 @@ 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:
signal_callback(signal)
Comment thread
abmantis marked this conversation as resolved.
Outdated

@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"
}
}
}
35 changes: 28 additions & 7 deletions tests/components/infrared/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,11 +19,11 @@ 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."""
Expand All @@ -33,6 +36,24 @@ async def async_send_command(self, command: InfraredCommand) -> None:


@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."""
Comment thread
abmantis marked this conversation as resolved.
Outdated
return MockInfraredEmitterEntity("test_ir_emitter")
Comment thread
abmantis marked this conversation as resolved.
Outdated


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."""
super().__init__()
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")
Loading
Loading