1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add LG Infrared integration (#162359)

This commit is contained in:
Abílio Costa
2026-03-24 17:10:18 +00:00
committed by GitHub
parent 55f56c6632
commit 0fd9360249
25 changed files with 893 additions and 9 deletions

View File

@@ -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.*

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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"
]
}

View File

@@ -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(

View File

@@ -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"]
}

View File

@@ -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,
)
),
}

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.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,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,
)
),
}
),
)

View File

@@ -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"

View File

@@ -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"
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

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

View File

@@ -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,

10
mypy.ini generated
View File

@@ -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

2
requirements.txt generated
View File

@@ -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

2
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Tests for the LG Infrared integration."""

View File

@@ -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

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.TV: 'tv'>,
'original_icon': None,
'original_name': None,
'platform': 'lg_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 21945>,
'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': <MediaPlayerEntityFeature: 21945>,
}),
'context': <ANY>,
'entity_id': 'media_player.lg_tv',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -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

View File

@@ -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

View File

@@ -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