Compare commits

..

3 Commits

Author SHA1 Message Date
abmantis
81415a3cb1 Add LG Infrared integration 2026-02-05 22:36:29 +00:00
abmantis
ed1bb685da Dynamic modulation + cleanup 2026-02-05 22:23:29 +00:00
abmantis
6c610dfe73 Add infrared platform to ESPHome 2026-02-05 20:23:19 +00:00
18 changed files with 1142 additions and 1 deletions

View File

@@ -315,6 +315,7 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*

View File

@@ -29,6 +29,7 @@ from aioesphomeapi import (
Event,
EventInfo,
FanInfo,
InfraredInfo,
LightInfo,
LockInfo,
MediaPlayerInfo,
@@ -85,6 +86,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
DateTimeInfo: Platform.DATETIME,
EventInfo: Platform.EVENT,
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
@@ -520,6 +522,27 @@ class RuntimeEntryData:
),
)
@callback
def async_on_infrared_proxy_receive(
self, hass: HomeAssistant, receive_event: Any
) -> None:
"""Handle an infrared proxy receive event."""
# Fire a Home Assistant event with the infrared data
device_info = self.device_info
if not device_info:
return
hass.bus.async_fire(
f"{DOMAIN}_infrared_proxy_received",
{
"device_name": device_info.name,
"device_mac": device_info.mac_address,
"entry_id": self.entry_id,
"key": receive_event.key,
"timings": receive_event.timings,
},
)
@callback
def async_register_assist_satellite_config_updated_callback(
self,

View File

@@ -0,0 +1,100 @@
"""Infrared platform for ESPHome."""
from __future__ import annotations
import logging
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import EsphomeEntity, async_static_info_updated
from .entry_data import ESPHomeConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
_attr_has_entity_name = True
_attr_name = "Infrared Transmitter"
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
# Infrared entities should go available as soon as the device comes online
self.async_write_ha_state()
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command.
Raises:
HomeAssistantError: If transmission fails.
"""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending command: %s", timings)
try:
self._client.infrared_rf_transmit_raw_timings(
self._static_info.key,
carrier_frequency=command.modulation,
timings=timings,
)
except Exception as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_sending_ir_command",
translation_placeholders={
"device_name": self._device_info.name,
"error": str(err),
},
) from err
async def async_setup_entry(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome infrared entities, filtering out receiver-only devices."""
entry_data = entry.runtime_data
entry_data.info[InfraredInfo] = {}
platform = entity_platform.async_get_current_platform()
def filtered_static_info_update(infos: list[EntityInfo]) -> None:
transmitter_infos: list[EntityInfo] = [
info
for info in infos
if isinstance(info, InfraredInfo)
and info.capabilities & InfraredCapability.TRANSMITTER
]
async_static_info_updated(
hass,
entry_data,
platform,
async_add_entities,
InfraredInfo,
EsphomeInfraredEntity,
EntityState,
transmitter_infos,
)
entry_data.cleanup_callbacks.append(
entry_data.async_register_static_info_callback(
InfraredInfo, filtered_static_info_update
)
)

View File

@@ -17,6 +17,8 @@ from aioesphomeapi import (
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
HomeassistantServiceCall,
InfraredCapability,
InfraredInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
@@ -692,6 +694,15 @@ class ESPHomeManager:
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
)
if any(
isinstance(info, InfraredInfo)
and info.capabilities & InfraredCapability.RECEIVER
for info in entity_infos
):
entry_data.disconnect_callbacks.add(
cli.subscribe_infrared_rf_receive(self._async_infrared_proxy_receive)
)
cli.subscribe_home_assistant_states_and_services(
on_state=entry_data.async_update_state,
on_service_call=self.async_on_service_call,
@@ -722,6 +733,10 @@ class ESPHomeManager:
self.hass, self.entry_data.device_info, zwave_home_id
)
def _async_infrared_proxy_receive(self, receive_event: Any) -> None:
"""Handle an infrared proxy receive event."""
self.entry_data.async_on_infrared_proxy_receive(self.hass, receive_event)
async def on_disconnect(self, expected_disconnect: bool) -> None:
"""Run disconnect callbacks on API disconnect."""
entry_data = self.entry_data

View File

@@ -137,6 +137,9 @@
"error_compiling": {
"message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information."
},
"error_sending_ir_command": {
"message": "Error sending IR command to {device_name}: {error}"
},
"error_uploading": {
"message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information."
},

View File

@@ -36,7 +36,12 @@ class NECInfraredCommand(InfraredCommand):
command: int
def __init__(
self, *, address: int, command: int, modulation: int, repeat_count: int = 0
self,
*,
address: int,
command: int,
modulation: int = 38000,
repeat_count: int = 0,
) -> None:
"""Initialize the NEC IR command."""
super().__init__(modulation=modulation, repeat_count=repeat_count)

View File

@@ -0,0 +1,20 @@
"""LG IR Remote integration for Home Assistant."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
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)
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)

View File

@@ -0,0 +1,256 @@
"""Button platform for LG IR integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
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_ENTITY_ID,
DOMAIN,
LG_ADDRESS,
LGDeviceType,
LGTVCommand,
)
@dataclass(frozen=True, kw_only=True)
class LgIrButtonEntityDescription(ButtonEntityDescription):
"""Describes LG IR button entity."""
command_code: int
TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
LgIrButtonEntityDescription(
key="power_on",
translation_key="power_on",
command_code=LGTVCommand.POWER_ON,
),
LgIrButtonEntityDescription(
key="power_off",
translation_key="power_off",
command_code=LGTVCommand.POWER_OFF,
),
LgIrButtonEntityDescription(
key="hdmi_1",
translation_key="hdmi_1",
command_code=LGTVCommand.HDMI_1,
),
LgIrButtonEntityDescription(
key="hdmi_2",
translation_key="hdmi_2",
command_code=LGTVCommand.HDMI_2,
),
LgIrButtonEntityDescription(
key="hdmi_3",
translation_key="hdmi_3",
command_code=LGTVCommand.HDMI_3,
),
LgIrButtonEntityDescription(
key="hdmi_4",
translation_key="hdmi_4",
command_code=LGTVCommand.HDMI_4,
),
LgIrButtonEntityDescription(
key="exit",
translation_key="exit",
command_code=LGTVCommand.EXIT,
),
LgIrButtonEntityDescription(
key="info",
translation_key="info",
command_code=LGTVCommand.INFO,
),
LgIrButtonEntityDescription(
key="guide",
translation_key="guide",
command_code=LGTVCommand.GUIDE,
),
LgIrButtonEntityDescription(
key="up",
translation_key="up",
command_code=LGTVCommand.NAV_UP,
),
LgIrButtonEntityDescription(
key="down",
translation_key="down",
command_code=LGTVCommand.NAV_DOWN,
),
LgIrButtonEntityDescription(
key="left",
translation_key="left",
command_code=LGTVCommand.NAV_LEFT,
),
LgIrButtonEntityDescription(
key="right",
translation_key="right",
command_code=LGTVCommand.NAV_RIGHT,
),
LgIrButtonEntityDescription(
key="ok",
translation_key="ok",
command_code=LGTVCommand.OK,
),
LgIrButtonEntityDescription(
key="back",
translation_key="back",
command_code=LGTVCommand.BACK,
),
LgIrButtonEntityDescription(
key="home",
translation_key="home",
command_code=LGTVCommand.HOME,
),
LgIrButtonEntityDescription(
key="menu",
translation_key="menu",
command_code=LGTVCommand.MENU,
),
LgIrButtonEntityDescription(
key="input",
translation_key="input",
command_code=LGTVCommand.INPUT,
),
LgIrButtonEntityDescription(
key="num_0",
translation_key="num_0",
command_code=LGTVCommand.NUM_0,
),
LgIrButtonEntityDescription(
key="num_1",
translation_key="num_1",
command_code=LGTVCommand.NUM_1,
),
LgIrButtonEntityDescription(
key="num_2",
translation_key="num_2",
command_code=LGTVCommand.NUM_2,
),
LgIrButtonEntityDescription(
key="num_3",
translation_key="num_3",
command_code=LGTVCommand.NUM_3,
),
LgIrButtonEntityDescription(
key="num_4",
translation_key="num_4",
command_code=LGTVCommand.NUM_4,
),
LgIrButtonEntityDescription(
key="num_5",
translation_key="num_5",
command_code=LGTVCommand.NUM_5,
),
LgIrButtonEntityDescription(
key="num_6",
translation_key="num_6",
command_code=LGTVCommand.NUM_6,
),
LgIrButtonEntityDescription(
key="num_7",
translation_key="num_7",
command_code=LGTVCommand.NUM_7,
),
LgIrButtonEntityDescription(
key="num_8",
translation_key="num_8",
command_code=LGTVCommand.NUM_8,
),
LgIrButtonEntityDescription(
key="num_9",
translation_key="num_9",
command_code=LGTVCommand.NUM_9,
),
)
HIFI_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
LgIrButtonEntityDescription(
key="power_on",
translation_key="power_on",
command_code=LGTVCommand.POWER_ON,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG IR buttons from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
if device_type == LGDeviceType.TV:
async_add_entities(
LgIrButton(entry, infrared_entity_id, description)
for description in TV_BUTTON_DESCRIPTIONS
)
class LgIrButton(ButtonEntity):
"""LG IR button entity."""
_attr_has_entity_name = True
_description: LgIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
infrared_entity_id: str,
description: LgIrButtonEntityDescription,
) -> None:
"""Initialize LG IR button."""
self._entry = entry
self._infrared_entity_id = infrared_entity_id
self._description = description
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
)
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def async_press(self) -> None:
"""Press the button."""
command = NECInfraredCommand(
address=LG_ADDRESS,
command=self._description.command_code,
repeat_count=1,
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)

