Move the HomeKit thermostat linked fan service into the climate base (#176042)

This commit is contained in:
J. Nick Koston
2026-07-08 23:22:03 -06:00
committed by GitHub
parent 4ead445cc7
commit e8f529278b
3 changed files with 150 additions and 119 deletions
@@ -12,6 +12,7 @@ from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_FAN_MODES,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODES,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
@@ -22,14 +23,19 @@ from homeassistant.components.climate import (
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
DOMAIN as CLIMATE_DOMAIN,
FAN_AUTO,
FAN_OFF,
FAN_ON,
SERVICE_SET_FAN_MODE,
SERVICE_SET_SWING_MODE,
SWING_OFF,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
from homeassistant.core import State
from homeassistant.core import State, callback
from homeassistant.util.percentage import percentage_to_ordered_list_item
from .accessories import HomeAccessory
from .climate_util import (
@@ -43,11 +49,37 @@ from .climate_util import (
resolve_target_temp_range,
temperature_attribute_to_homekit,
)
from .const import CHAR_CURRENT_TEMPERATURE, PROP_MAX_VALUE, PROP_MIN_VALUE
from .const import (
CHAR_ACTIVE,
CHAR_CURRENT_FAN_STATE,
CHAR_CURRENT_TEMPERATURE,
CHAR_ROTATION_SPEED,
CHAR_SWING_MODE,
CHAR_TARGET_FAN_STATE,
PROP_MAX_VALUE,
PROP_MIN_STEP,
PROP_MIN_VALUE,
SERV_FANV2,
)
from .util import temperature_to_homekit, temperature_to_states
_LOGGER = logging.getLogger(__name__)
FAN_STATE_INACTIVE = 0
FAN_STATE_IDLE = 1
FAN_STATE_ACTIVE = 2
HC_HASS_TO_HOMEKIT_FAN_STATE = {
HVACAction.OFF: FAN_STATE_INACTIVE,
HVACAction.IDLE: FAN_STATE_IDLE,
HVACAction.HEATING: FAN_STATE_ACTIVE,
HVACAction.COOLING: FAN_STATE_ACTIVE,
HVACAction.DRYING: FAN_STATE_ACTIVE,
HVACAction.FAN: FAN_STATE_ACTIVE,
HVACAction.PREHEATING: FAN_STATE_IDLE,
HVACAction.DEFROSTING: FAN_STATE_IDLE,
}
class HomeKitClimateAccessory(HomeAccessory):
"""Base class for the Thermostat and HeaterCooler accessories."""
@@ -58,6 +90,11 @@ class HomeKitClimateAccessory(HomeAccessory):
char_current_temp: Characteristic
# Configured by _configure_fan_service when fan_chars is non-empty.
char_fan_active: Characteristic
char_target_fan_state: Characteristic
char_current_fan_state: Characteristic
def __init__(self, *args: Any) -> None:
"""Initialize the shared climate accessory state."""
super().__init__(*args, category=CATEGORY_THERMOSTAT)
@@ -83,6 +120,10 @@ class HomeKitClimateAccessory(HomeAccessory):
self.swing_on_mode = get_swing_on_mode(attributes)
self.swing_off_mode = get_swing_off_mode(attributes)
# Characteristics the subclass places on a linked fan service; which
# ones, if any, is the subclass's policy.
self.fan_chars: list[str] = []
# These attributes drive the characteristic set and valid values, so
# reload the accessory when any of them change.
self._reload_on_change_attrs.extend(
@@ -218,3 +259,95 @@ class HomeKitClimateAccessory(HomeAccessory):
swing_mode := attributes.get(ATTR_SWING_MODE)
):
self.char_swing.set_value(1 if is_swing_on(swing_mode) else 0)
def _configure_fan_service(self, primary_serv: Service) -> None:
"""Create a linked fan service for the chars in ``fan_chars``."""
serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars)
primary_serv.add_linked_service(serv_fan)
self.char_fan_active = serv_fan.configure_char(
CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active
)
if CHAR_SWING_MODE in self.fan_chars:
self.char_swing = serv_fan.configure_char(
CHAR_SWING_MODE,
value=0,
setter_callback=self._set_swing_mode,
)
self.char_swing.display_name = "Swing Mode"
if CHAR_ROTATION_SPEED in self.fan_chars:
self.char_speed = serv_fan.configure_char(
CHAR_ROTATION_SPEED,
value=100,
properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)},
setter_callback=self._set_fan_speed,
)
self.char_speed.display_name = "Fan Mode"
if CHAR_CURRENT_FAN_STATE in self.fan_chars:
self.char_current_fan_state = serv_fan.configure_char(
CHAR_CURRENT_FAN_STATE,
value=0,
)
self.char_current_fan_state.display_name = "Fan State"
if CHAR_TARGET_FAN_STATE in self.fan_chars:
self.char_target_fan_state = serv_fan.configure_char(
CHAR_TARGET_FAN_STATE,
value=0,
setter_callback=self._set_fan_auto,
)
self.char_target_fan_state.display_name = "Fan Auto"
def _get_on_mode(self) -> str:
"""Return the fan mode to use when leaving auto or turning the fan on."""
if self.ordered_fan_speeds:
speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50)
return self.fan_modes[speed_key]
return self.fan_modes[FAN_ON]
def _set_fan_active(self, active: int) -> None:
"""Send the climate fan mode for a HomeKit fan active toggle."""
_LOGGER.debug("%s: Set fan active to %s", self.entity_id, active)
if FAN_OFF not in self.fan_modes:
_LOGGER.debug(
"%s: Fan does not support off, resetting to on", self.entity_id
)
self.char_fan_active.value = 1
self.char_fan_active.notify()
return
mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF]
params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
def _set_fan_auto(self, auto: int) -> None:
"""Send the climate fan mode for a HomeKit fan auto toggle.
Subclasses must only add CHAR_TARGET_FAN_STATE to ``fan_chars`` when
FAN_AUTO is in ``fan_modes``; this setter assumes the mode exists.
"""
_LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto)
mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode()
params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
@callback
def _async_update_fan_service(self, new_state: State) -> None:
"""Update the linked fan service from the entity state."""
attributes = new_state.attributes
self._update_swing_char(attributes)
self._update_fan_speed_char(attributes)
fan_mode = attributes.get(ATTR_FAN_MODE)
fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None
if CHAR_TARGET_FAN_STATE in self.fan_chars:
self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0)
if CHAR_CURRENT_FAN_STATE in self.fan_chars and (
hvac_action := attributes.get(ATTR_HVAC_ACTION)
):
self.char_current_fan_state.set_value(
HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action]
)
self.char_fan_active.set_value(
int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF)
)
@@ -8,7 +8,6 @@ from pyhap.const import CATEGORY_THERMOSTAT
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
@@ -23,9 +22,7 @@ from homeassistant.components.climate import (
DEFAULT_MIN_HUMIDITY,
DOMAIN as CLIMATE_DOMAIN,
FAN_AUTO,
FAN_OFF,
FAN_ON,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT,
SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT,
@@ -55,7 +52,6 @@ from homeassistant.const import (
)
from homeassistant.core import State, callback
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.percentage import percentage_to_ordered_list_item
from .accessories import TYPES, HomeAccessory
from .climate_base import HomeKitClimateAccessory
@@ -64,7 +60,6 @@ from .climate_util import (
temperature_attribute_to_homekit,
)
from .const import (
CHAR_ACTIVE,
CHAR_COOLING_THRESHOLD_TEMPERATURE,
CHAR_CURRENT_FAN_STATE,
CHAR_CURRENT_HEATING_COOLING,
@@ -81,9 +76,7 @@ from .const import (
DEFAULT_MAX_TEMP_WATER_HEATER,
DEFAULT_MIN_TEMP_WATER_HEATER,
PROP_MAX_VALUE,
PROP_MIN_STEP,
PROP_MIN_VALUE,
SERV_FANV2,
SERV_THERMOSTAT,
)
from .util import get_min_max, temperature_to_states
@@ -145,21 +138,6 @@ HC_HASS_TO_HOMEKIT_ACTION = {
HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT,
}
FAN_STATE_INACTIVE = 0
FAN_STATE_IDLE = 1
FAN_STATE_ACTIVE = 2
HC_HASS_TO_HOMEKIT_FAN_STATE = {
HVACAction.OFF: FAN_STATE_INACTIVE,
HVACAction.IDLE: FAN_STATE_IDLE,
HVACAction.HEATING: FAN_STATE_ACTIVE,
HVACAction.COOLING: FAN_STATE_ACTIVE,
HVACAction.DRYING: FAN_STATE_ACTIVE,
HVACAction.FAN: FAN_STATE_ACTIVE,
HVACAction.PREHEATING: FAN_STATE_IDLE,
HVACAction.DEFROSTING: FAN_STATE_IDLE,
}
def _hk_hvac_mode_from_state(state: State) -> int | None:
"""Return the equivalent HomeKit HVAC mode for a given state."""
@@ -188,7 +166,6 @@ class Thermostat(HomeKitClimateAccessory):
# Add additional characteristics if auto mode is supported
self.chars: list[str] = []
self.fan_chars: list[str] = []
attributes = state.attributes
min_humidity, _ = get_min_max(
@@ -297,69 +274,12 @@ class Thermostat(HomeKitClimateAccessory):
if self.fan_chars:
if attributes.get(ATTR_HVAC_ACTION) is not None:
self.fan_chars.append(CHAR_CURRENT_FAN_STATE)
serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars)
serv_thermostat.add_linked_service(serv_fan)
self.char_active = serv_fan.configure_char(
CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active
)
if CHAR_SWING_MODE in self.fan_chars:
self.char_swing = serv_fan.configure_char(
CHAR_SWING_MODE,
value=0,
setter_callback=self._set_swing_mode,
)
self.char_swing.display_name = "Swing Mode"
if CHAR_ROTATION_SPEED in self.fan_chars:
self.char_speed = serv_fan.configure_char(
CHAR_ROTATION_SPEED,
value=100,
properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)},
setter_callback=self._set_fan_speed,
)
self.char_speed.display_name = "Fan Mode"
if CHAR_CURRENT_FAN_STATE in self.fan_chars:
self.char_current_fan_state = serv_fan.configure_char(
CHAR_CURRENT_FAN_STATE,
value=0,
)
self.char_current_fan_state.display_name = "Fan State"
if CHAR_TARGET_FAN_STATE in self.fan_chars and FAN_AUTO in self.fan_modes:
self.char_target_fan_state = serv_fan.configure_char(
CHAR_TARGET_FAN_STATE,
value=0,
setter_callback=self._set_fan_auto,
)
self.char_target_fan_state.display_name = "Fan Auto"
self._configure_fan_service(serv_thermostat)
self.async_update_state(state)
serv_thermostat.setter_callback = self._set_chars
def _get_on_mode(self) -> str:
if self.ordered_fan_speeds:
speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50)
return self.fan_modes[speed_key]
return self.fan_modes[FAN_ON]
def _set_fan_active(self, active: int) -> None:
_LOGGER.debug("%s: Set fan active to %s", self.entity_id, active)
if FAN_OFF not in self.fan_modes:
_LOGGER.debug(
"%s: Fan does not support off, resetting to on", self.entity_id
)
self.char_active.value = 1
self.char_active.notify()
return
mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF]
params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
def _set_fan_auto(self, auto: int) -> None:
_LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto)
mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode()
params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
def _set_chars(self, char_values: dict[str, Any]) -> None:
_LOGGER.debug("Thermostat _set_chars: %s", char_values)
events = []
@@ -602,31 +522,7 @@ class Thermostat(HomeKitClimateAccessory):
self.char_display_units.set_value(unit)
if self.fan_chars:
self._async_update_fan_state(new_state)
@callback
def _async_update_fan_state(self, new_state: State) -> None:
"""Update state without rechecking the device features."""
attributes = new_state.attributes
self._update_swing_char(attributes)
self._update_fan_speed_char(attributes)
fan_mode = attributes.get(ATTR_FAN_MODE)
fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None
if CHAR_TARGET_FAN_STATE in self.fan_chars:
self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0)
if CHAR_CURRENT_FAN_STATE in self.fan_chars and (
hvac_action := attributes.get(ATTR_HVAC_ACTION)
):
self.char_current_fan_state.set_value(
HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action]
)
self.char_active.set_value(
int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF)
)
self._async_update_fan_service(new_state)
@TYPES.register("WaterHeater")
@@ -44,6 +44,11 @@ from homeassistant.components.climate import (
HVACMode,
)
from homeassistant.components.homekit.accessories import HomeDriver
from homeassistant.components.homekit.climate_base import (
FAN_STATE_ACTIVE,
FAN_STATE_IDLE,
FAN_STATE_INACTIVE,
)
from homeassistant.components.homekit.const import (
ATTR_VALUE,
CHAR_CURRENT_FAN_STATE,
@@ -57,9 +62,6 @@ from homeassistant.components.homekit.const import (
PROP_MIN_VALUE,
)
from homeassistant.components.homekit.type_thermostats import (
FAN_STATE_ACTIVE,
FAN_STATE_IDLE,
FAN_STATE_INACTIVE,
HC_HEAT_COOL_AUTO,
HC_HEAT_COOL_COOL,
HC_HEAT_COOL_HEAT,
@@ -2336,13 +2338,13 @@ async def test_thermostat_with_fan_modes_with_auto(
assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id
assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_LOW
char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID]
char_fan_active_iid = acc.char_fan_active.to_HAP()[HAP_REPR_IID]
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_active_iid,
HAP_REPR_IID: char_fan_active_iid,
HAP_REPR_VALUE: 0,
}
]
@@ -2351,7 +2353,7 @@ async def test_thermostat_with_fan_modes_with_auto(
)
await hass.async_block_till_done()
assert acc.char_active.value == 1
assert acc.char_fan_active.value == 1
char_target_fan_state_iid = acc.char_target_fan_state.to_HAP()[HAP_REPR_IID]
@@ -2434,7 +2436,7 @@ async def test_thermostat_with_fan_modes_with_off(
assert CHAR_TARGET_FAN_STATE not in acc.fan_chars
assert CHAR_SWING_MODE in acc.fan_chars
assert CHAR_CURRENT_FAN_STATE in acc.fan_chars
assert acc.char_active.value == 1
assert acc.char_fan_active.value == 1
hass.states.async_set(
entity_id,
@@ -2460,16 +2462,16 @@ async def test_thermostat_with_fan_modes_with_off(
},
)
await hass.async_block_till_done()
assert acc.char_active.value == 0
assert acc.char_fan_active.value == 0
call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE)
char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID]
char_fan_active_iid = acc.char_fan_active.to_HAP()[HAP_REPR_IID]
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_active_iid,
HAP_REPR_IID: char_fan_active_iid,
HAP_REPR_VALUE: 1,
}
]
@@ -2487,7 +2489,7 @@ async def test_thermostat_with_fan_modes_with_off(
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_active_iid,
HAP_REPR_IID: char_fan_active_iid,
HAP_REPR_VALUE: 0,
}
]