Compare commits

...

1 Commits

Author SHA1 Message Date
abmantis
e0455c0c1f Add infrared receiver POC 2026-04-07 19:24:00 +01:00
16 changed files with 1152 additions and 72 deletions

View File

@@ -2,27 +2,37 @@
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
from aioesphomeapi.client import InfraredRFReceiveEventModel
from infrared_protocols import Timing as InfraredTiming
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import callback
from homeassistant.components.infrared import (
InfraredCommand,
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
class EsphomeInfraredEmitterEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
@callback
def _on_device_update(self) -> None:
@@ -50,10 +60,118 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=InfraredInfo,
entity_type=EsphomeInfraredEntity,
state_type=EntityState,
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
)
class EsphomeInfraredReceiverEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredReceiverEntity
):
"""ESPHome infrared receiver entity using native API."""
def __init__(
self,
entry_data: RuntimeEntryData,
entity_info: InfraredInfo,
state_type: type[EntityState],
) -> None:
"""Initialize the receiver entity."""
InfraredReceiverEntity.__init__(self)
EsphomeEntity.__init__(self, entry_data, entity_info, state_type)
@callback
def _on_static_info_update(self, static_info: EntityInfo) -> None:
"""Update static info and ensure unique_id has receiver suffix."""
super()._on_static_info_update(static_info)
self._attr_unique_id = f"{self._attr_unique_id}-rx"
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks including IR receive subscription."""
await super().async_added_to_hass()
self.async_on_remove(
self._client.subscribe_infrared_rf_receive(
self._on_infrared_rf_receive,
)
)
@callback
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
"""Handle a received IR signal from the device."""
if (
event.key != self._static_info.key
or event.device_id != self._static_info.device_id
):
return
timings = [
InfraredTiming(high_us=event.timings[i], low_us=abs(event.timings[i + 1]))
for i in range(0, len(event.timings) - 1, 2)
]
signal = InfraredReceivedSignal(timings=timings)
self._handle_received_signal(signal)
async def async_setup_entry(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome infrared entities."""
entry_data = entry.runtime_data
# Set up emitter entities via the standard platform setup
await platform_async_setup_entry(
hass,
entry,
async_add_entities,
info_type=InfraredInfo,
entity_type=EsphomeInfraredEmitterEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities & InfraredCapability.TRANSMITTER
),
)
# Set up receiver entities via a second registration
# We need a separate info tracking dict for receivers since
# platform_async_setup_entry overwrites entry_data.info[InfraredInfo]
receiver_entities: dict[tuple[int, int], EsphomeInfraredReceiverEntity] = {}
@callback
def _on_receiver_info_update(infos: list[EntityInfo]) -> None:
"""Handle receiver static info updates."""
receiver_infos = [
info
for info in infos
if isinstance(info, InfraredInfo)
and info.capabilities & InfraredCapability.RECEIVER
]
new_entities: list[EsphomeInfraredReceiverEntity] = []
new_keys: set[tuple[int, int]] = set()
for info in receiver_infos:
info_key = (info.device_id, info.key)
new_keys.add(info_key)
if info_key not in receiver_entities:
entity = EsphomeInfraredReceiverEntity(entry_data, info, EntityState)
receiver_entities[info_key] = entity
new_entities.append(entity)
# Remove entities that are no longer present
for info_key in list(receiver_entities):
if info_key not in new_keys:
del receiver_entities[info_key]
if new_entities:
async_add_entities(new_entities)
entry_data.cleanup_callbacks.append(
entry_data.async_register_static_info_callback(
InfraredInfo,
_on_receiver_info_update,
)
)

View File

@@ -3,18 +3,20 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import final
from infrared_protocols import Command as InfraredCommand
from infrared_protocols import Command as InfraredCommand, Timing as InfraredTiming
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
@@ -25,15 +27,22 @@ 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",
]
_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
@@ -42,9 +51,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
@@ -67,7 +76,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(
@@ -91,7 +118,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",
@@ -104,14 +131,53 @@ 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)
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)
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[InfraredTiming]
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_should_poll = False
_attr_state: None = None
@@ -151,3 +217,59 @@ class InfraredEntity(RestoreEntity):
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_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()
@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"
)
self.async_write_ha_state()
for signal_callback in self.__signal_callbacks:
signal_callback(signal)
@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