View File

@@ -0,0 +1,82 @@
"""Config flow for LG IR integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LGDeviceType
DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
LGDeviceType.TV: "TV",
}
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for LG IR."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
valid_entity_ids = [entity.entity_id for entity in entities]
if user_input is not None:
entity_id = user_input[CONF_INFRARED_ENTITY_ID]
device_type = user_input[CONF_DEVICE_TYPE]
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
self._abort_if_unique_id_configured()
# Get entity name for the title
entity_name = next(
(
entity.name or entity.entity_id
for entity in entities
if entity.entity_id == entity_id
),
entity_id,
)
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
title = f"LG {device_type_name} via {entity_name}"
return self.async_create_entry(title=title, data=user_input)
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=valid_entity_ids,
)
),
}
),
)

View File

@@ -0,0 +1,59 @@
"""Constants for the LG IR integration."""
from enum import StrEnum
DOMAIN = "lg_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_DEVICE_TYPE = "device_type"
LG_ADDRESS = 0xFB04
class LGDeviceType(StrEnum):
"""LG device types."""
TV = "tv"
class LGTVCommand:
"""LG TV IR command codes."""
BACK = 0xD728
CHANNEL_DOWN = 0xFE01
CHANNEL_UP = 0xFF00
EXIT = 0xA45B
FAST_FORWARD = 0x718E
GUIDE = 0x56A9
HDMI_1 = 0x31CE
HDMI_2 = 0x33CC
HDMI_3 = 0x16E9
HDMI_4 = 0x25DA
HOME = 0x837C
INFO = 0x55AA
INPUT = 0xF40B
MENU = 0xBC43
MUTE = 0xF609
NAV_DOWN = 0xBE41
NAV_LEFT = 0xF807
NAV_RIGHT = 0xF906
NAV_UP = 0xBF40
NUM_0 = 0xEF10
NUM_1 = 0xEE11
NUM_2 = 0xED12
NUM_3 = 0xEC13
NUM_4 = 0xEB14
NUM_5 = 0xEA15
NUM_6 = 0xE916
NUM_7 = 0xE817
NUM_8 = 0xE718
NUM_9 = 0xE619
OK = 0xBB44
PAUSE = 0x45BA
PLAY = 0x4FB0
POWER = 0xF708
POWER_ON = 0x3BC4
POWER_OFF = 0x3AC5
REWIND = 0x708F
STOP = 0x4EB1
VOLUME_DOWN = 0xFC03
VOLUME_UP = 0xFD02

View File

@@ -0,0 +1,11 @@
{
"domain": "lg_infrared",
"name": "LG Infrared",
"codeowners": [],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/lg_infrared",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze"
}

View File

@@ -0,0 +1,142 @@
"""Media player platform for LG IR integration."""
from __future__ import annotations
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
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_ENTITY_ID,
DOMAIN,
LG_ADDRESS,
LGDeviceType,
LGTVCommand,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG IR media player from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
if device_type == LGDeviceType.TV:
async_add_entities([LgIrTvMediaPlayer(entry, infrared_entity_id)])
class LgIrTvMediaPlayer(MediaPlayerEntity):
"""LG IR media player entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
)
def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None:
"""Initialize LG IR media player."""
self._entry = entry
self._infrared_entity_id = infrared_entity_id
self._attr_unique_id = f"{entry.entry_id}_media_player"
self._attr_state = MediaPlayerState.ON
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
)
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def _send_command(self, command_code: int, repeat_count: int = 1) -> None:
"""Send an IR command using the LG protocol."""
command = NECInfraredCommand(
address=LG_ADDRESS, command=command_code, repeat_count=repeat_count
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)
async def async_turn_on(self) -> None:
"""Turn on the TV."""
await self._send_command(LGTVCommand.POWER)
async def async_turn_off(self) -> None:
"""Turn off the TV."""
await self._send_command(LGTVCommand.POWER)
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_command(LGTVCommand.VOLUME_UP)
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_command(LGTVCommand.VOLUME_DOWN)
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_command(LGTVCommand.MUTE)
async def async_media_next_track(self) -> None:
"""Send channel up command."""
await self._send_command(LGTVCommand.CHANNEL_UP)
async def async_media_previous_track(self) -> None:
"""Send channel down command."""
await self._send_command(LGTVCommand.CHANNEL_DOWN)
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_command(LGTVCommand.PLAY)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_command(LGTVCommand.PAUSE)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._send_command(LGTVCommand.STOP)

