diff --git a/homeassistant/components/lg_infrared/climate.py b/homeassistant/components/lg_infrared/climate.py index 77a6d8b2864c..12f5d772de48 100644 --- a/homeassistant/components/lg_infrared/climate.py +++ b/homeassistant/components/lg_infrared/climate.py @@ -2,21 +2,26 @@ from typing import Any, override +from infrared_protocols.codes.lg.ac import LGACCode from infrared_protocols.commands.lg_ac import ( MAX_TEMP, MIN_TEMP, LgAcCommand, LgAcFanSpeed, + LgAcFixedCommand, LgAcMode, ) from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, FAN_AUTO, FAN_HIGH, FAN_LOW, FAN_MEDIUM, + SWING_OFF, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -75,6 +80,51 @@ _LIB_MODE_TO_HA: dict[LgAcMode, HVACMode] = {v: k for k, v in _HA_MODE_TO_LIB.it # Only these modes carry a temperature in the LG AC protocol frame. _TEMPERATURE_MODES = (LgAcMode.COOL, LgAcMode.HEAT) +SWING_LOWEST = "lowest" +SWING_LOW = "low" +SWING_MIDDLE_LOW = "middle_low" +SWING_MIDDLE_HIGH = "middle_high" +SWING_HIGH = "high" +SWING_HIGHEST = "highest" +SWING_OSCILLATE = "swing" + +SWING_LEFT = "left" +SWING_MIDDLE_LEFT = "middle_left" +SWING_MIDDLE = "middle" +SWING_MIDDLE_RIGHT = "middle_right" +SWING_RIGHT = "right" + +# Oscillates in one half +SWING_LEFT_HALF = "left_half" +SWING_RIGHT_HALF = "right_half" + +# The six vane positions plus off and the oscillating "swing" mode share the vertical +# swing dropdown. There is no true centre position on this ladder. +_HA_SWING_TO_LIB: dict[str, LGACCode] = { + SWING_OFF: LGACCode.SWING_V_OFF, + SWING_HIGHEST: LGACCode.SWING_V_HIGHEST, + SWING_HIGH: LGACCode.SWING_V_HIGH, + SWING_MIDDLE_HIGH: LGACCode.SWING_V_MIDDLE_HIGH, + SWING_MIDDLE_LOW: LGACCode.SWING_V_MIDDLE_LOW, + SWING_LOW: LGACCode.SWING_V_LOW, + SWING_LOWEST: LGACCode.SWING_V_LOWEST, + SWING_OSCILLATE: LGACCode.SWING_V_SWING, +} +_LIB_SWING_TO_HA: dict[LGACCode, str] = {v: k for k, v in _HA_SWING_TO_LIB.items()} + +_HA_SWING_H_TO_LIB: dict[str, LGACCode] = { + SWING_OFF: LGACCode.SWING_H_OFF, + SWING_LEFT: LGACCode.SWING_H_LEFT, + SWING_MIDDLE_LEFT: LGACCode.SWING_H_MIDDLE_LEFT, + SWING_MIDDLE: LGACCode.SWING_H_MIDDLE, + SWING_MIDDLE_RIGHT: LGACCode.SWING_H_MIDDLE_RIGHT, + SWING_RIGHT: LGACCode.SWING_H_RIGHT, + SWING_LEFT_HALF: LGACCode.SWING_H_MIDDLE_TO_LEFT, + SWING_RIGHT_HALF: LGACCode.SWING_H_MIDDLE_TO_RIGHT, + SWING_OSCILLATE: LGACCode.SWING_H_SWING, +} +_LIB_SWING_H_TO_HA: dict[LGACCode, str] = {v: k for k, v in _HA_SWING_H_TO_LIB.items()} + async def async_setup_entry( hass: HomeAssistant, @@ -116,6 +166,8 @@ class LgAcClimateEntity( FAN_MEDIUM_HIGH, FAN_HIGH, ] + _attr_swing_modes = list(_HA_SWING_TO_LIB) + _attr_swing_horizontal_modes = list(_HA_SWING_H_TO_LIB) def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: """Initialize LG AC climate entity.""" @@ -129,8 +181,14 @@ class LgAcClimateEntity( self._attr_hvac_mode = HVACMode.OFF self._attr_target_temperature = float(MIN_TEMP) self._attr_fan_mode = FAN_AUTO + self._attr_swing_mode = SWING_OFF + self._attr_swing_horizontal_mode = SWING_OFF - self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE + ) # Without a temperature-carrying mode no target temperature can ever be sent. if any( _HA_MODE_TO_LIB[mode] in _TEMPERATURE_MODES @@ -156,6 +214,12 @@ class LgAcClimateEntity( self._attr_fan_mode = fan_mode if (temperature := last_state.attributes.get(ATTR_TEMPERATURE)) is not None: self._attr_target_temperature = float(temperature) + if (swing := last_state.attributes.get(ATTR_SWING_MODE)) in _HA_SWING_TO_LIB: + self._attr_swing_mode = swing + if ( + swing_h := last_state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) + ) in _HA_SWING_H_TO_LIB: + self._attr_swing_horizontal_mode = swing_h @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -202,6 +266,24 @@ class LgAcClimateEntity( self._attr_fan_mode = fan_mode self.async_write_ha_state() + @override + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the vertical swing mode. + + Each vane position is a self-contained fixed code, so it is sent directly + rather than folded into the current state frame. + """ + await self._send_command(_HA_SWING_TO_LIB[swing_mode].to_command()) + self._attr_swing_mode = swing_mode + self.async_write_ha_state() + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal swing mode.""" + await self._send_command(_HA_SWING_H_TO_LIB[swing_horizontal_mode].to_command()) + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + def _build_command(self, mode: LgAcMode, temp: int, fan_mode: str) -> LgAcCommand: """Build a command from a mode, a temperature and a fan mode. @@ -227,6 +309,7 @@ class LgAcClimateWithReceiver(LgAcClimateEntity, InfraredReceiverConsumerEntity) """Update state from a physical remote signal.""" command = LgAcCommand.from_raw_timings(signal.timings) if command is None: + self._handle_fixed_signal(signal) return hvac_mode = _LIB_MODE_TO_HA[command.mode] @@ -241,3 +324,21 @@ class LgAcClimateWithReceiver(LgAcClimateEntity, InfraredReceiverConsumerEntity) self._attr_target_temperature = float(command.temperature) self.async_write_ha_state() + + @callback + def _handle_fixed_signal(self, signal: InfraredReceivedSignal) -> None: + """Update the swing state from a fixed-code frame (e.g. a vane button).""" + command = LgAcFixedCommand.from_raw_timings(signal.timings) + if command is None: + return + try: + code = LGACCode(command.code) + except ValueError: + return + + if (swing := _LIB_SWING_TO_HA.get(code)) is not None: + self._attr_swing_mode = swing + self.async_write_ha_state() + elif (swing_h := _LIB_SWING_H_TO_HA.get(code)) is not None: + self._attr_swing_horizontal_mode = swing_h + self.async_write_ha_state() diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json index 896d1eb4e998..9b3aed6831c5 100644 --- a/homeassistant/components/lg_infrared/icons.json +++ b/homeassistant/components/lg_infrared/icons.json @@ -88,6 +88,39 @@ "up": { "default": "mdi:arrow-up" } + }, + "climate": { + "lg_ac": { + "state_attributes": { + "swing_horizontal_mode": { + "default": "mdi:circle-medium", + "state": { + "left": "mdi:chevron-double-left", + "left_half": "mdi:arrow-left", + "middle": "mdi:circle-small", + "middle_left": "mdi:chevron-left", + "middle_right": "mdi:chevron-right", + "off": "mdi:arrow-oscillating-off", + "right": "mdi:chevron-double-right", + "right_half": "mdi:arrow-right", + "swing": "mdi:arrow-expand-horizontal" + } + }, + "swing_mode": { + "default": "mdi:circle-medium", + "state": { + "high": "mdi:chevron-up", + "highest": "mdi:chevron-double-up", + "low": "mdi:chevron-down", + "lowest": "mdi:chevron-double-down", + "middle_high": "mdi:arrow-up-thin", + "middle_low": "mdi:arrow-down-thin", + "off": "mdi:arrow-oscillating-off", + "swing": "mdi:arrow-oscillating" + } + } + } + } } } } diff --git a/homeassistant/components/lg_infrared/quality_scale.yaml b/homeassistant/components/lg_infrared/quality_scale.yaml index 268b4f9c5356..90bc25e6bad0 100644 --- a/homeassistant/components/lg_infrared/quality_scale.yaml +++ b/homeassistant/components/lg_infrared/quality_scale.yaml @@ -93,10 +93,7 @@ rules: status: exempt comment: | This integration does not raise exceptions. - icon-translations: - status: exempt - comment: | - This integration does not use custom icons. + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index 3dfea8c7405d..d32e6826d38e 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -144,6 +144,31 @@ "medium_low": "Medium low", "quiet": "Quiet" } + }, + "swing_horizontal_mode": { + "state": { + "left": "Left", + "left_half": "Left half", + "middle": "Middle", + "middle_left": "Middle left", + "middle_right": "Middle right", + "off": "Off", + "right": "Right", + "right_half": "Right half", + "swing": "Swing" + } + }, + "swing_mode": { + "state": { + "high": "High", + "highest": "Highest", + "low": "Low", + "lowest": "Lowest", + "middle_high": "Middle high", + "middle_low": "Middle low", + "off": "Off", + "swing": "Swing" + } } } } diff --git a/tests/components/lg_infrared/snapshots/test_climate.ambr b/tests/components/lg_infrared/snapshots/test_climate.ambr index 86899d87d002..d829ec0ef951 100644 --- a/tests/components/lg_infrared/snapshots/test_climate.ambr +++ b/tests/components/lg_infrared/snapshots/test_climate.ambr @@ -22,6 +22,27 @@ ]), : 30.0, : 16.0, + : list([ + 'off', + 'left', + 'middle_left', + 'middle', + 'middle_right', + 'right', + 'left_half', + 'right_half', + 'swing', + ]), + : list([ + 'off', + 'highest', + 'high', + 'middle_high', + 'middle_low', + 'low', + 'lowest', + 'swing', + ]), : 1.0, }), 'config_entry_id': , @@ -48,7 +69,7 @@ 'platform': 'lg_infrared', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'lg_ac', 'unique_id': '01JTEST0000000000000000000_climate', 'unit_of_measurement': None, @@ -77,7 +98,30 @@ ]), : 30.0, : 16.0, - : , + : , + : 'off', + : list([ + 'off', + 'left', + 'middle_left', + 'middle', + 'middle_right', + 'right', + 'left_half', + 'right_half', + 'swing', + ]), + : 'off', + : list([ + 'off', + 'highest', + 'high', + 'middle_high', + 'middle_low', + 'low', + 'lowest', + 'swing', + ]), : 1.0, : 16.0, }), diff --git a/tests/components/lg_infrared/test_climate.py b/tests/components/lg_infrared/test_climate.py index 93e7a24e9816..53af2d26d228 100644 --- a/tests/components/lg_infrared/test_climate.py +++ b/tests/components/lg_infrared/test_climate.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import patch +from infrared_protocols.codes.lg.ac import LGACCode from infrared_protocols.commands.lg_ac import ( MIN_TEMP, LgAcCommand, @@ -13,6 +14,8 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_HIGH, @@ -20,6 +23,8 @@ from homeassistant.components.climate import ( FAN_MEDIUM, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, HVACMode, @@ -441,12 +446,17 @@ async def test_receiver_ignores_non_lg_ac_signal( [ pytest.param( [HVACMode.COOL, HVACMode.DRY], - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE, + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE, id="with_temperature_mode", ), pytest.param( [HVACMode.DRY, HVACMode.FAN_ONLY], - ClimateEntityFeature.FAN_MODE, + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE, id="without_temperature_mode", ), ], @@ -467,20 +477,25 @@ async def test_target_temperature_feature_follows_configured_modes( [ pytest.param( HVACMode.COOL, - {"fan_mode": FAN_HIGH, "temperature": 29.0}, - (HVACMode.COOL, FAN_HIGH, 29.0), + { + "fan_mode": FAN_HIGH, + "temperature": 29.0, + ATTR_SWING_MODE: "high", + ATTR_SWING_HORIZONTAL_MODE: "left", + }, + (HVACMode.COOL, FAN_HIGH, 29.0, "high", "left"), id="full_state", ), pytest.param( STATE_UNAVAILABLE, {}, - (HVACMode.OFF, FAN_AUTO, float(MIN_TEMP)), + (HVACMode.OFF, FAN_AUTO, float(MIN_TEMP), "off", "off"), id="unavailable_falls_back_to_defaults", ), pytest.param( HVACMode.HEAT, {"fan_mode": FAN_HIGH, "temperature": 29.0}, - (HVACMode.OFF, FAN_HIGH, 29.0), + (HVACMode.OFF, FAN_HIGH, 29.0, "off", "off"), id="mode_no_longer_configured_is_ignored", ), ], @@ -492,7 +507,7 @@ async def test_state_restored_on_restart( platforms: list[Platform], restored_state: str, restored_attributes: dict[str, Any], - expected: tuple[HVACMode, str, float], + expected: tuple[HVACMode, str, float, str, str], ) -> None: """Test the assumed state is restored, since infrared cannot read it back.""" mock_restore_cache( @@ -506,10 +521,14 @@ async def test_state_restored_on_restart( state = hass.states.get(_CLIMATE_ENTITY_ID) assert state is not None - expected_mode, expected_fan, expected_temp = expected + expected_mode, expected_fan, expected_temp, expected_swing, expected_swing_h = ( + expected + ) assert state.state == expected_mode assert state.attributes["fan_mode"] == expected_fan assert state.attributes["temperature"] == expected_temp + assert state.attributes[ATTR_SWING_MODE] == expected_swing + assert state.attributes[ATTR_SWING_HORIZONTAL_MODE] == expected_swing_h @pytest.mark.usefixtures("init_integration") @@ -563,3 +582,112 @@ async def test_set_temperature_with_unsupported_hvac_mode( ) assert len(mock_infrared_emitter_entity.send_command_calls) == 0 + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("swing_mode", "expected_button"), + [ + pytest.param("off", LGACCode.SWING_V_OFF, id="off"), + pytest.param("lowest", LGACCode.SWING_V_LOWEST, id="lowest"), + pytest.param("low", LGACCode.SWING_V_LOW, id="low"), + pytest.param("middle_low", LGACCode.SWING_V_MIDDLE_LOW, id="middle_low"), + pytest.param("middle_high", LGACCode.SWING_V_MIDDLE_HIGH, id="middle_high"), + pytest.param("high", LGACCode.SWING_V_HIGH, id="high"), + pytest.param("highest", LGACCode.SWING_V_HIGHEST, id="highest"), + pytest.param("swing", LGACCode.SWING_V_SWING, id="swing"), + ], +) +async def test_set_swing_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + swing_mode: str, + expected_button: LGACCode, +) -> None: + """Test setting a vertical swing mode sends that vane's fixed code.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_SWING_MODE: swing_mode}, + 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 == expected_button.to_command().get_raw_timings() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_SWING_MODE] == swing_mode + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("swing_mode", "expected_button"), + [ + pytest.param("off", LGACCode.SWING_H_OFF, id="off"), + pytest.param("left", LGACCode.SWING_H_LEFT, id="left"), + pytest.param("middle_left", LGACCode.SWING_H_MIDDLE_LEFT, id="middle_left"), + pytest.param("middle", LGACCode.SWING_H_MIDDLE, id="middle"), + pytest.param("middle_right", LGACCode.SWING_H_MIDDLE_RIGHT, id="middle_right"), + pytest.param("right", LGACCode.SWING_H_RIGHT, id="right"), + pytest.param("left_half", LGACCode.SWING_H_MIDDLE_TO_LEFT, id="left_half"), + pytest.param("right_half", LGACCode.SWING_H_MIDDLE_TO_RIGHT, id="right_half"), + pytest.param("swing", LGACCode.SWING_H_SWING, id="swing"), + ], +) +async def test_set_swing_horizontal_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + swing_mode: str, + expected_button: LGACCode, +) -> None: + """Test setting a horizontal swing mode sends that vane's fixed code.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_mode}, + 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 == expected_button.to_command().get_raw_timings() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_SWING_HORIZONTAL_MODE] == swing_mode + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("button", "attribute", "expected"), + [ + pytest.param( + LGACCode.SWING_V_HIGH, ATTR_SWING_MODE, "high", id="vertical_position" + ), + pytest.param( + LGACCode.SWING_H_RIGHT, + ATTR_SWING_HORIZONTAL_MODE, + "right", + id="horizontal_position", + ), + ], +) +async def test_receiver_updates_swing_from_fixed_code( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + button: LGACCode, + attribute: str, + expected: str, +) -> None: + """Test a received vane fixed code updates the matching swing attribute.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=button.to_command().get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.attributes[attribute] == expected