Add horizontal swing support to ClimateEntity (#125578)

* Add horizontal swing support to ClimateEntity

* Fixes + tests

* Fixes
This commit is contained in:
G Johansson 2024-11-27 15:06:46 +01:00 committed by GitHub
parent 88feb8a7ad
commit a7db35c76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 224 additions and 3 deletions

View File

@ -70,6 +70,8 @@ from .const import ( # noqa: F401
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_HORIZONTAL_MODES,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
@ -101,6 +103,7 @@ from .const import ( # noqa: F401
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
SWING_BOTH,
@ -219,6 +222,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_handle_set_swing_mode_service",
[ClimateEntityFeature.SWING_MODE],
)
component.async_register_entity_service(
SERVICE_SET_SWING_HORIZONTAL_MODE,
{vol.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string},
"async_handle_set_swing_horizontal_mode_service",
[ClimateEntityFeature.SWING_HORIZONTAL_MODE],
)
return True
@ -256,6 +265,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"fan_modes",
"swing_mode",
"swing_modes",
"swing_horizontal_mode",
"swing_horizontal_modes",
"supported_features",
"min_temp",
"max_temp",
@ -300,6 +311,8 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_supported_features: ClimateEntityFeature = ClimateEntityFeature(0)
_attr_swing_mode: str | None
_attr_swing_modes: list[str] | None
_attr_swing_horizontal_mode: str | None
_attr_swing_horizontal_modes: list[str] | None
_attr_target_humidity: float | None = None
_attr_target_temperature_high: float | None
_attr_target_temperature_low: float | None
@ -513,6 +526,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ClimateEntityFeature.SWING_MODE in supported_features:
data[ATTR_SWING_MODES] = self.swing_modes
if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
data[ATTR_SWING_HORIZONTAL_MODES] = self.swing_horizontal_modes
return data
@final
@ -564,6 +580,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ClimateEntityFeature.SWING_MODE in supported_features:
data[ATTR_SWING_MODE] = self.swing_mode
if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode
if ClimateEntityFeature.AUX_HEAT in supported_features:
data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF
if (
@ -691,11 +710,27 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""
return self._attr_swing_modes
@cached_property
def swing_horizontal_mode(self) -> str | None:
"""Return the horizontal swing setting.
Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE.
"""
return self._attr_swing_horizontal_mode
@cached_property
def swing_horizontal_modes(self) -> list[str] | None:
"""Return the list of available horizontal swing modes.
Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE.
"""
return self._attr_swing_horizontal_modes
@final
@callback
def _valid_mode_or_raise(
self,
mode_type: Literal["preset", "swing", "fan", "hvac"],
mode_type: Literal["preset", "horizontal_swing", "swing", "fan", "hvac"],
mode: str | HVACMode,
modes: list[str] | list[HVACMode] | None,
) -> None:
@ -793,6 +828,26 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Set new target swing operation."""
await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode)
@final
async def async_handle_set_swing_horizontal_mode_service(
self, swing_horizontal_mode: str
) -> None:
"""Validate and set new horizontal swing mode."""
self._valid_mode_or_raise(
"horizontal_swing", swing_horizontal_mode, self.swing_horizontal_modes
)
await self.async_set_swing_horizontal_mode(swing_horizontal_mode)
def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set new target horizontal swing operation."""
raise NotImplementedError
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set new target horizontal swing operation."""
await self.hass.async_add_executor_job(
self.set_swing_horizontal_mode, swing_horizontal_mode
)
@final
async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None:
"""Validate and set new preset mode."""

View File

@ -92,6 +92,10 @@ SWING_BOTH = "both"
SWING_VERTICAL = "vertical"
SWING_HORIZONTAL = "horizontal"
# Possible horizontal swing state
SWING_HORIZONTAL_ON = "on"
SWING_HORIZONTAL_OFF = "off"
class HVACAction(StrEnum):
"""HVAC action for climate devices."""
@ -134,6 +138,8 @@ ATTR_HVAC_MODES = "hvac_modes"
ATTR_HVAC_MODE = "hvac_mode"
ATTR_SWING_MODES = "swing_modes"
ATTR_SWING_MODE = "swing_mode"
ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode"
ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step"
@ -153,6 +159,7 @@ SERVICE_SET_PRESET_MODE = "set_preset_mode"
SERVICE_SET_HUMIDITY = "set_humidity"
SERVICE_SET_HVAC_MODE = "set_hvac_mode"
SERVICE_SET_SWING_MODE = "set_swing_mode"
SERVICE_SET_SWING_HORIZONTAL_MODE = "set_swing_horizontal_mode"
SERVICE_SET_TEMPERATURE = "set_temperature"
@ -168,6 +175,7 @@ class ClimateEntityFeature(IntFlag):
AUX_HEAT = 64
TURN_OFF = 128
TURN_ON = 256
SWING_HORIZONTAL_MODE = 512
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.

View File

@ -51,6 +51,13 @@
"on": "mdi:arrow-oscillating",
"vertical": "mdi:arrow-up-down"
}
},
"swing_horizontal_mode": {
"default": "mdi:circle-medium",
"state": {
"off": "mdi:arrow-oscillating-off",
"on": "mdi:arrow-expand-horizontal"
}
}
}
}
@ -65,6 +72,9 @@
"set_swing_mode": {
"service": "mdi:arrow-oscillating"
},
"set_swing_horizontal_mode": {
"service": "mdi:arrow-expand-horizontal"
},
"set_temperature": {
"service": "mdi:thermometer"
},

View File

@ -14,6 +14,7 @@ from .const import (
ATTR_HUMIDITY,
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@ -23,6 +24,7 @@ from .const import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
)
@ -76,6 +78,14 @@ async def _async_reproduce_states(
):
await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE])
if (
ATTR_SWING_HORIZONTAL_MODE in state.attributes
and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None
):
await call_service(
SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE]
)
if (
ATTR_FAN_MODE in state.attributes
and state.attributes[ATTR_FAN_MODE] is not None

View File

@ -131,7 +131,20 @@ set_swing_mode:
fields:
swing_mode:
required: true
example: "horizontal"
example: "on"
selector:
text:
set_swing_horizontal_mode:
target:
entity:
domain: climate
supported_features:
- climate.ClimateEntityFeature.SWING_HORIZONTAL_MODE
fields:
swing_horizontal_mode:
required: true
example: "on"
selector:
text:

View File

@ -19,6 +19,7 @@ from . import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@ -34,6 +35,7 @@ SIGNIFICANT_ATTRIBUTES: set[str] = {
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
@ -70,6 +72,7 @@ def async_check_significant_change(
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
ATTR_SWING_HORIZONTAL_MODE,
]:
return True

View File

@ -123,6 +123,16 @@
"swing_modes": {
"name": "Swing modes"
},
"swing_horizontal_mode": {
"name": "Horizontal swing mode",
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"swing_horizontal_modes": {
"name": "Horizontal swing modes"
},
"target_temp_high": {
"name": "Upper target temperature"
},
@ -221,6 +231,16 @@
}
}
},
"set_swing_horizontal_mode": {
"name": "Set horizontal swing mode",
"description": "Sets horizontal swing operation mode.",
"fields": {
"swing_horizontal_mode": {
"name": "Horizontal swing mode",
"description": "Horizontal swing operation mode."
}
}
},
"turn_on": {
"name": "[%key:common::action::turn_on%]",
"description": "Turns climate device on."
@ -264,6 +284,9 @@
"not_valid_swing_mode": {
"message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}."
},
"not_valid_horizontal_swing_mode": {
"message": "Horizontal swing mode {mode} is not valid. Valid horizontal swing modes are: {modes}."
},
"not_valid_fan_mode": {
"message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}."
},

View File

@ -43,6 +43,7 @@ async def async_setup_entry(
target_humidity=None,
current_humidity=None,
swing_mode=None,
swing_horizontal_mode=None,
hvac_mode=HVACMode.HEAT,
hvac_action=HVACAction.HEATING,
target_temp_high=None,
@ -60,6 +61,7 @@ async def async_setup_entry(
target_humidity=67.4,
current_humidity=54.2,
swing_mode="off",
swing_horizontal_mode="auto",
hvac_mode=HVACMode.COOL,
hvac_action=HVACAction.COOLING,
target_temp_high=None,
@ -78,6 +80,7 @@ async def async_setup_entry(
target_humidity=None,
current_humidity=None,
swing_mode="auto",
swing_horizontal_mode=None,
hvac_mode=HVACMode.HEAT_COOL,
hvac_action=None,
target_temp_high=24,
@ -109,6 +112,7 @@ class DemoClimate(ClimateEntity):
target_humidity: float | None,
current_humidity: float | None,
swing_mode: str | None,
swing_horizontal_mode: str | None,
hvac_mode: HVACMode,
hvac_action: HVACAction | None,
target_temp_high: float | None,
@ -129,6 +133,8 @@ class DemoClimate(ClimateEntity):
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
if swing_mode is not None:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
if swing_horizontal_mode is not None:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
if HVACMode.HEAT_COOL in hvac_modes or HVACMode.AUTO in hvac_modes:
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@ -147,9 +153,11 @@ class DemoClimate(ClimateEntity):
self._hvac_action = hvac_action
self._hvac_mode = hvac_mode
self._current_swing_mode = swing_mode
self._current_swing_horizontal_mode = swing_horizontal_mode
self._fan_modes = ["on_low", "on_high", "auto_low", "auto_high", "off"]
self._hvac_modes = hvac_modes
self._swing_modes = ["auto", "1", "2", "3", "off"]
self._swing_horizontal_modes = ["auto", "rangefull", "off"]
self._target_temperature_high = target_temp_high
self._target_temperature_low = target_temp_low
self._attr_device_info = DeviceInfo(
@ -242,6 +250,16 @@ class DemoClimate(ClimateEntity):
"""List of available swing modes."""
return self._swing_modes
@property
def swing_horizontal_mode(self) -> str | None:
"""Return the swing setting."""
return self._current_swing_horizontal_mode
@property
def swing_horizontal_modes(self) -> list[str]:
"""List of available swing modes."""
return self._swing_horizontal_modes
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
if kwargs.get(ATTR_TEMPERATURE) is not None:
@ -266,6 +284,11 @@ class DemoClimate(ClimateEntity):
self._current_swing_mode = swing_mode
self.async_write_ha_state()
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set new swing mode."""
self._current_swing_horizontal_mode = swing_horizontal_mode
self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
self._current_fan_mode = fan_mode

View File

@ -19,6 +19,13 @@
"auto": "mdi:arrow-oscillating",
"off": "mdi:arrow-oscillating-off"
}
},
"swing_horizontal_mode": {
"state": {
"rangefull": "mdi:pan-horizontal",
"auto": "mdi:compare-horizontal",
"off": "mdi:arrow-oscillating-off"
}
}
}
}