View File

@@ -0,0 +1,127 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands:
status: exempt
comment: |
This is a proof of concept integration, brand assets will be added later.
common-modules:
status: exempt
comment: |
This integration is simple and does not share patterns with others.
config-flow-test-coverage:
status: exempt
comment: |
This is a proof of concept integration, config flow tests will be added later.
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description:
status: exempt
comment: |
This is a proof of concept integration, documentation will be added later.
docs-installation-instructions:
status: exempt
comment: |
This is a proof of concept integration, documentation will be added later.
docs-removal-instructions:
status: exempt
comment: |
This is a proof of concept integration, documentation will be added later.
entity-event-setup:
status: exempt
comment: |
This integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure: done
test-before-setup:
status: exempt
comment: |
Setup validation is handled by checking emitter existence in remote.py.
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category:
status: exempt
comment: |
The remote entity is the primary entity and does not need a category.
entity-device-class:
status: exempt
comment: |
Remote entities do not have a device class.
entity-disabled-by-default:
status: exempt
comment: |
The remote entity is the primary entity and should be enabled by default.
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration only depends on ir_proxy which is part of Home Assistant.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo

View File

@@ -0,0 +1,114 @@
{
"config": {
"abort": {
"already_configured": "This LG device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"device_type": "Device type",
"infrared_entity_id": "Infrared transmitter"
},
"description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.",
"title": "Set up LG IR Remote"
}
}
},
"entity": {
"button": {
"back": {
"name": "Back"
},
"down": {
"name": "Down"
},
"exit": {
"name": "Exit"
},
"guide": {
"name": "Guide"
},
"hdmi_1": {
"name": "HDMI 1"
},
"hdmi_2": {
"name": "HDMI 2"
},
"hdmi_3": {
"name": "HDMI 3"
},
"hdmi_4": {
"name": "HDMI 4"
},
"home": {
"name": "Home"
},
"info": {
"name": "Info"
},
"input": {
"name": "Input"
},
"left": {
"name": "Left"
},
"menu": {
"name": "Menu"
},
"num_0": {
"name": "0"
},
"num_1": {
"name": "1"
},
"num_2": {
"name": "2"
},
"num_3": {
"name": "3"
},
"num_4": {
"name": "4"
},
"num_5": {
"name": "5"
},
"num_6": {
"name": "6"
},
"num_7": {
"name": "7"
},
"num_8": {
"name": "8"
},
"num_9": {
"name": "9"
},
"ok": {
"name": "OK"
},
"power_off": {
"name": "Power off"
},
"power_on": {
"name": "Power on"
},
"right": {
"name": "Right"
},
"up": {
"name": "Up"
}
}
},
"selector": {
"device_type": {
"options": {
"hifi": "Hi-Fi",
"tv": "TV"
}
}
}
}

