mirror of
https://github.com/home-assistant/core.git
synced 2026-08-14 17:23:33 +01:00
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
384 lines
14 KiB
Python
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
|
|
)
|
|
)
|