View File

@ -42,6 +42,13 @@
"auto": "Auto",
"off": "[%key:common::state::off%]"
}
},
"swing_horizontal_mode": {
"state": {
"rangefull": "Full range",
"auto": "Auto",
"off": "[%key:common::state::off%]"
}
}
}
}

View File

@ -24,6 +24,7 @@ from homeassistant.components.climate.const import (
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@ -31,8 +32,11 @@ from homeassistant.components.climate.const import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
SWING_HORIZONTAL_OFF,
SWING_HORIZONTAL_ON,
ClimateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@ -104,6 +108,7 @@ class MockClimateEntity(MockEntity, ClimateEntity):
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.SWING_HORIZONTAL_MODE
)
_attr_preset_mode = "home"
_attr_preset_modes = ["home", "away"]
@ -111,6 +116,8 @@ class MockClimateEntity(MockEntity, ClimateEntity):
_attr_fan_modes = ["auto", "off"]
_attr_swing_mode = "auto"
_attr_swing_modes = ["auto", "off"]
_attr_swing_horizontal_mode = "on"
_attr_swing_horizontal_modes = [SWING_HORIZONTAL_ON, SWING_HORIZONTAL_OFF]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature = 20
_attr_target_temperature_high = 25
@ -144,6 +151,10 @@ class MockClimateEntity(MockEntity, ClimateEntity):
"""Set swing mode."""
self._attr_swing_mode = swing_mode
def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set horizontal swing mode."""
self._attr_swing_horizontal_mode = swing_horizontal_mode
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
self._attr_hvac_mode = hvac_mode
@ -194,7 +205,11 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s
(enum_field, constant_prefix)
for enum_field in enum
if enum_field
not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF]
not in [
ClimateEntityFeature.TURN_ON,
ClimateEntityFeature.TURN_OFF,
ClimateEntityFeature.SWING_HORIZONTAL_MODE,
]
]
@ -339,6 +354,7 @@ async def test_mode_validation(
assert state.attributes.get(ATTR_PRESET_MODE) == "home"
assert state.attributes.get(ATTR_FAN_MODE) == "auto"
assert state.attributes.get(ATTR_SWING_MODE) == "auto"
assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "on"
await hass.services.async_call(
DOMAIN,
@ -358,6 +374,15 @@ async def test_mode_validation(
},
blocking=True,
)
await hass.services.async_call(
DOMAIN,
SERVICE_SET_SWING_HORIZONTAL_MODE,
{
"entity_id": "climate.test",
"swing_horizontal_mode": "off",
},
blocking=True,
)
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
@ -371,6 +396,7 @@ async def test_mode_validation(
assert state.attributes.get(ATTR_PRESET_MODE) == "away"
assert state.attributes.get(ATTR_FAN_MODE) == "off"
assert state.attributes.get(ATTR_SWING_MODE) == "off"
assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off"
await hass.services.async_call(
DOMAIN,
@ -427,6 +453,25 @@ async def test_mode_validation(
)
assert exc.value.translation_key == "not_valid_swing_mode"
with pytest.raises(
ServiceValidationError,
match="Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off",
) as exc:
await hass.services.async_call(
DOMAIN,
SERVICE_SET_SWING_HORIZONTAL_MODE,
{
"entity_id": "climate.test",
"swing_horizontal_mode": "invalid",
},
blocking=True,
)
assert (
str(exc.value)
== "Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off"
)
assert exc.value.translation_key == "not_valid_horizontal_swing_mode"
with pytest.raises(
ServiceValidationError,
match="Fan mode invalid is not valid. Valid fan modes are: auto, off",

View File

@ -6,6 +6,7 @@ from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@ -14,6 +15,7 @@ from homeassistant.components.climate import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
@ -96,6 +98,7 @@ async def test_state_with_context(hass: HomeAssistant) -> None:
[
(SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE),
(SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
(SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE),
(SERVICE_SET_FAN_MODE, ATTR_FAN_MODE),
(SERVICE_SET_HUMIDITY, ATTR_HUMIDITY),
(SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE),
@ -122,6 +125,7 @@ async def test_attribute(hass: HomeAssistant, service, attribute) -> None:
[
(SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE),
(SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
(SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE),
(SERVICE_SET_FAN_MODE, ATTR_FAN_MODE),
],
)

View File

@ -10,6 +10,7 @@ from homeassistant.components.climate import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@ -66,6 +67,18 @@ async def test_significant_state_change(hass: HomeAssistant) -> None:
),
(METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False),
(METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True),
(
METRIC,
{ATTR_SWING_HORIZONTAL_MODE: "old_value"},
{ATTR_SWING_HORIZONTAL_MODE: "old_value"},
False,
),
(
METRIC,
{ATTR_SWING_HORIZONTAL_MODE: "old_value"},
{ATTR_SWING_HORIZONTAL_MODE: "new_value"},
True,
),
# multiple attributes
(
METRIC,