View File

@@ -374,6 +374,7 @@ FLOWS = {
"led_ble",
"lektrico",
"letpot",
"lg_infrared",
"lg_netcast",
"lg_soundbar",
"lg_thinq",

View File

@@ -3605,6 +3605,12 @@
}
}
},
"lg_infrared": {
"name": "LG Infrared",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"libre_hardware_monitor": {
"name": "Libre Hardware Monitor",
"integration_type": "device",

10
mypy.ini generated
View File

@@ -2906,6 +2906,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lg_infrared.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.libre_hardware_monitor.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@@ -0,0 +1,166 @@
"""Test ESPHome infrared platform."""
from aioesphomeapi import APIClient, InfraredCapability, InfraredInfo
import pytest
from homeassistant.components import infrared
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import MockESPHomeDeviceType
ENTITY_ID = "infrared.test_ir"
async def _mock_ir_device(
mock_esphome_device: MockESPHomeDeviceType,
mock_client: APIClient,
capabilities: InfraredCapability = InfraredCapability.TRANSMITTER,
) -> MockESPHomeDeviceType:
entity_info = [
InfraredInfo(object_id="ir", key=1, name="IR", capabilities=capabilities)
]
return await mock_esphome_device(
mock_client=mock_client, entity_info=entity_info, states=[]
)
@pytest.mark.parametrize(
("capabilities", "entity_created"),
[
(InfraredCapability.TRANSMITTER, True),
(InfraredCapability.RECEIVER, False),
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
(0, False),
],
)
async def test_infrared_entity_transmitter(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
capabilities: InfraredCapability,
entity_created: bool,
) -> None:
"""Test infrared entity with transmitter capability is created."""
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
async def test_infrared_multiple_entities_mixed_capabilities(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test multiple infrared entities with mixed capabilities."""
entity_info = [
InfraredInfo(
object_id="ir_transmitter",
key=1,
name="IR Transmitter",
capabilities=InfraredCapability.TRANSMITTER,
),
InfraredInfo(
object_id="ir_receiver",
key=2,
name="IR Receiver",
capabilities=InfraredCapability.RECEIVER,
),
InfraredInfo(
object_id="ir_transceiver",
key=3,
name="IR Transceiver",
capabilities=InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER,
),
]
await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
states=[],
)
# Only transmitter and transceiver should be created
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
async def test_infrared_send_command_success(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test sending IR command successfully."""
await _mock_ir_device(mock_esphome_device, mock_client)
command = infrared.NECInfraredCommand(address=0x04, command=0x08, modulation=38000)
await infrared.async_send_command(hass, ENTITY_ID, command)
# Verify the command was sent to the ESPHome client
mock_client.infrared_rf_transmit_raw_timings.assert_called_once()
call_args = mock_client.infrared_rf_transmit_raw_timings.call_args
assert call_args[0][0] == 1 # key
assert call_args[1]["carrier_frequency"] == 38000
# Verify timings (alternating positive/negative values)
timings = call_args[1]["timings"]
assert len(timings) > 0
for i in range(0, len(timings), 2):
assert timings[i] >= 0
for i in range(1, len(timings), 2):
assert timings[i] <= 0
async def test_infrared_send_command_failure(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test sending IR command with failure raises HomeAssistantError."""
await _mock_ir_device(mock_esphome_device, mock_client)
mock_client.infrared_rf_transmit_raw_timings.side_effect = Exception(
"Connection lost"
)
command = infrared.NECInfraredCommand(address=0x04, command=0x08, modulation=38000)
with pytest.raises(HomeAssistantError) as exc_info:
await infrared.async_send_command(hass, ENTITY_ID, command)
assert exc_info.value.translation_domain == "esphome"
assert exc_info.value.translation_key == "error_sending_ir_command"
async def test_infrared_entity_availability(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test infrared entity becomes available after device reconnects."""
mock_device = await _mock_ir_device(mock_esphome_device, mock_client)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
await mock_device.mock_disconnect(False)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
await mock_device.mock_connect()
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE