diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py index cb3535eada48..0345ef85d3a5 100644 --- a/homeassistant/components/lg_infrared/__init__.py +++ b/homeassistant/components/lg_infrared/__init__.py @@ -6,7 +6,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.EVENT, Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.EVENT, + Platform.MEDIA_PLAYER, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json index 9b3aed6831c5..5ca08923f9ee 100644 --- a/homeassistant/components/lg_infrared/icons.json +++ b/homeassistant/components/lg_infrared/icons.json @@ -121,6 +121,14 @@ } } } + }, + "switch": { + "auto_clean": { + "default": "mdi:broom" + }, + "ion_generator": { + "default": "mdi:air-purifier" + } } } } diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index d32e6826d38e..f9fbc8740ff7 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -235,6 +235,14 @@ } } } + }, + "switch": { + "auto_clean": { + "name": "Auto clean" + }, + "ion_generator": { + "name": "Ion generator" + } } }, "selector": { diff --git a/homeassistant/components/lg_infrared/switch.py b/homeassistant/components/lg_infrared/switch.py new file mode 100644 index 000000000000..071dfad20164 --- /dev/null +++ b/homeassistant/components/lg_infrared/switch.py @@ -0,0 +1,111 @@ +"""Switch platform for LG IR integration — LG AC toggles with discrete codes.""" + +from dataclasses import dataclass +from typing import Any, override + +from infrared_protocols.codes.lg.ac import LGACCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ON, + 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 + + +@dataclass(frozen=True, kw_only=True) +class LgAcSwitchEntityDescription(SwitchEntityDescription): + """Describes an LG AC switch backed by separate on and off IR codes.""" + + on_code: LGACCode + off_code: LGACCode + + +AC_SWITCH_DESCRIPTIONS: tuple[LgAcSwitchEntityDescription, ...] = ( + LgAcSwitchEntityDescription( + key="ion_generator", + translation_key="ion_generator", + on_code=LGACCode.ION_GENERATOR_ON, + off_code=LGACCode.ION_GENERATOR_OFF, + ), + LgAcSwitchEntityDescription( + key="auto_clean", + translation_key="auto_clean", + on_code=LGACCode.AUTO_CLEAN_ON, + off_code=LGACCode.AUTO_CLEAN_OFF, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LG AC switches from a config entry.""" + if entry.data[CONF_DEVICE_TYPE] != LGDeviceType.AC: + return + + emitter_entity_id = entry.data[CONF_INFRARED_ENTITY_ID] + async_add_entities( + LgAcSwitch(entry, emitter_entity_id, description) + for description in AC_SWITCH_DESCRIPTIONS + ) + + +class LgAcSwitch( + LgIrEntity, InfraredEmitterConsumerEntity, SwitchEntity, RestoreEntity +): + """An LG AC feature toggled by two discrete infrared codes.""" + + _attr_assumed_state = True + entity_description: LgAcSwitchEntityDescription + + def __init__( + self, + entry: ConfigEntry, + emitter_entity_id: str, + description: LgAcSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(entry, unique_id_suffix=description.key, device_name="LG AC") + self._infrared_emitter_entity_id = emitter_entity_id + self.entity_description = description + self._attr_is_on = False + + @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, + ): + self._attr_is_on = last_state.state == STATE_ON + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the feature on.""" + await self._send_command(self.entity_description.on_code.to_command()) + self._attr_is_on = True + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the feature off.""" + await self._send_command(self.entity_description.off_code.to_command()) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/tests/components/lg_infrared/snapshots/test_switch.ambr b/tests/components/lg_infrared/snapshots/test_switch.ambr new file mode 100644 index 000000000000..8bb14239fa66 --- /dev/null +++ b/tests/components/lg_infrared/snapshots/test_switch.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_entities[switch.lg_ac_auto_clean-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lg_ac_auto_clean', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto clean', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto clean', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_clean', + 'unique_id': '01JTEST0000000000000000000_auto_clean', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.lg_ac_auto_clean-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'LG AC Auto clean', + }), + 'context': , + 'entity_id': 'switch.lg_ac_auto_clean', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.lg_ac_ion_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.lg_ac_ion_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ion generator', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ion generator', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_generator', + 'unique_id': '01JTEST0000000000000000000_ion_generator', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.lg_ac_ion_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'LG AC Ion generator', + }), + 'context': , + 'entity_id': 'switch.lg_ac_ion_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lg_infrared/test_switch.py b/tests/components/lg_infrared/test_switch.py new file mode 100644 index 000000000000..07da898238a4 --- /dev/null +++ b/tests/components/lg_infrared/test_switch.py @@ -0,0 +1,132 @@ +"""Tests for the LG Infrared switch 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.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, 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 + +_ION_ENTITY_ID = "switch.lg_ac_ion_generator" +_AUTO_CLEAN_ENTITY_ID = "switch.lg_ac_auto_clean" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.SWITCH] + + +@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 all switch entities are created with correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("entity_id", "on_code", "off_code"), + [ + pytest.param( + _ION_ENTITY_ID, + LGACCode.ION_GENERATOR_ON, + LGACCode.ION_GENERATOR_OFF, + id="ion", + ), + pytest.param( + _AUTO_CLEAN_ENTITY_ID, + LGACCode.AUTO_CLEAN_ON, + LGACCode.AUTO_CLEAN_OFF, + id="auto_clean", + ), + ], +) +async def test_switch_sends_correct_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + entity_id: str, + on_code: LGACCode, + off_code: LGACCode, +) -> None: + """Test turning a switch on and off sends the matching discrete IR codes.""" + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert timings == on_code.to_command().get_raw_timings() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, 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 == off_code.to_command().get_raw_timings() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + +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 on/off state is restored after a restart.""" + mock_restore_cache(hass, [State(_ION_ENTITY_ID, STATE_ON)]) + 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(_ION_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("init_integration") +async def test_availability_follows_emitter(hass: HomeAssistant) -> None: + """Test switch availability follows the infrared emitter.""" + await assert_availability_follows_source_entity( + hass, _ION_ENTITY_ID, EMITTER_ENTITY_ID + )