Compare commits

...

1 Commits

Author SHA1 Message Date
abmantis d571dc1de0 Add infrared receiver POC 2026-04-25 00:21:34 +01:00
18 changed files with 1302 additions and 103 deletions
+131 -13
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,
)
)
+149 -16
View File
@@ -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 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 +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
@@ -42,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
@@ -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(
@@ -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)
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 +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.
"""
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_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER
_attr_should_poll = False
_attr_state: None = None
@@ -151,3 +227,60 @@ 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_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()
@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
@@ -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"
}
}
}
@@ -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
@@ -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)
@@ -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),
)
@@ -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"
@@ -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
)
)
@@ -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,
)
@@ -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": {
+21 -14
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,
+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,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."""
@@ -33,6 +36,24 @@ 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."""
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")
+102 -26
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,7 +21,7 @@ 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
@@ -26,49 +29,56 @@ from tests.common import mock_restore_cache
async def test_get_entities_integration_setup(hass: HomeAssistant) -> None:
"""Test getting entities when the integration is not setup."""
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_infrared_entities_initial_state(
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 infrared entities have no state before any command is sent."""
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 (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, 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 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 +86,27 @@ 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, 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
@@ -134,19 +144,85 @@ 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
# Receiver entity does not restore state
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_subscribe_receiver_success(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test subscribing to a receiver via async_subscribe_receiver helper."""
# Add the mock entity to the component
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_receiver_entity])
# Freeze time so we can verify the state update
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")
# Verify unsubscribe stops further callbacks
unsubscribe()
mock_infrared_receiver_entity._handle_received_signal(signal)
assert signal_callback.call_count == 1
@pytest.mark.usefixtures("init_integration")
async def test_async_subscribe_receiver_entity_not_found(hass: HomeAssistant) -> None:
"""Test async_subscribe_receiver raises error when entity not found."""
with pytest.raises(
HomeAssistantError,
match="Infrared receiver entity `infrared.nonexistent_entity` not found",
):
async_subscribe_receiver(hass, "infrared.nonexistent_entity", 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)
+22 -2
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."""
@@ -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',
})
# ---
@@ -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,
}
+222
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)