View File

@@ -5,6 +5,9 @@
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
},
"receiver_not_found": {
"message": "Infrared receiver entity `{entity_id}` not found"
}
}
}

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import infrared_protocols
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredEntity
from homeassistant.components.infrared import InfraredEmitterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@@ -33,7 +33,7 @@ async def async_setup_entry(
)
class DemoInfrared(InfraredEntity):
class DemoInfrared(InfraredEmitterEntity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True

View File

@@ -6,15 +6,25 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_INFRARED_RECEIVER_ENTITY_ID
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LG IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
platforms = list(PLATFORMS)
if entry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID):
platforms.append(Platform.EVENT)
await hass.config_entries.async_forward_entry_setups(entry, platforms)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a LG IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
platforms = list(PLATFORMS)
if entry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID):
platforms.append(Platform.EVENT)
return await hass.config_entries.async_unload_platforms(entry, platforms)

View File

@@ -7,6 +7,7 @@ import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
async_get_receivers,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import entity_registry as er
@@ -18,7 +19,13 @@ from homeassistant.helpers.selector import (
SelectSelectorMode,
)
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LGDeviceType
from .const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
LGDeviceType,
)
DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
LGDeviceType.TV: "TV",
@@ -38,6 +45,8 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
receiver_entity_ids = async_get_receivers(self.hass)
if user_input is not None:
entity_id = user_input[CONF_INFRARED_ENTITY_ID]
device_type = user_input[CONF_DEVICE_TYPE]
@@ -56,23 +65,33 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=title, data=user_input)
schema_dict: dict[vol.Marker, Any] = {
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
SelectSelectorConfig(
options=[device_type.value for device_type in LGDeviceType],
translation_key=CONF_DEVICE_TYPE,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entity_ids,
)
),
}
if receiver_entity_ids:
schema_dict[vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID)] = (
EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=receiver_entity_ids,
)
)
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
SelectSelectorConfig(
options=[device_type.value for device_type in LGDeviceType],
translation_key=CONF_DEVICE_TYPE,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entity_ids,
)
),
}
),
data_schema=vol.Schema(schema_dict),
)

View File

@@ -4,6 +4,7 @@ from enum import StrEnum
DOMAIN = "lg_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id"
CONF_DEVICE_TYPE = "device_type"

View File

@@ -0,0 +1,137 @@
"""Event platform for LG IR integration."""
from __future__ import annotations
import logging
from infrared_protocols.codes.lg.tv import LGTVCode
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 Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_RECEIVER_ENTITY_ID, LGDeviceType
from .entity import LgIrEntity
from .nec_decoder import from_raw_timings
_LOGGER = logging.getLogger(__name__)
# LG TV NEC address (extended NEC, 16-bit)
_LG_TV_NEC_ADDRESS = 0xFB04
# Build a lookup from command byte value -> lowercase LGTVCode name
_COMMAND_BYTE_TO_EVENT_TYPE: dict[int, str] = {
code.value: code.name.lower() for code in LGTVCode
}
# Event type for commands from the LG TV address that don't match any known code
_EVENT_TYPE_UNKNOWN = "unknown"
# All possible event types: known LG TV codes + unknown
_EVENT_TYPES: list[str] = [code.name.lower() for code in LGTVCode] + [
_EVENT_TYPE_UNKNOWN
]
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG IR event entity from config entry."""
receiver_entity_id = entry.data[CONF_INFRARED_RECEIVER_ENTITY_ID]
device_type = entry.data[CONF_DEVICE_TYPE]
if device_type == LGDeviceType.TV:
async_add_entities(
[
LgIrReceivedCommandEvent(
entry,
receiver_entity_id,
)
]
)
class LgIrReceivedCommandEvent(LgIrEntity, EventEntity):
"""Event entity that fires when an LG TV IR command is received."""
_attr_translation_key = "received_command"
_attr_event_types = _EVENT_TYPES
def __init__(
self,
entry: ConfigEntry,
receiver_entity_id: str,
) -> None:
"""Initialize the event entity."""
super().__init__(entry, receiver_entity_id, unique_id_suffix="received_command")
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."""
nec_command = from_raw_timings(signal.timings)
if nec_command is None:
return
if nec_command.address != _LG_TV_NEC_ADDRESS:
return
event_type = _COMMAND_BYTE_TO_EVENT_TYPE.get(
nec_command.command, _EVENT_TYPE_UNKNOWN
)
_LOGGER.debug(
"Received LG TV IR command: %s (0x%02X)",
event_type,
nec_command.command,
)
self._trigger_event(event_type)
self.async_write_ha_state()
@callback
def _async_subscribe_when_available() -> None:
"""Subscribe to the IR receiver when it becomes available."""
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
if not self._attr_available:
return
_LOGGER.info(
"Subscribing to infrared receiver entity %s for %s",
self._infrared_entity_id,
self.entity_id,
)
# TODO: unsubscribe before resubscribing if already subscribed
self.async_on_remove(
async_subscribe_receiver(
self.hass, self._infrared_entity_id, _handle_signal
)
)
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
_async_subscribe_when_available()
_async_subscribe_when_available()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)

