From 4b65ed7ef069ce2cf561075275d1cc1b892333a4 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Tue, 1 Sep 2026 16:18:33 +0530 Subject: [PATCH] Add select platform to LG Infrared (#180844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AbĂ­lio Costa --- .../components/lg_infrared/__init__.py | 1 + .../components/lg_infrared/icons.json | 5 + .../components/lg_infrared/select.py | 78 +++++++++++ .../components/lg_infrared/strings.json | 11 ++ .../lg_infrared/snapshots/test_select.ambr | 65 ++++++++++ tests/components/lg_infrared/test_select.py | 121 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 homeassistant/components/lg_infrared/select.py create mode 100644 tests/components/lg_infrared/snapshots/test_select.ambr create mode 100644 tests/components/lg_infrared/test_select.py diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py index 0345ef85d3a5..84f579ddb405 100644 --- a/homeassistant/components/lg_infrared/__init__.py +++ b/homeassistant/components/lg_infrared/__init__.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.EVENT, Platform.MEDIA_PLAYER, + Platform.SELECT, Platform.SWITCH, ] diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json index 785708909fd2..e1f20445a73d 100644 --- a/homeassistant/components/lg_infrared/icons.json +++ b/homeassistant/components/lg_infrared/icons.json @@ -149,6 +149,11 @@ } } }, + "select": { + "energy_limit": { + "default": "mdi:lightning-bolt-outline" + } + }, "switch": { "auto_clean": { "default": "mdi:broom" diff --git a/homeassistant/components/lg_infrared/select.py b/homeassistant/components/lg_infrared/select.py new file mode 100644 index 000000000000..85ffee39236e --- /dev/null +++ b/homeassistant/components/lg_infrared/select.py @@ -0,0 +1,78 @@ +"""Select platform for LG IR integration.""" + +from typing import override + +from infrared_protocols.codes.lg.ac import LGACCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, LGDeviceType +from .entity import LgIrEntity + +PARALLEL_UPDATES = 1 + +ENERGY_LIMIT_OFF = "off" + +# The unit caps its power draw at the selected percentage; "off" removes the cap. +_ENERGY_LIMIT_TO_CODE: dict[str, LGACCode] = { + ENERGY_LIMIT_OFF: LGACCode.ENERGY_LIMIT_OFF, + "40": LGACCode.ENERGY_LIMIT_40, + "60": LGACCode.ENERGY_LIMIT_60, + "80": LGACCode.ENERGY_LIMIT_80, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the LG AC energy-limit select from a config entry.""" + if entry.data[CONF_DEVICE_TYPE] != LGDeviceType.AC: + return + + async_add_entities( + [LgAcEnergyLimitSelect(entry, entry.data[CONF_INFRARED_ENTITY_ID])] + ) + + +class LgAcEnergyLimitSelect( + LgIrEntity, InfraredEmitterConsumerEntity, SelectEntity, RestoreEntity +): + """Selects the LG AC energy-consumption cap.""" + + _attr_assumed_state = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "energy_limit" + _attr_options = list(_ENERGY_LIMIT_TO_CODE) + + def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: + """Initialize the energy-limit select.""" + super().__init__(entry, unique_id_suffix="energy_limit", device_name="LG AC") + self._infrared_emitter_entity_id = emitter_entity_id + self._attr_current_option = ENERGY_LIMIT_OFF + + @override + async def async_added_to_hass(self) -> None: + """Restore the assumed state, as infrared cannot read it back from the AC.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if ( + last_state is not None + and last_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and last_state.state in _ENERGY_LIMIT_TO_CODE + ): + self._attr_current_option = last_state.state + + @override + async def async_select_option(self, option: str) -> None: + """Send the code for the chosen energy cap.""" + await self._send_command(_ENERGY_LIMIT_TO_CODE[option].to_command()) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index 49b6a3c9b822..6afe0745338b 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -263,6 +263,17 @@ } } }, + "select": { + "energy_limit": { + "name": "Energy limit", + "state": { + "40": "40%", + "60": "60%", + "80": "80%", + "off": "[%key:common::state::off%]" + } + } + }, "switch": { "auto_clean": { "name": "Auto clean" diff --git a/tests/components/lg_infrared/snapshots/test_select.ambr b/tests/components/lg_infrared/snapshots/test_select.ambr new file mode 100644 index 000000000000..26cfd7bb4776 --- /dev/null +++ b/tests/components/lg_infrared/snapshots/test_select.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_entities[select.lg_ac_energy_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + '40', + '60', + '80', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lg_ac_energy_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy limit', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_limit', + 'unique_id': '01JTEST0000000000000000000_energy_limit', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select.lg_ac_energy_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'LG AC Energy limit', + : list([ + 'off', + '40', + '60', + '80', + ]), + }), + 'context': , + 'entity_id': 'select.lg_ac_energy_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lg_infrared/test_select.py b/tests/components/lg_infrared/test_select.py new file mode 100644 index 000000000000..087e8690d0a8 --- /dev/null +++ b/tests/components/lg_infrared/test_select.py @@ -0,0 +1,121 @@ +"""Tests for the LG Infrared select platform.""" + +from unittest.mock import patch + +from infrared_protocols.codes.lg.ac import LGACCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lg_infrared.const import LGDeviceType +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + +_ENTITY_ID = "select.lg_ac_energy_limit" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.SELECT] + + +@pytest.fixture +def device_type() -> LGDeviceType: + """Return the device type of the config entry.""" + return LGDeviceType.AC + + +@pytest.fixture +def has_receiver() -> bool: + """Return whether the config entry has an infrared receiver configured.""" + return False + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select entity is created with correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("previous_option", "option", "expected_button"), + [ + pytest.param("40", "off", LGACCode.ENERGY_LIMIT_OFF, id="off"), + pytest.param("off", "40", LGACCode.ENERGY_LIMIT_40, id="40"), + pytest.param("off", "60", LGACCode.ENERGY_LIMIT_60, id="60"), + pytest.param("off", "80", LGACCode.ENERGY_LIMIT_80, id="80"), + ], +) +async def test_select_option_sends_correct_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + previous_option: str, + option: str, + expected_button: LGACCode, +) -> None: + """Test selecting an energy cap sends the matching IR code.""" + # Start from a different cap, so every case has to change the state rather than + # land on the one it started in. + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _ENTITY_ID, ATTR_OPTION: previous_option}, + blocking=True, + ) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _ENTITY_ID, ATTR_OPTION: option}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 2 + timings = mock_infrared_emitter_entity.send_command_calls[1].get_raw_timings() + assert timings == expected_button.to_command().get_raw_timings() + + state = hass.states.get(_ENTITY_ID) + assert state is not None + assert state.state == option + + +async def test_state_restored_on_restart( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + platforms: list[Platform], +) -> None: + """Test the assumed selection is restored after a restart.""" + mock_restore_cache(hass, [State(_ENTITY_ID, "60")]) + 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() + + state = hass.states.get(_ENTITY_ID) + assert state is not None + assert state.state == "60" + + +@pytest.mark.usefixtures("init_integration") +async def test_availability_follows_emitter(hass: HomeAssistant) -> None: + """Test select availability follows the infrared emitter.""" + await assert_availability_follows_source_entity(hass, _ENTITY_ID, EMITTER_ENTITY_ID)