Add swing mode support to LG Infrared AC climate (#177191)

This commit is contained in:
Dr.Blank
2026-08-31 14:24:38 +01:00
committed by GitHub
parent 9889cfcf66
commit 9c0ef04514
6 changed files with 343 additions and 15 deletions
+102 -1
View File
@@ -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()
@@ -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"
}
}
}
}
}
}
}
@@ -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
@@ -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"
}
}
}
}
@@ -22,6 +22,27 @@
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30.0,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16.0,
<ClimateEntityCapabilityAttribute.SWING_HORIZONTAL_MODES: 'swing_horizontal_modes'>: list([
'off',
'left',
'middle_left',
'middle',
'middle_right',
'right',
'left_half',
'right_half',
'swing',
]),
<ClimateEntityCapabilityAttribute.SWING_MODES: 'swing_modes'>: list([
'off',
'highest',
'high',
'middle_high',
'middle_low',
'low',
'lowest',
'swing',
]),
<ClimateEntityCapabilityAttribute.TARGET_TEMP_STEP: 'target_temp_step'>: 1.0,
}),
'config_entry_id': <ANY>,
@@ -48,7 +69,7 @@
'platform': 'lg_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 9>,
'supported_features': <ClimateEntityFeature: 553>,
'translation_key': 'lg_ac',
'unique_id': '01JTEST0000000000000000000_climate',
'unit_of_measurement': None,
@@ -77,7 +98,30 @@
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30.0,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16.0,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <ClimateEntityFeature: 9>,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <ClimateEntityFeature: 553>,
<ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE: 'swing_horizontal_mode'>: 'off',
<ClimateEntityCapabilityAttribute.SWING_HORIZONTAL_MODES: 'swing_horizontal_modes'>: list([
'off',
'left',
'middle_left',
'middle',
'middle_right',
'right',
'left_half',
'right_half',
'swing',
]),
<ClimateEntityStateAttribute.SWING_MODE: 'swing_mode'>: 'off',
<ClimateEntityCapabilityAttribute.SWING_MODES: 'swing_modes'>: list([
'off',
'highest',
'high',
'middle_high',
'middle_low',
'low',
'lowest',
'swing',
]),
<ClimateEntityCapabilityAttribute.TARGET_TEMP_STEP: 'target_temp_step'>: 1.0,
<ClimateEntityStateAttribute.TARGET_TEMPERATURE: 'temperature'>: 16.0,
}),
+136 -8
View File
@@ -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