Add fan support for KNX climate entities (#126368)

* Add fan mode support to knx climate

* fix linting errors

* remove unneeded None protection from CONF_FAN_PERCENTAGES_MODES

* Update homeassistant/components/knx/climate.py

Co-authored-by: Matthias Alphart <farmio@alphart.net>

* Update homeassistant/components/knx/climate.py

Co-authored-by: Matthias Alphart <farmio@alphart.net>

* Update homeassistant/components/knx/climate.py

Co-authored-by: Matthias Alphart <farmio@alphart.net>

* Update homeassistant/components/knx/schema.py

Co-authored-by: Matthias Alphart <farmio@alphart.net>

* find closest percentage when not in fan modes

* new field for fan speed mode, max steps apply to both step and percentage

* not picking FAN_OFF when the percentage is closest to zero

* add fan zero mode to support auto mode

* use StrEnum for FanZeroMode

* change default to 'percent'

* fix mypy errors

---------

Co-authored-by: Matthias Alphart <farmio@alphart.net>
This commit is contained in:
Doron Somech 2024-09-24 22:38:09 +03:00 committed by GitHub
parent 69ecdda5f5
commit d2d3ab2d98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 482 additions and 4 deletions

View File

@ -10,10 +10,15 @@ from xknx.devices import (
ClimateMode as XknxClimateMode,
Device as XknxDevice,
)
from xknx.devices.fan import FanSpeedMode
from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
from homeassistant import config_entries
from homeassistant.components.climate import (
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_ON,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@ -126,6 +131,11 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
min_temp=config.get(ClimateSchema.CONF_MIN_TEMP),
max_temp=config.get(ClimateSchema.CONF_MAX_TEMP),
mode=climate_mode,
group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS),
group_address_fan_speed_state=config.get(
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS
),
fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE],
)
@ -166,6 +176,36 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
self._attr_preset_modes = [
mode.name.lower() for mode in self._device.mode.operation_modes
]
fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP]
self._fan_modes_percentages = [
int(100 * i / fan_max_step) for i in range(fan_max_step + 1)
]
self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE]
if self._device.fan_speed is not None and self._device.fan_speed.initialized:
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
if fan_max_step == 3:
self._attr_fan_modes = [
self.fan_zero_mode,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
]
elif fan_max_step == 2:
self._attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH]
elif fan_max_step == 1:
self._attr_fan_modes = [self.fan_zero_mode, FAN_ON]
elif self._device.fan_speed_mode == FanSpeedMode.STEP:
self._attr_fan_modes = [self.fan_zero_mode] + [
str(i) for i in range(1, fan_max_step + 1)
]
else:
self._attr_fan_modes = [self.fan_zero_mode] + [
f"{percentage}%" for percentage in self._fan_modes_percentages[1:]
]
self._attr_target_temperature_step = self._device.temperature_step
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
@ -322,6 +362,41 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
)
self.async_write_ha_state()
@property
def fan_mode(self) -> str:
"""Return the fan setting."""
fan_speed = self._device.current_fan_speed
if not fan_speed or self._attr_fan_modes is None:
return self.fan_zero_mode
if self._device.fan_speed_mode == FanSpeedMode.STEP:
return self._attr_fan_modes[fan_speed]
# Find the closest fan mode percentage
closest_percentage = min(
self._fan_modes_percentages[1:], # fan_speed == 0 is handled above
key=lambda x: abs(x - fan_speed),
)
return self._attr_fan_modes[
self._fan_modes_percentages.index(closest_percentage)
]
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
if self._attr_fan_modes is None:
return
fan_mode_index = self._attr_fan_modes.index(fan_mode)
if self._device.fan_speed_mode == FanSpeedMode.STEP:
await self._device.set_fan_speed(fan_mode_index)
return
await self._device.set_fan_speed(self._fan_modes_percentages[fan_mode_index])
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return device specific state attributes."""

View File

@ -3,13 +3,13 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from enum import Enum
from enum import Enum, StrEnum
from typing import TYPE_CHECKING, Final, TypedDict
from xknx.dpt.dpt_20 import HVACControllerMode
from xknx.telegram import Telegram
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.components.climate import FAN_AUTO, FAN_OFF, HVACAction, HVACMode
from homeassistant.const import Platform
from homeassistant.util.hass_dict import HassKey
@ -129,6 +129,13 @@ class ColorTempModes(Enum):
RELATIVE = "5.001"
class FanZeroMode(StrEnum):
"""Enum for setting the fan zero mode."""
OFF = FAN_OFF
AUTO = FAN_AUTO
SUPPORTED_PLATFORMS_YAML: Final = {
Platform.BINARY_SENSOR,
Platform.BUTTON,

View File

@ -7,7 +7,7 @@ from collections import OrderedDict
from typing import ClassVar, Final
import voluptuous as vol
from xknx.devices.climate import SetpointShiftMode
from xknx.devices.climate import FanSpeedMode, SetpointShiftMode
from xknx.dpt import DPTBase, DPTNumeric
from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
from xknx.exceptions import ConversionError, CouldNotParseTelegram
@ -15,7 +15,7 @@ from xknx.exceptions import ConversionError, CouldNotParseTelegram
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
)
from homeassistant.components.climate import HVACMode
from homeassistant.components.climate import FAN_OFF, HVACMode
from homeassistant.components.cover import (
DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA,
)
@ -54,6 +54,7 @@ from .const import (
CONF_SYNC_STATE,
KNX_ADDRESS,
ColorTempModes,
FanZeroMode,
)
from .validation import (
backwards_compatible_xknx_climate_enum_member,
@ -341,6 +342,11 @@ class ClimateSchema(KNXPlatformSchema):
CONF_ON_OFF_INVERT = "on_off_invert"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
CONF_FAN_SPEED_ADDRESS = "fan_speed_address"
CONF_FAN_SPEED_STATE_ADDRESS = "fan_speed_state_address"
CONF_FAN_MAX_STEP = "fan_max_step"
CONF_FAN_SPEED_MODE = "fan_speed_mode"
CONF_FAN_ZERO_MODE = "fan_zero_mode"
DEFAULT_NAME = "KNX Climate"
DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010"
@ -348,6 +354,7 @@ class ClimateSchema(KNXPlatformSchema):
DEFAULT_SETPOINT_SHIFT_MIN = -6
DEFAULT_TEMPERATURE_STEP = 0.1
DEFAULT_ON_OFF_INVERT = False
DEFAULT_FAN_SPEED_MODE = "percent"
ENTITY_SCHEMA = vol.All(
# deprecated since September 2020
@ -423,6 +430,15 @@ class ClimateSchema(KNXPlatformSchema):
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_MAX_STEP, default=3): cv.byte,
vol.Optional(
CONF_FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
): vol.All(vol.Upper, cv.enum(FanSpeedMode)),
vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
FanZeroMode
),
}
),
)

View File

@ -439,3 +439,383 @@ async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) ->
knx.assert_state(
"climate.test", HVACMode.HEAT, command_value=0, hvac_action=STATE_IDLE
)
async def test_fan_speed_3_steps(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX climate fan speed 3 steps."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_SPEED_MODE: "step",
ClimateSchema.CONF_FAN_MAX_STEP: 3,
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (0x01,))
knx.assert_state(
"climate.test",
HVACMode.HEAT,
fan_mode="low",
fan_modes=["off", "low", "medium", "high"],
)
# set fan mode
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "medium"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x02,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="medium")
# turn off
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "off"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off")
async def test_fan_speed_2_steps(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX climate fan speed 2 steps."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_SPEED_MODE: "step",
ClimateSchema.CONF_FAN_MAX_STEP: 2,
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (0x01,))
knx.assert_state(
"climate.test", HVACMode.HEAT, fan_mode="low", fan_modes=["off", "low", "high"]
)
# set fan mode
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "high"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x02,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="high")
# turn off
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "off"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off")
async def test_fan_speed_1_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX climate fan speed 1 step."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_SPEED_MODE: "step",
ClimateSchema.CONF_FAN_MAX_STEP: 1,
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (0x01,))
knx.assert_state(
"climate.test", HVACMode.HEAT, fan_mode="on", fan_modes=["off", "on"]
)
# turn off
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "off"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off")
async def test_fan_speed_5_steps(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX climate fan speed 5 steps."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_SPEED_MODE: "step",
ClimateSchema.CONF_FAN_MAX_STEP: 5,
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (0x01,))
knx.assert_state(
"climate.test",
HVACMode.HEAT,
fan_mode="1",
fan_modes=["off", "1", "2", "3", "4", "5"],
)
# set fan mode
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "4"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x04,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="4")
# turn off
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "off"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off")
async def test_fan_speed_percentage(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX climate fan speed percentage."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_SPEED_MODE: "percent",
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (84,)) # 84 / 255 = 33%
knx.assert_state(
"climate.test",
HVACMode.HEAT,
fan_mode="low",
fan_modes=["off", "low", "medium", "high"],
)
# set fan mode
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "medium"},
blocking=True,
)
await knx.assert_write("1/2/6", (168,)) # 168 / 255 = 66%
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="medium")
# turn off
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "off"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off")
# check fan mode that is not in the fan modes list
await knx.receive_write("1/2/6", (127,)) # 127 / 255 = 50%
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="medium")
# check FAN_OFF is not picked when fan_speed is closest to zero
await knx.receive_write("1/2/6", (3,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="low")
async def test_fan_speed_percentage_4_steps(
hass: HomeAssistant, knx: KNXTestKit
) -> None:
"""Test KNX climate fan speed percentage with 4 steps."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_SPEED_MODE: "percent",
ClimateSchema.CONF_FAN_MAX_STEP: 4,
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (64,)) # 64 / 255 = 25%
knx.assert_state(
"climate.test",
HVACMode.HEAT,
fan_mode="25%",
fan_modes=["off", "25%", "50%", "75%", "100%"],
)
# set fan mode
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "50%"},
blocking=True,
)
await knx.assert_write("1/2/6", (128,)) # 128 / 255 = 50%
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="50%")
# turn off
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "off"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off")
# check fan mode that is not in the fan modes list
await knx.receive_write("1/2/6", (168,)) # 168 / 255 = 66%
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="75%")
async def test_fan_speed_zero_mode_auto(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX climate fan speed 3 steps."""
await knx.setup_integration(
{
ClimateSchema.PLATFORM: {
CONF_NAME: "test",
ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3",
ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4",
ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5",
ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6",
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7",
ClimateSchema.CONF_FAN_MAX_STEP: 3,
ClimateSchema.CONF_FAN_SPEED_MODE: "step",
ClimateSchema.CONF_FAN_ZERO_MODE: "auto",
}
}
)
# read states state updater
await knx.assert_read("1/2/3")
await knx.assert_read("1/2/5")
# StateUpdater initialize state
await knx.receive_response("1/2/5", RAW_FLOAT_22_0)
await knx.receive_response("1/2/3", RAW_FLOAT_21_0)
# Query status
await knx.assert_read("1/2/7")
await knx.receive_response("1/2/7", (0x01,))
knx.assert_state(
"climate.test",
HVACMode.HEAT,
fan_mode="low",
fan_modes=["auto", "low", "medium", "high"],
)
# set auto
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": "climate.test", "fan_mode": "auto"},
blocking=True,
)
await knx.assert_write("1/2/6", (0x0,))
knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="auto")