View File

@@ -0,0 +1,123 @@
"""NEC IR protocol decoding for the LG IR integration."""
from __future__ import annotations
from infrared_protocols import NECCommand, Timing
# NEC protocol timing constants (microseconds)
_LEADER_HIGH = 9000
_LEADER_LOW = 4500
_BIT_HIGH = 562
_ZERO_LOW = 562
_ONE_LOW = 1687
_REPEAT_LOW = 2250
# Tolerance for timing comparisons (±40%)
_TOLERANCE = 0.4
def _is_close(actual: int, expected: int) -> bool:
"""Check if an actual timing value is within tolerance of the expected value."""
margin = expected * _TOLERANCE
return expected - margin <= actual <= expected + margin
def _decode_bit(timing: Timing) -> int | None:
"""Decode a single NEC data bit from a timing.
Returns 0, 1, or None if the timing doesn't match a valid NEC bit.
"""
if not _is_close(timing.high_us, _BIT_HIGH):
return None
if _is_close(timing.low_us, _ZERO_LOW):
return 0
if _is_close(timing.low_us, _ONE_LOW):
return 1
return None
def _count_repeat_codes(timings: list[Timing], start_index: int) -> int:
"""Count NEC repeat codes starting from the given index.
A repeat code consists of a leader burst (9000µs high, 2250µs low)
followed by an end pulse (562µs high).
"""
count = 0
i = start_index
while i + 1 < len(timings):
leader = timings[i]
end_pulse = timings[i + 1]
if (
_is_close(leader.high_us, _LEADER_HIGH)
and _is_close(leader.low_us, _REPEAT_LOW)
and _is_close(end_pulse.high_us, _BIT_HIGH)
):
count += 1
i += 2
else:
break
return count
def from_raw_timings(timings: list[Timing]) -> NECCommand | None:
"""Decode raw IR timings into a NECCommand.
Expects timings in the NEC protocol format:
- Leader pulse: ~9000µs high, ~4500µs low
- 32 data bits (LSB first): 562µs high + 562µs low (0) or 1687µs low (1)
- End pulse: ~562µs high
- Optional repeat codes: ~9000µs high, ~2250µs low + end pulse
Returns a NECCommand if the timings match, or None otherwise.
"""
# Minimum: 1 leader + 32 bits + 1 end pulse = 34 timings
if len(timings) < 34:
return None
# Validate leader pulse
leader = timings[0]
if not _is_close(leader.high_us, _LEADER_HIGH) or not _is_close(
leader.low_us, _LEADER_LOW
):
return None
# Decode 32 data bits (LSB first)
data = 0
for i in range(32):
bit = _decode_bit(timings[1 + i])
if bit is None:
return None
data |= bit << i
# Validate end pulse
end_pulse = timings[33]
if not _is_close(end_pulse.high_us, _BIT_HIGH):
return None
# Extract bytes
address_low = data & 0xFF
address_high = (data >> 8) & 0xFF
command_byte = (data >> 16) & 0xFF
command_inverted = (data >> 24) & 0xFF
# Validate command checksum
if command_byte ^ command_inverted != 0xFF:
return None
# Reconstruct the full 16-bit address.
# Standard NEC (8-bit address) and extended NEC (16-bit address) produce
# identical timings when address_low ^ address_high == 0xFF, making them
# indistinguishable from raw timings alone. We always return the 16-bit
# representation; callers can check if the high byte is the complement
# of the low byte to determine if it was originally a standard 8-bit address.
address = address_low | (address_high << 8)
# Count repeat codes after the end pulse
repeat_count = _count_repeat_codes(timings, 34)
return NECCommand(
address=address,
command=command_byte,
modulation=0,
repeat_count=repeat_count,
)

