From 0fd9360249fc31f1fca299160f70ad51dddb093d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 24 Mar 2026 17:10:18 +0000 Subject: [PATCH] Add LG Infrared integration (#162359) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/lg.json | 8 +- homeassistant/components/infrared/__init__.py | 6 +- .../components/infrared/manifest.json | 2 +- .../components/kitchen_sink/config_flow.py | 2 +- .../components/lg_infrared/__init__.py | 20 +++ .../components/lg_infrared/config_flow.py | 78 +++++++++ homeassistant/components/lg_infrared/const.py | 13 ++ .../components/lg_infrared/manifest.json | 11 ++ .../components/lg_infrared/media_player.py | 151 ++++++++++++++++++ .../components/lg_infrared/quality_scale.yaml | 113 +++++++++++++ .../components/lg_infrared/strings.json | 29 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lg_infrared/__init__.py | 1 + tests/components/lg_infrared/conftest.py | 109 +++++++++++++ .../snapshots/test_media_player.ambr | 55 +++++++ .../lg_infrared/test_config_flow.py | 136 ++++++++++++++++ tests/components/lg_infrared/test_init.py | 21 +++ .../lg_infrared/test_media_player.py | 121 ++++++++++++++ 25 files changed, 893 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/lg_infrared/__init__.py create mode 100644 homeassistant/components/lg_infrared/config_flow.py create mode 100644 homeassistant/components/lg_infrared/const.py create mode 100644 homeassistant/components/lg_infrared/manifest.json create mode 100644 homeassistant/components/lg_infrared/media_player.py create mode 100644 homeassistant/components/lg_infrared/quality_scale.yaml create mode 100644 homeassistant/components/lg_infrared/strings.json create mode 100644 tests/components/lg_infrared/__init__.py create mode 100644 tests/components/lg_infrared/conftest.py create mode 100644 tests/components/lg_infrared/snapshots/test_media_player.ambr create mode 100644 tests/components/lg_infrared/test_config_flow.py create mode 100644 tests/components/lg_infrared/test_init.py create mode 100644 tests/components/lg_infrared/test_media_player.py diff --git a/.strict-typing b/.strict-typing index 05aec46bb79..9ba9762d08b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -327,6 +327,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.* diff --git a/CODEOWNERS b/CODEOWNERS index 9d9ff9544b4..a5005971652 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -947,6 +947,8 @@ build.json @home-assistant/supervisor /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom /tests/components/letpot/ @jpelgrom +/homeassistant/components/lg_infrared/ @home-assistant/core +/tests/components/lg_infrared/ @home-assistant/core /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration diff --git a/homeassistant/brands/lg.json b/homeassistant/brands/lg.json index 02bd58c0d1c..65fc54da8bd 100644 --- a/homeassistant/brands/lg.json +++ b/homeassistant/brands/lg.json @@ -1,5 +1,11 @@ { "domain": "lg", "name": "LG", - "integrations": ["lg_netcast", "lg_soundbar", "lg_thinq", "webostv"] + "integrations": [ + "lg_infrared", + "lg_netcast", + "lg_soundbar", + "lg_thinq", + "webostv" + ] } diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 6411fe9599a..44adbe154cc 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -61,13 +61,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback -def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]: - """Get all infrared emitters.""" +def async_get_emitters(hass: HomeAssistant) -> list[str]: + """Get all infrared emitter entity IDs.""" component = hass.data.get(DATA_COMPONENT) if component is None: return [] - return list(component.entities) + return [entity.entity_id for entity in component.entities] async def async_send_command( diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 49cf9ad98df..d81f5ecffa7 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==1.0.0"] + "requirements": ["infrared-protocols==1.1.0"] } diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 434d54dc1e5..2fbceef3062 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -196,7 +196,7 @@ class InfraredFanSubentryFlowHandler(ConfigSubentryFlow): vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( EntitySelectorConfig( domain=INFRARED_DOMAIN, - include_entities=[entity.entity_id for entity in entities], + include_entities=entities, ) ), } diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py new file mode 100644 index 00000000000..16d6a2ce7da --- /dev/null +++ b/homeassistant/components/lg_infrared/__init__.py @@ -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.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) diff --git a/homeassistant/components/lg_infrared/config_flow.py b/homeassistant/components/lg_infrared/config_flow.py new file mode 100644 index 00000000000..3e49757cbb0 --- /dev/null +++ b/homeassistant/components/lg_infrared/config_flow.py @@ -0,0 +1,78 @@ +"""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 import entity_registry as er +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.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + 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 + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(entity_id) + entity_name = ( + entry.name or entry.original_name or entity_id if entry else 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=emitter_entity_ids, + ) + ), + } + ), + ) diff --git a/homeassistant/components/lg_infrared/const.py b/homeassistant/components/lg_infrared/const.py new file mode 100644 index 00000000000..43958b763d4 --- /dev/null +++ b/homeassistant/components/lg_infrared/const.py @@ -0,0 +1,13 @@ +"""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" + + +class LGDeviceType(StrEnum): + """LG device types.""" + + TV = "tv" diff --git a/homeassistant/components/lg_infrared/manifest.json b/homeassistant/components/lg_infrared/manifest.json new file mode 100644 index 00000000000..27c5110740b --- /dev/null +++ b/homeassistant/components/lg_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lg_infrared", + "name": "LG Infrared", + "codeowners": ["@home-assistant/core"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/lg_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "silver" +} diff --git a/homeassistant/components/lg_infrared/media_player.py b/homeassistant/components/lg_infrared/media_player.py new file mode 100644 index 00000000000..433a307f41c --- /dev/null +++ b/homeassistant/components/lg_infrared/media_player.py @@ -0,0 +1,151 @@ +"""Media player platform for LG IR integration.""" + +from __future__ import annotations + +import logging + +from infrared_protocols.codes.lg.tv import LGTVCode, make_command as make_lg_tv_command + +from homeassistant.components.infrared import 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, LGDeviceType + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +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[CONF_DEVICE_TYPE] + 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._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"] + ir_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if ir_available != self.available: + _LOGGER.info( + "Infrared entity %s for media player %s is %s", + self._infrared_entity_id, + self.entity_id, + "available" if ir_available else "unavailable", + ) + + self._attr_available = ir_available + 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, code: LGTVCode) -> None: + """Send an IR command using the LG protocol.""" + await async_send_command( + self.hass, + self._infrared_entity_id, + make_lg_tv_command(code), + context=self._context, + ) + + async def async_turn_on(self) -> None: + """Turn on the TV.""" + await self._send_command(LGTVCode.POWER) + + async def async_turn_off(self) -> None: + """Turn off the TV.""" + await self._send_command(LGTVCode.POWER) + + async def async_volume_up(self) -> None: + """Send volume up command.""" + await self._send_command(LGTVCode.VOLUME_UP) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + await self._send_command(LGTVCode.VOLUME_DOWN) + + async def async_mute_volume(self, mute: bool) -> None: + """Send mute command.""" + await self._send_command(LGTVCode.MUTE) + + async def async_media_next_track(self) -> None: + """Send channel up command.""" + await self._send_command(LGTVCode.CHANNEL_UP) + + async def async_media_previous_track(self) -> None: + """Send channel down command.""" + await self._send_command(LGTVCode.CHANNEL_DOWN) + + async def async_media_play(self) -> None: + """Send play command.""" + await self._send_command(LGTVCode.PLAY) + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self._send_command(LGTVCode.PAUSE) + + async def async_media_stop(self) -> None: + """Send stop command.""" + await self._send_command(LGTVCode.STOP) diff --git a/homeassistant/components/lg_infrared/quality_scale.yaml b/homeassistant/components/lg_infrared/quality_scale.yaml new file mode 100644 index 00000000000..afc5a8045ea --- /dev/null +++ b/homeassistant/components/lg_infrared/quality_scale.yaml @@ -0,0 +1,113 @@ +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: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + 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: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # 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: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: + status: exempt + comment: | + This integration does not use custom icons. + 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 has no external dependencies. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json new file mode 100644 index 00000000000..67d6e04bf0f --- /dev/null +++ b/homeassistant/components/lg_infrared/strings.json @@ -0,0 +1,29 @@ +{ + "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" + }, + "data_description": { + "device_type": "The type of LG device to control.", + "infrared_entity_id": "The infrared transmitter entity to use for sending commands." + }, + "description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.", + "title": "Set up LG IR Remote" + } + } + }, + "selector": { + "device_type": { + "options": { + "tv": "TV" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 793cfa1cc65..7837770fc54 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -385,6 +385,7 @@ FLOWS = { "led_ble", "lektrico", "letpot", + "lg_infrared", "lg_netcast", "lg_soundbar", "lg_thinq", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1c577775c44..4463dc8231f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3657,6 +3657,12 @@ "lg": { "name": "LG", "integrations": { + "lg_infrared": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "LG Infrared" + }, "lg_netcast": { "integration_type": "device", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index 3022c1711b5..a03255db041 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3026,6 +3026,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 diff --git a/requirements.txt b/requirements.txt index df4c84d815d..d8d39484a62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==1.13.1 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==1.0.0 +infrared-protocols==1.1.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 diff --git a/requirements_all.txt b/requirements_all.txt index b2f971669fc..20e92d55b1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1322,7 +1322,7 @@ influxdb-client==1.50.0 influxdb==5.3.1 # homeassistant.components.infrared -infrared-protocols==1.0.0 +infrared-protocols==1.1.0 # homeassistant.components.inkbird inkbird-ble==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bf7fd5297c..83668eca076 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1171,7 +1171,7 @@ influxdb-client==1.50.0 influxdb==5.3.1 # homeassistant.components.infrared -infrared-protocols==1.0.0 +infrared-protocols==1.1.0 # homeassistant.components.inkbird inkbird-ble==1.1.1 diff --git a/tests/components/lg_infrared/__init__.py b/tests/components/lg_infrared/__init__.py new file mode 100644 index 00000000000..dcedfca29d3 --- /dev/null +++ b/tests/components/lg_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the LG Infrared integration.""" diff --git a/tests/components/lg_infrared/conftest.py b/tests/components/lg_infrared/conftest.py new file mode 100644 index 00000000000..1133103dd01 --- /dev/null +++ b/tests/components/lg_infrared/conftest.py @@ -0,0 +1,109 @@ +"""Common fixtures for the LG Infrared tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols import Command as InfraredCommand +import pytest + +from homeassistant.components.infrared import ( + DATA_COMPONENT as INFRARED_DATA_COMPONENT, + DOMAIN as INFRARED_DOMAIN, + InfraredEntity, +) +from homeassistant.components.lg_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LGDeviceType, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_INFRARED_ENTITY_ID = "infrared.test_ir_transmitter" + + +class MockInfraredEntity(InfraredEntity): + """Mock infrared entity for testing.""" + + _attr_has_entity_name = True + _attr_name = "Test IR transmitter" + + def __init__(self, unique_id: str) -> None: + """Initialize mock entity.""" + self._attr_unique_id = unique_id + self.send_command_calls: list[InfraredCommand] = [] + + async def async_send_command(self, command: InfraredCommand) -> None: + """Mock send command.""" + self.send_command_calls.append(command) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + 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, + }, + unique_id=f"lg_ir_tv_{MOCK_INFRARED_ENTITY_ID}", + ) + + +@pytest.fixture +def mock_infrared_entity() -> MockInfraredEntity: + """Return a mock infrared entity.""" + return MockInfraredEntity("test_ir_transmitter") + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.MEDIA_PLAYER] + + +@pytest.fixture +def mock_make_lg_tv_command() -> Generator[None]: + """Patch make_command to return the LGTVCode directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with patch( + "homeassistant.components.lg_infrared.media_player.make_lg_tv_command", + side_effect=lambda code, **kwargs: code, + ): + yield + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_entity: MockInfraredEntity, + mock_make_lg_tv_command: None, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the LG Infrared integration 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_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.lg_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/lg_infrared/snapshots/test_media_player.ambr b/tests/components/lg_infrared/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..a48def334c8 --- /dev/null +++ b/tests/components/lg_infrared/snapshots/test_media_player.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_entities[media_player.lg_tv-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.lg_tv', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000000_media_player', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.lg_tv-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'device_class': 'tv', + 'friendly_name': 'LG TV', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.lg_tv', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lg_infrared/test_config_flow.py b/tests/components/lg_infrared/test_config_flow.py new file mode 100644 index 00000000000..dc04ec40341 --- /dev/null +++ b/tests/components/lg_infrared/test_config_flow.py @@ -0,0 +1,136 @@ +"""Tests for the LG Infrared config flow.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.infrared import ( + DATA_COMPONENT as INFRARED_DATA_COMPONENT, + DOMAIN as INFRARED_DOMAIN, +) +from homeassistant.components.lg_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LGDeviceType, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +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 tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_infrared( + hass: HomeAssistant, mock_infrared_entity: MockInfraredEntity +) -> None: + """Set up the infrared component with a mock entity.""" + 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]) + + +@pytest.mark.usefixtures("setup_infrared") +async def test_user_flow_success( + hass: HomeAssistant, +) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + 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["title"] == "LG TV via Test IR transmitter" + assert result["data"] == { + CONF_DEVICE_TYPE: LGDeviceType.TV, + CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + } + assert result["result"].unique_id == f"lg_ir_tv_{MOCK_INFRARED_ENTITY_ID}" + + +@pytest.mark.usefixtures("setup_infrared") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test user flow aborts when entry is already configured.""" + mock_config_entry.add_to_hass(hass) + + 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.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + assert await async_setup_component(hass, INFRARED_DOMAIN, {}) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_emitters" + + +@pytest.mark.usefixtures("setup_infrared") +@pytest.mark.parametrize( + ("entity_name", "expected_title"), + [ + (None, "LG TV via Test IR transmitter"), + ("AC IR emitter", "LG TV via AC IR emitter"), + ], +) +async def test_user_flow_title_from_entity_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_name: str | None, + expected_title: str, +) -> None: + """Test config entry title uses the entity name.""" + entity_registry.async_update_entity(MOCK_INFRARED_ENTITY_ID, name=entity_name) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + 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["title"] == expected_title diff --git a/tests/components/lg_infrared/test_init.py b/tests/components/lg_infrared/test_init.py new file mode 100644 index 00000000000..d70da11090e --- /dev/null +++ b/tests/components/lg_infrared/test_init.py @@ -0,0 +1,21 @@ +"""Tests for the LG Infrared integration setup.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + entry = init_integration + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/lg_infrared/test_media_player.py b/tests/components/lg_infrared/test_media_player.py new file mode 100644 index 00000000000..7ff6943335e --- /dev/null +++ b/tests/components/lg_infrared/test_media_player.py @@ -0,0 +1,121 @@ +"""Tests for the LG Infrared media player platform.""" + +from __future__ import annotations + +from infrared_protocols.codes.lg.tv import LGTVCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_UP, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import MOCK_INFRARED_ENTITY_ID, MockInfraredEntity + +from tests.common import MockConfigEntry, snapshot_platform + +MEDIA_PLAYER_ENTITY_ID = "media_player.lg_tv" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.MEDIA_PLAYER] + + +@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 the media player 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={("lg_infrared", 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.parametrize( + ("service", "service_data", "expected_code"), + [ + (SERVICE_TURN_ON, {}, LGTVCode.POWER), + (SERVICE_TURN_OFF, {}, LGTVCode.POWER), + (SERVICE_VOLUME_UP, {}, LGTVCode.VOLUME_UP), + (SERVICE_VOLUME_DOWN, {}, LGTVCode.VOLUME_DOWN), + (SERVICE_VOLUME_MUTE, {"is_volume_muted": True}, LGTVCode.MUTE), + (SERVICE_MEDIA_NEXT_TRACK, {}, LGTVCode.CHANNEL_UP), + (SERVICE_MEDIA_PREVIOUS_TRACK, {}, LGTVCode.CHANNEL_DOWN), + (SERVICE_MEDIA_PLAY, {}, LGTVCode.PLAY), + (SERVICE_MEDIA_PAUSE, {}, LGTVCode.PAUSE), + (SERVICE_MEDIA_STOP, {}, LGTVCode.STOP), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_media_player_action_sends_correct_code( + hass: HomeAssistant, + mock_infrared_entity: MockInfraredEntity, + service: str, + service_data: dict[str, bool], + expected_code: LGTVCode, +) -> None: + """Test each media player action sends the correct IR code.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data}, + blocking=True, + ) + + assert len(mock_infrared_entity.send_command_calls) == 1 + assert mock_infrared_entity.send_command_calls[0] == expected_code + + +@pytest.mark.usefixtures("init_integration") +async def test_media_player_availability_follows_ir_entity( + hass: HomeAssistant, +) -> None: + """Test media player becomes unavailable when IR entity is unavailable.""" + # Initially available + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # Make IR entity unavailable + hass.states.async_set(MOCK_INFRARED_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Restore IR entity + hass.states.async_set(MOCK_INFRARED_ENTITY_ID, "2026-01-01T00:00:00.000") + await hass.async_block_till_done() + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE