Files
core/homeassistant/components/homekit/climate_base.py
T
2026-07-10 08:31:22 -10:00

384 lines
14 KiB
Python

"""Base class shared by the climate accessory types."""
from collections.abc import Mapping
import logging
from typing import Any
from pyhap.characteristic import Characteristic
from pyhap.const import CATEGORY_THERMOSTAT
from pyhap.service import Service
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,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
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,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import State, callback
from homeassistant.util.percentage import percentage_to_ordered_list_item
from .accessories import HomeAccessory
from .climate_util import (
fan_mode_to_speed,
fan_speed_to_mode,
get_fan_modes_and_speeds,
get_swing_off_mode,
get_swing_on_mode,
get_temperature_range_from_state,
has_swing_off_mode,
is_swing_on,
resolve_target_temp_range,
temperature_attribute_to_homekit,
)
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
# States in which a climate entity is inactive rather than idle
CLIMATE_INACTIVE_STATES = frozenset({HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN})
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."""
# Configured by subclasses only when the entity exposes the mode.
char_speed: Characteristic
char_swing: Characteristic
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)
self._unit = self.hass.config.units.temperature_unit
state = self.hass.states.get(self.entity_id)
assert state
attributes = state.attributes
features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# ``fan_modes`` maps lowercased names to their original casing;
# ``ordered_fan_speeds`` holds the predefined speeds in HomeKit order.
self.fan_modes: dict[str, str] = {}
self.ordered_fan_speeds: list[str] = []
if features & ClimateEntityFeature.FAN_MODE:
self.fan_modes, self.ordered_fan_speeds = get_fan_modes_and_speeds(
attributes
)
self.swing_on_mode: str | None = None
self.swing_off_mode: str = SWING_OFF
# The binary swing toggle writes the off mode back, so it is only
# usable when the entity advertises one.
if features & ClimateEntityFeature.SWING_MODE and has_swing_off_mode(
attributes
):
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(
(
ATTR_MIN_TEMP,
ATTR_MAX_TEMP,
ATTR_FAN_MODES,
ATTR_SWING_MODES,
ATTR_HVAC_MODES,
)
)
def get_temperature_range(self, state: State) -> tuple[float, float]:
"""Return the min and max temperature range."""
return get_temperature_range_from_state(
state, self._unit, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP
)
def _configure_current_temperature_char(self, serv: Service) -> None:
"""Configure the shared current temperature characteristic."""
self.char_current_temp = serv.configure_char(
CHAR_CURRENT_TEMPERATURE, value=21.0
)
def _configure_target_mode_char(
self,
serv: Service,
char_name: str,
value: int,
valid_values: dict[HVACMode, int],
) -> Characteristic:
"""Configure a target mode characteristic scoped to the supported modes.
The value must be set before ``valid_values`` because pyhap applies the
valid values first and would reject a default outside that set.
"""
char = serv.configure_char(char_name, value=value)
char.override_properties(valid_values=valid_values)
char.allow_invalid_client_values = True
return char
def _reject_char_write(self, char: Characteristic, value: Any) -> None:
"""Flip a characteristic back after rejecting a client write."""
char.value = value
char.notify()
def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None:
"""Send a climate write from a characteristic setter.
Subclasses can override this to serialize their writes.
"""
self.async_call_service(
CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params}
)
def _update_temperature_char(
self, char: Characteristic, state: State, attr: str
) -> None:
"""Set a temperature characteristic from a state attribute, if present."""
if (
value := temperature_attribute_to_homekit(state, attr, self._unit)
) is not None:
char.set_value(value)
def _update_current_temperature_char(self, state: State) -> None:
"""Update the current temperature characteristic from the entity state."""
self._update_temperature_char(
self.char_current_temp, state, ATTR_CURRENT_TEMPERATURE
)
def _dual_setpoint_params(
self,
cool_char: Characteristic,
heat_char: Characteristic,
new_high: float | None,
new_low: float | None,
) -> dict[str, float]:
"""Return an ordered high/low target temperature pair for a range write.
Fills the unchanged side from the current characteristic value and
enforces the deadband, so the entity always gets a consistent pair.
"""
high, low = resolve_target_temp_range(
cool_char.value,
heat_char.value,
new_high,
new_low,
cool_char.properties[PROP_MIN_VALUE],
cool_char.properties[PROP_MAX_VALUE],
)
return {
ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high),
ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low),
}
def _temperature_to_homekit(self, temp: float) -> float:
"""Convert a temperature in the entity's unit to the HomeKit unit."""
return temperature_to_homekit(temp, self._unit)
def _temperature_to_states(self, temp: float) -> float:
"""Convert a temperature in the HomeKit unit to the entity's unit."""
return temperature_to_states(temp, self._unit)
def _fan_speed_params(self, speed: int) -> dict[str, Any] | None:
"""Return the set_fan_mode data for a HomeKit rotation speed."""
_LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed)
if not self.ordered_fan_speeds or not 0 < speed <= 100:
return None
mode = fan_speed_to_mode(self.ordered_fan_speeds, self.fan_modes, speed)
return {ATTR_FAN_MODE: mode}
def _set_fan_speed(self, speed: int) -> None:
"""Send the climate fan mode for a HomeKit rotation speed."""
if (params := self._fan_speed_params(speed)) is not None:
self._dispatch_climate_write(SERVICE_SET_FAN_MODE, params)
def _swing_mode_params(self, swing_on: int) -> dict[str, Any] | None:
"""Return the set_swing_mode data for a HomeKit swing toggle."""
if self.swing_on_mode is None:
return None
_LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on)
return {
ATTR_SWING_MODE: self.swing_on_mode if swing_on else self.swing_off_mode
}
def _set_swing_mode(self, swing_on: int) -> None:
"""Send the climate swing mode for a HomeKit swing toggle."""
if (params := self._swing_mode_params(swing_on)) is not None:
self._dispatch_climate_write(SERVICE_SET_SWING_MODE, params)
def _update_fan_speed_char(self, attributes: Mapping[str, Any]) -> None:
"""Update the rotation speed characteristic from the current fan mode."""
# Modes with no predefined speed (e.g. fan auto) keep the last value;
# HomeKit's slider has no position to represent them.
if (
self.ordered_fan_speeds
and (
speed := fan_mode_to_speed(
self.ordered_fan_speeds, attributes.get(ATTR_FAN_MODE)
)
)
is not None
):
self.char_speed.set_value(speed)
def _update_swing_char(self, attributes: Mapping[str, Any]) -> None:
"""Update the swing characteristic from the current swing mode."""
# An absent swing mode keeps the last value; there is nothing to show.
if self.swing_on_mode is not None and (
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._reject_char_write(self.char_fan_active, 1)
return
mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF]
self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode})
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()
self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode})
@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 not in CLIMATE_INACTIVE_STATES
and fan_mode_lower != FAN_OFF
)
)