View File

@@ -8,11 +8,13 @@
"user": {
"data": {
"device_type": "Device type",
"infrared_entity_id": "Infrared transmitter"
"infrared_entity_id": "Infrared transmitter",
"infrared_receiver_entity_id": "Infrared receiver"
},
"data_description": {
"device_type": "The type of LG device to control.",
"infrared_entity_id": "The infrared transmitter entity to use for sending commands."
"infrared_entity_id": "The infrared transmitter entity to use for sending commands.",
"infrared_receiver_entity_id": "The infrared receiver entity to use for receiving signals."
},
"description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.",
"title": "Set up LG IR Remote"
@@ -105,6 +107,57 @@
"up": {
"name": "Up"
}
},
"event": {
"received_command": {
"name": "Received command",
"state_attributes": {
"event_type": {
"state": {
"back": "Back",
"channel_down": "Channel down",
"channel_up": "Channel up",
"exit": "Exit",
"fast_forward": "Fast forward",
"guide": "Guide",
"hdmi_1": "HDMI 1",
"hdmi_2": "HDMI 2",
"hdmi_3": "HDMI 3",
"hdmi_4": "HDMI 4",
"home": "Home",
"info": "Info",
"input": "Input",
"menu": "Menu",
"mute": "Mute",
"nav_down": "Navigate down",
"nav_left": "Navigate left",
"nav_right": "Navigate right",
"nav_up": "Navigate up",
"num_0": "Number 0",
"num_1": "Number 1",
"num_2": "Number 2",
"num_3": "Number 3",
"num_4": "Number 4",
"num_5": "Number 5",
"num_6": "Number 6",
"num_7": "Number 7",
"num_8": "Number 8",
"num_9": "Number 9",
"ok": "OK",
"pause": "Pause",
"play": "Play",
"power": "Power",
"power_off": "Power off",
"power_on": "Power on",
"rewind": "Rewind",
"stop": "Stop",
"unknown": "Unknown",
"volume_down": "Volume down",
"volume_up": "Volume up"
}
}
}
}
}
},
"selector": {

View File

@@ -33,29 +33,30 @@ async def _mock_ir_device(
@pytest.mark.parametrize(
("capabilities", "entity_created"),
("capabilities", "emitter_created", "receiver_created"),
[
(InfraredCapability.TRANSMITTER, True),
(InfraredCapability.RECEIVER, False),
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
(InfraredCapability(0), False),
(InfraredCapability.TRANSMITTER, True, False),
(InfraredCapability.RECEIVER, False, True),
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True, True),
(InfraredCapability(0), False, False),
],
)
async def test_infrared_entity_transmitter(
async def test_infrared_entity_capabilities(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
capabilities: InfraredCapability,
entity_created: bool,
emitter_created: bool,
receiver_created: bool,
) -> None:
"""Test infrared entity with transmitter capability is created."""
"""Test infrared entities are created based on capabilities."""
await _mock_ir_device(mock_esphome_device, mock_client, capabilities)
state = hass.states.get(ENTITY_ID)
assert (state is not None) == entity_created
emitters = infrared.async_get_emitters(hass)
assert (len(emitters) == 1) == entity_created
assert (len(emitters) == 1) == emitter_created
receivers = infrared.async_get_receivers(hass)
assert (len(receivers) == 1) == receiver_created
async def test_infrared_multiple_entities_mixed_capabilities(
@@ -90,14 +91,20 @@ async def test_infrared_multiple_entities_mixed_capabilities(
states=[],
)
# Only transmitter and transceiver should be created
# Emitter entities for transmitter and transceiver
assert hass.states.get("infrared.test_ir_transmitter") is not None
assert hass.states.get("infrared.test_ir_receiver") is None
assert hass.states.get("infrared.test_ir_transceiver") is not None
emitters = infrared.async_get_emitters(hass)
assert len(emitters) == 2
# Receiver entities for receiver and transceiver
assert hass.states.get("infrared.test_ir_receiver") is not None
assert hass.states.get("infrared.test_ir_transceiver_2") is not None
receivers = infrared.async_get_receivers(hass)
assert len(receivers) == 2
async def test_infrared_send_command_success(
hass: HomeAssistant,

View File

@@ -3,7 +3,7 @@
from infrared_protocols import Command as InfraredCommand
import pytest
from homeassistant.components.infrared import InfraredEntity
from homeassistant.components.infrared import InfraredEmitterEntity
from homeassistant.components.infrared.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -16,7 +16,7 @@ async def init_integration(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
class MockInfraredEntity(InfraredEntity):
class MockInfraredEntity(InfraredEmitterEntity):
"""Mock infrared entity for testing."""
_attr_has_entity_name = True

View File

@@ -11,7 +11,8 @@ import pytest
from homeassistant.components.infrared import (
DATA_COMPONENT as INFRARED_DATA_COMPONENT,
DOMAIN as INFRARED_DOMAIN,
InfraredEntity,
InfraredEmitterEntity,
InfraredReceiverEntity,
)
from homeassistant.components.lg_infrared import PLATFORMS
from homeassistant.components.lg_infrared.const import (
@@ -27,9 +28,10 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
MOCK_INFRARED_ENTITY_ID = "infrared.test_ir_transmitter"
MOCK_INFRARED_RECEIVER_ENTITY_ID = "infrared.test_ir_receiver"
class MockInfraredEntity(InfraredEntity):
class MockInfraredEntity(InfraredEmitterEntity):
"""Mock infrared entity for testing."""
_attr_has_entity_name = True
@@ -45,6 +47,18 @@ class MockInfraredEntity(InfraredEntity):
self.send_command_calls.append(command)
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_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
@@ -66,6 +80,12 @@ def mock_infrared_entity() -> MockInfraredEntity:
return MockInfraredEntity("test_ir_transmitter")
@pytest.fixture
def mock_infrared_receiver_entity() -> MockInfraredReceiverEntity:
"""Return a mock infrared receiver entity."""
return MockInfraredReceiverEntity("test_ir_receiver")
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""

View File

@@ -0,0 +1,137 @@
# serializer version: 1
# name: test_entities[event.lg_tv_received_command-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'event_types': list([
'back',
'channel_down',
'channel_up',
'exit',
'fast_forward',
'guide',
'hdmi_1',
'hdmi_2',
'hdmi_3',
'hdmi_4',
'home',
'info',
'input',
'menu',
'mute',
'nav_down',
'nav_left',
'nav_right',
'nav_up',
'num_0',
'num_1',
'num_2',
'num_3',
'num_4',
'num_5',
'num_6',
'num_7',
'num_8',
'num_9',
'ok',
'pause',
'play',
'power',
'power_on',
'power_off',
'rewind',
'stop',
'volume_down',
'volume_up',
'unknown',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.lg_tv_received_command',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Received command',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Received command',
'platform': 'lg_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'received_command',
'unique_id': '01JTEST0000000000000000000_received_command',
'unit_of_measurement': None,
})
# ---
# name: test_entities[event.lg_tv_received_command-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'event_type': None,
'event_types': list([
'back',
'channel_down',
'channel_up',
'exit',
'fast_forward',
'guide',
'hdmi_1',
'hdmi_2',
'hdmi_3',
'hdmi_4',
'home',
'info',
'input',
'menu',
'mute',
'nav_down',
'nav_left',
'nav_right',
'nav_up',
'num_0',
'num_1',
'num_2',
'num_3',
'num_4',
'num_5',
'num_6',
'num_7',
'num_8',
'num_9',
'ok',
'pause',
'play',
'power',
'power_on',
'power_off',
'rewind',
'stop',
'volume_down',
'volume_up',
'unknown',
]),
'friendly_name': 'LG TV Received command',
}),
'context': <ANY>,
'entity_id': 'event.lg_tv_received_command',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -11,6 +11,7 @@ from homeassistant.components.infrared import (
from homeassistant.components.lg_infrared.const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
LGDeviceType,
)
@@ -20,7 +21,12 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import MOCK_INFRARED_ENTITY_ID, MockInfraredEntity
from .conftest import (
MOCK_INFRARED_ENTITY_ID,
MOCK_INFRARED_RECEIVER_ENTITY_ID,
MockInfraredEntity,
MockInfraredReceiverEntity,
)
from tests.common import MockConfigEntry
@@ -37,6 +43,22 @@ async def setup_infrared(
await component.async_add_entities([mock_infrared_entity])
@pytest.fixture
async def setup_infrared_with_receiver(
hass: HomeAssistant,
mock_infrared_entity: MockInfraredEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Set up the infrared component with emitter and receiver entities."""
assert await async_setup_component(hass, INFRARED_DOMAIN, {})
await hass.async_block_till_done()
component = hass.data[INFRARED_DATA_COMPONENT]
await component.async_add_entities(
[mock_infrared_entity, mock_infrared_receiver_entity]
)
@pytest.mark.usefixtures("setup_infrared")
async def test_user_flow_success(
hass: HomeAssistant,
@@ -134,3 +156,89 @@ async def test_user_flow_title_from_entity_name(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
@pytest.mark.usefixtures("setup_infrared")
async def test_user_flow_no_receiver_field_without_receivers(
hass: HomeAssistant,
) -> None:
"""Test receiver field is not shown when no receivers exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
schema_keys = [key.schema for key in result["data_schema"].schema]
assert CONF_INFRARED_RECEIVER_ENTITY_ID not in schema_keys
@pytest.mark.usefixtures("setup_infrared_with_receiver")
async def test_user_flow_receiver_field_shown_with_receivers(
hass: HomeAssistant,
) -> None:
"""Test receiver field is shown when receivers exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
schema_keys = [key.schema for key in result["data_schema"].schema]
assert CONF_INFRARED_RECEIVER_ENTITY_ID in schema_keys
@pytest.mark.usefixtures("setup_infrared_with_receiver")
async def test_user_flow_success_with_receiver(
hass: HomeAssistant,
) -> None:
"""Test successful user config flow with receiver selected."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "LG TV via Test IR transmitter"
assert result["data"] == {
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
}
assert result["result"].unique_id == f"lg_ir_tv_{MOCK_INFRARED_ENTITY_ID}"
@pytest.mark.usefixtures("setup_infrared_with_receiver")
async def test_user_flow_success_without_receiver_when_available(
hass: HomeAssistant,
) -> None:
"""Test successful user config flow without selecting optional receiver."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID,
}

View File

@@ -0,0 +1,222 @@
"""Tests for the LG Infrared event platform."""
from __future__ import annotations
from unittest.mock import patch
from infrared_protocols import NECCommand, Timing
from infrared_protocols.codes.lg.tv import LGTVCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.components.infrared import (
DATA_COMPONENT as INFRARED_DATA_COMPONENT,
DOMAIN as INFRARED_DOMAIN,
InfraredReceivedSignal,
)
from homeassistant.components.lg_infrared.const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
LGDeviceType,
)
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import (
MOCK_INFRARED_ENTITY_ID,
MOCK_INFRARED_RECEIVER_ENTITY_ID,
MockInfraredEntity,
MockInfraredReceiverEntity,
)
from .utils import check_availability_follows_ir_entity
from tests.common import MockConfigEntry, snapshot_platform
EVENT_ENTITY_ID = "event.lg_tv_received_command"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.EVENT]
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry with receiver configured."""
return MockConfigEntry(
domain=DOMAIN,
entry_id="01JTEST0000000000000000000",
title="LG TV via Test IR transmitter",
data={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
},
unique_id=f"lg_ir_tv_{MOCK_INFRARED_ENTITY_ID}",
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_entity: MockInfraredEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
mock_make_lg_tv_command: None,
) -> MockConfigEntry:
"""Set up LG Infrared integration with receiver for testing."""
assert await async_setup_component(hass, INFRARED_DOMAIN, {})
await hass.async_block_till_done()
infrared_component = hass.data[INFRARED_DATA_COMPONENT]
await infrared_component.async_add_entities(
[mock_infrared_entity, mock_infrared_receiver_entity]
)
mock_config_entry.add_to_hass(hass)
# Patch base PLATFORMS to empty so only the conditionally-added EVENT loads
with patch("homeassistant.components.lg_infrared.PLATFORMS", []):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
def _make_lg_tv_signal(code: LGTVCode) -> InfraredReceivedSignal:
"""Create an InfraredReceivedSignal for an LG TV command."""
timings = NECCommand(
address=0xFB04, command=code.value, modulation=38000
).get_raw_timings()
return InfraredReceivedSignal(timings=timings, modulation=38000)
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test event entity is created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Verify entity belongs to the correct device
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
@pytest.mark.usefixtures("init_integration")
async def test_receives_known_lg_tv_command(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test event fires when a known LG TV IR command is received."""
state = hass.states.get(EVENT_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
signal = _make_lg_tv_signal(LGTVCode.POWER)
mock_infrared_receiver_entity._handle_received_signal(signal)
await hass.async_block_till_done()
state = hass.states.get(EVENT_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNKNOWN
assert state.attributes[ATTR_EVENT_TYPE] == "power"
@pytest.mark.usefixtures("init_integration")
async def test_receives_volume_up_command(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test event fires with correct type for volume up command."""
signal = _make_lg_tv_signal(LGTVCode.VOLUME_UP)
mock_infrared_receiver_entity._handle_received_signal(signal)
await hass.async_block_till_done()
state = hass.states.get(EVENT_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_EVENT_TYPE] == "volume_up"
@pytest.mark.usefixtures("init_integration")
async def test_receives_unknown_lg_command(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test event fires as 'unknown' for unrecognized LG TV command code."""
# Use command byte 0xFE which is not in LGTVCode
timings = NECCommand(
address=0xFB04, command=0xFE, modulation=38000
).get_raw_timings()
signal = InfraredReceivedSignal(timings=timings, modulation=38000)
mock_infrared_receiver_entity._handle_received_signal(signal)
await hass.async_block_till_done()
state = hass.states.get(EVENT_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_EVENT_TYPE] == "unknown"
@pytest.mark.usefixtures("init_integration")
async def test_ignores_non_lg_address(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test that signals from a non-LG NEC address are ignored."""
timings = NECCommand(
address=0x1234, command=0x08, modulation=38000
).get_raw_timings()
signal = InfraredReceivedSignal(timings=timings, modulation=38000)
mock_infrared_receiver_entity._handle_received_signal(signal)
await hass.async_block_till_done()
state = hass.states.get(EVENT_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_ignores_non_nec_signal(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test that non-NEC signals are ignored."""
# Send garbage timings that don't match NEC protocol
signal = InfraredReceivedSignal(
timings=[Timing(high_us=100, low_us=100)] * 10,
modulation=38000,
)
mock_infrared_receiver_entity._handle_received_signal(signal)
await hass.async_block_till_done()
state = hass.states.get(EVENT_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_event_entity_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test event entity becomes unavailable when IR emitter entity is unavailable."""
await check_availability_follows_ir_entity(hass, EVENT_ENTITY_ID)