mirror of
https://github.com/home-assistant/core.git
synced 2026-04-08 16:35:17 +00:00
Compare commits
1 Commits
adjust_dev
...
infrared_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0455c0c1f |
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
137
homeassistant/components/lg_infrared/event.py
Normal file
137
homeassistant/components/lg_infrared/event.py
Normal 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
|
||||
)
|
||||
)
|
||||
123
homeassistant/components/lg_infrared/nec_decoder.py
Normal file
123
homeassistant/components/lg_infrared/nec_decoder.py
Normal 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,
|
||||
)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
137
tests/components/lg_infrared/snapshots/test_event.ambr
Normal file
137
tests/components/lg_infrared/snapshots/test_event.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
tests/components/lg_infrared/test_event.py
Normal file
222
tests/components/lg_infrared/test_event.py
Normal 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)
|
||||
Reference in New Issue
Block a user