1 """Class to hold all thermostat accessories."""
6 from pyhap.const
import CATEGORY_THERMOSTAT
10 ATTR_CURRENT_TEMPERATURE,
22 ATTR_TARGET_TEMP_HIGH,
27 DOMAIN
as DOMAIN_CLIMATE,
37 SERVICE_SET_HVAC_MODE
as SERVICE_SET_HVAC_MODE_THERMOSTAT,
38 SERVICE_SET_SWING_MODE,
39 SERVICE_SET_TEMPERATURE
as SERVICE_SET_TEMPERATURE_THERMOSTAT,
50 DOMAIN
as DOMAIN_WATER_HEATER,
51 SERVICE_SET_TEMPERATURE
as SERVICE_SET_TEMPERATURE_WATER_HEATER,
55 ATTR_SUPPORTED_FEATURES,
65 ordered_list_item_to_percentage,
66 percentage_to_ordered_list_item,
69 from .accessories
import TYPES, HomeAccessory
72 CHAR_COOLING_THRESHOLD_TEMPERATURE,
73 CHAR_CURRENT_FAN_STATE,
74 CHAR_CURRENT_HEATING_COOLING,
75 CHAR_CURRENT_HUMIDITY,
76 CHAR_CURRENT_TEMPERATURE,
77 CHAR_HEATING_THRESHOLD_TEMPERATURE,
80 CHAR_TARGET_FAN_STATE,
81 CHAR_TARGET_HEATING_COOLING,
83 CHAR_TARGET_TEMPERATURE,
84 CHAR_TEMP_DISPLAY_UNITS,
85 DEFAULT_MAX_TEMP_WATER_HEATER,
86 DEFAULT_MIN_TEMP_WATER_HEATER,
93 from .util
import temperature_to_homekit, temperature_to_states
95 _LOGGER = logging.getLogger(__name__)
97 DEFAULT_HVAC_MODES = [
104 HC_HOMEKIT_VALID_MODES_WATER_HEATER = {
"Heat": 1}
105 UNIT_HASS_TO_HOMEKIT = {UnitOfTemperature.CELSIUS: 0, UnitOfTemperature.FAHRENHEIT: 1}
108 HC_HEAT_COOL_HEAT = 1
109 HC_HEAT_COOL_COOL = 2
110 HC_HEAT_COOL_AUTO = 3
112 HC_HEAT_COOL_PREFER_HEAT = [
119 HC_HEAT_COOL_PREFER_COOL = [
126 ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH]
127 PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS)
128 SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL]
129 PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER)
134 UNIT_HOMEKIT_TO_HASS = {c: s
for s, c
in UNIT_HASS_TO_HOMEKIT.items()}
135 HC_HASS_TO_HOMEKIT = {
136 HVACMode.OFF: HC_HEAT_COOL_OFF,
137 HVACMode.HEAT: HC_HEAT_COOL_HEAT,
138 HVACMode.COOL: HC_HEAT_COOL_COOL,
139 HVACMode.AUTO: HC_HEAT_COOL_AUTO,
140 HVACMode.HEAT_COOL: HC_HEAT_COOL_AUTO,
141 HVACMode.DRY: HC_HEAT_COOL_COOL,
142 HVACMode.FAN_ONLY: HC_HEAT_COOL_COOL,
144 HC_HOMEKIT_TO_HASS = {c: s
for s, c
in HC_HASS_TO_HOMEKIT.items()}
146 HC_HASS_TO_HOMEKIT_ACTION = {
147 HVACAction.OFF: HC_HEAT_COOL_OFF,
148 HVACAction.IDLE: HC_HEAT_COOL_OFF,
149 HVACAction.HEATING: HC_HEAT_COOL_HEAT,
150 HVACAction.COOLING: HC_HEAT_COOL_COOL,
151 HVACAction.DRYING: HC_HEAT_COOL_COOL,
152 HVACAction.FAN: HC_HEAT_COOL_COOL,
153 HVACAction.PREHEATING: HC_HEAT_COOL_HEAT,
154 HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT,
157 FAN_STATE_INACTIVE = 0
161 HC_HASS_TO_HOMEKIT_FAN_STATE = {
162 HVACAction.OFF: FAN_STATE_INACTIVE,
163 HVACAction.IDLE: FAN_STATE_IDLE,
164 HVACAction.HEATING: FAN_STATE_ACTIVE,
165 HVACAction.COOLING: FAN_STATE_ACTIVE,
166 HVACAction.DRYING: FAN_STATE_ACTIVE,
167 HVACAction.FAN: FAN_STATE_ACTIVE,
170 HEAT_COOL_DEADBAND = 5
174 """Return the equivalent HomeKit HVAC mode for a given state."""
175 if (current_state := state.state)
in (STATE_UNKNOWN, STATE_UNAVAILABLE):
177 if not (hvac_mode := try_parse_enum(HVACMode, current_state)):
179 "%s: Received invalid HVAC mode: %s", state.entity_id, state.state
182 return HC_HASS_TO_HOMEKIT.get(hvac_mode)
185 @TYPES.register("Thermostat")
187 """Generate a Thermostat accessory for a climate."""
190 """Initialize a Thermostat accessory object."""
191 super().
__init__(*args, category=CATEGORY_THERMOSTAT)
192 self.
_unit_unit = self.
hasshass.config.units.temperature_unit
207 self.chars: list[str] = []
208 self.fan_chars: list[str] = []
210 attributes = state.attributes
211 min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY)
212 features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
214 if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
216 (CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE)
220 ATTR_CURRENT_HUMIDITY
in attributes
221 or features & ClimateEntityFeature.TARGET_HUMIDITY
223 self.chars.append(CHAR_CURRENT_HUMIDITY)
225 if features & ClimateEntityFeature.TARGET_HUMIDITY:
226 self.chars.append(CHAR_TARGET_HUMIDITY)
228 serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars)
229 self.set_primary_service(serv_thermostat)
233 CHAR_CURRENT_HEATING_COOLING, value=0
251 CHAR_CURRENT_TEMPERATURE, value=21.0
255 CHAR_TARGET_TEMPERATURE,
260 properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp},
265 CHAR_TEMP_DISPLAY_UNITS, value=0
271 if CHAR_COOLING_THRESHOLD_TEMPERATURE
in self.chars:
273 CHAR_COOLING_THRESHOLD_TEMPERATURE,
278 properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp},
280 if CHAR_HEATING_THRESHOLD_TEMPERATURE
in self.chars:
282 CHAR_HEATING_THRESHOLD_TEMPERATURE,
287 properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp},
290 if CHAR_TARGET_HUMIDITY
in self.chars:
292 CHAR_TARGET_HUMIDITY,
299 properties={PROP_MIN_VALUE: min_humidity},
302 if CHAR_CURRENT_HUMIDITY
in self.chars:
304 CHAR_CURRENT_HUMIDITY, value=50
307 fan_modes: dict[str, str] = {}
310 if features & ClimateEntityFeature.FAN_MODE:
312 fan_mode.lower(): fan_mode
313 for fan_mode
in attributes.get(ATTR_FAN_MODES)
or []
315 if fan_modes
and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
317 speed
for speed
in ORDERED_FAN_SPEEDS
if speed
in fan_modes
319 self.fan_chars.append(CHAR_ROTATION_SPEED)
321 if FAN_AUTO
in fan_modes
and (FAN_ON
in fan_modes
or self.
ordered_fan_speedsordered_fan_speeds):
322 self.fan_chars.append(CHAR_TARGET_FAN_STATE)
326 features & ClimateEntityFeature.SWING_MODE
327 and (swing_modes := attributes.get(ATTR_SWING_MODES))
328 and PRE_DEFINED_SWING_MODES.intersection(swing_modes)
333 for swing_mode
in SWING_MODE_PREFERRED_ORDER
334 if swing_mode
in swing_modes
337 self.fan_chars.append(CHAR_SWING_MODE)
340 if attributes.get(ATTR_HVAC_ACTION)
is not None:
341 self.fan_chars.append(CHAR_CURRENT_FAN_STATE)
342 serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars)
343 serv_thermostat.add_linked_service(serv_fan)
345 CHAR_ACTIVE, value=1, setter_callback=self.
_set_fan_active_set_fan_active
347 if CHAR_SWING_MODE
in self.fan_chars:
353 self.
char_swingchar_swing.display_name =
"Swing Mode"
354 if CHAR_ROTATION_SPEED
in self.fan_chars:
361 self.
char_speedchar_speed.display_name =
"Fan Mode"
362 if CHAR_CURRENT_FAN_STATE
in self.fan_chars:
364 CHAR_CURRENT_FAN_STATE,
368 if CHAR_TARGET_FAN_STATE
in self.fan_chars
and FAN_AUTO
in self.
fan_modesfan_modes:
370 CHAR_TARGET_FAN_STATE,
378 serv_thermostat.setter_callback = self.
_set_chars_set_chars
381 _LOGGER.debug(
"%s: Set swing mode to %s", self.
entity_identity_id, swing_on)
382 mode = self.
swing_on_modeswing_on_mode
if swing_on
else SWING_OFF
383 params = {ATTR_ENTITY_ID: self.
entity_identity_id, ATTR_SWING_MODE: mode}
384 self.
async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE, params)
387 _LOGGER.debug(
"%s: Set fan speed to %s", self.
entity_identity_id, speed)
388 mode = percentage_to_ordered_list_item(self.
ordered_fan_speedsordered_fan_speeds, speed - 1)
389 params = {ATTR_ENTITY_ID: self.
entity_identity_id, ATTR_FAN_MODE: mode}
390 self.
async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params)
398 _LOGGER.debug(
"%s: Set fan active to %s", self.
entity_identity_id, active)
399 if FAN_OFF
not in self.
fan_modesfan_modes:
401 "%s: Fan does not support off, resetting to on", self.
entity_identity_id
407 params = {ATTR_ENTITY_ID: self.
entity_identity_id, ATTR_FAN_MODE: mode}
408 self.
async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params)
411 _LOGGER.debug(
"%s: Set fan auto to %s", self.
entity_identity_id, auto)
413 params = {ATTR_ENTITY_ID: self.
entity_identity_id, ATTR_FAN_MODE: mode}
414 self.
async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params)
417 return temperature_to_homekit(temp, self.
_unit_unit)
420 return temperature_to_states(temp, self.
_unit_unit)
423 _LOGGER.debug(
"Thermostat _set_chars: %s", char_values)
425 params: dict[str, Any] = {ATTR_ENTITY_ID: self.
entity_identity_id}
429 features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
434 CHAR_TARGET_HEATING_COOLING
in char_values
435 and char_values[CHAR_TARGET_HEATING_COOLING] != homekit_hvac_mode
437 target_hc = char_values[CHAR_TARGET_HEATING_COOLING]
444 hc_target_temp = char_values.get(CHAR_TARGET_TEMPERATURE)
446 hc_fallback_order = HC_HEAT_COOL_PREFER_HEAT
448 hc_target_temp
is not None
449 and hc_current_temp
is not None
450 and hc_target_temp < hc_current_temp
452 hc_fallback_order = HC_HEAT_COOL_PREFER_COOL
453 for hc_fallback
in hc_fallback_order:
457 "Siri requested target mode: %s and the device does not"
458 " support, falling back to %s"
468 f
"{CHAR_TARGET_HEATING_COOLING} to"
469 f
" {char_values[CHAR_TARGET_HEATING_COOLING]}"
477 SERVICE_SET_HVAC_MODE_THERMOSTAT,
482 if CHAR_TARGET_TEMPERATURE
in char_values:
483 hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE]
484 if features & ClimateEntityFeature.TARGET_TEMPERATURE:
485 service = SERVICE_SET_TEMPERATURE_THERMOSTAT
488 f
"{CHAR_TARGET_TEMPERATURE} to"
489 f
" {char_values[CHAR_TARGET_TEMPERATURE]}°C"
491 params[ATTR_TEMPERATURE] = temperature
492 elif features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
496 "Homekit requested target temp: %s and the device does not support",
500 homekit_hvac_mode == HC_HEAT_COOL_HEAT
501 and CHAR_HEATING_THRESHOLD_TEMPERATURE
not in char_values
503 char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] = hc_target_temp
505 homekit_hvac_mode == HC_HEAT_COOL_COOL
506 and CHAR_COOLING_THRESHOLD_TEMPERATURE
not in char_values
508 char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] = hc_target_temp
511 CHAR_HEATING_THRESHOLD_TEMPERATURE
in char_values
512 or CHAR_COOLING_THRESHOLD_TEMPERATURE
in char_values
516 service = SERVICE_SET_TEMPERATURE_THERMOSTAT
520 if CHAR_COOLING_THRESHOLD_TEMPERATURE
in char_values:
522 f
"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to"
523 f
" {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C"
525 high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]
529 low = high - HEAT_COOL_DEADBAND
530 if CHAR_HEATING_THRESHOLD_TEMPERATURE
in char_values:
532 f
"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to"
533 f
" {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C"
535 low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]
539 high = low + HEAT_COOL_DEADBAND
541 high =
min(high, max_temp)
542 low =
max(low, min_temp)
559 if CHAR_TARGET_HUMIDITY
in char_values:
563 """Configure target mode characteristics."""
565 hc_modes = state.attributes.get(ATTR_HVAC_MODES)
or DEFAULT_HVAC_MODES
576 for s, c
in HC_HASS_TO_HOMEKIT.items()
580 (s == HVACMode.AUTO
and HVACMode.HEAT_COOL
in hc_modes)
582 s
in (HVACMode.DRY, HVACMode.FAN_ONLY)
583 and HVACMode.COOL
in hc_modes
591 """Return min and max temperature range."""
600 """Set target humidity to value if call came from HomeKit."""
601 _LOGGER.debug(
"%s: Set target humidity to %d", self.
entity_identity_id, value)
602 params = {ATTR_ENTITY_ID: self.
entity_identity_id, ATTR_HUMIDITY: value}
604 DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f
"{value}{PERCENTAGE}"
609 """Update state without rechecking the device features."""
610 attributes = new_state.attributes
611 features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
620 "Cannot map hvac target mode: %s to homekit as only %s modes"
628 if hvac_action := attributes.get(ATTR_HVAC_ACTION):
630 HC_HASS_TO_HOMEKIT_ACTION.get(hvac_action, HC_HEAT_COOL_OFF)
635 if current_temp
is not None:
639 if CHAR_CURRENT_HUMIDITY
in self.chars:
641 current_humdity = attributes.get(ATTR_CURRENT_HUMIDITY)
642 if isinstance(current_humdity, (int, float)):
646 if CHAR_TARGET_HUMIDITY
in self.chars:
648 target_humdity = attributes.get(ATTR_HUMIDITY)
649 if isinstance(target_humdity, (int, float)):
654 cooling_thresh = attributes.get(ATTR_TARGET_TEMP_HIGH)
655 if isinstance(cooling_thresh, (int, float)):
661 heating_thresh = attributes.get(ATTR_TARGET_TEMP_LOW)
662 if isinstance(heating_thresh, (int, float)):
670 and features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
675 if hc_hvac_mode == HC_HEAT_COOL_HEAT:
676 temp_low = attributes.get(ATTR_TARGET_TEMP_LOW)
677 if isinstance(temp_low, (int, float)):
679 elif hc_hvac_mode == HC_HEAT_COOL_COOL:
680 temp_high = attributes.get(ATTR_TARGET_TEMP_HIGH)
681 if isinstance(temp_high, (int, float)):
687 if self.
_unit_unit
and self.
_unit_unit
in UNIT_HASS_TO_HOMEKIT:
688 unit = UNIT_HASS_TO_HOMEKIT[self.
_unit_unit]
696 """Update state without rechecking the device features."""
697 attributes = new_state.attributes
699 if CHAR_SWING_MODE
in self.fan_chars
and (
700 swing_mode := attributes.get(ATTR_SWING_MODE)
702 swing = 1
if swing_mode
in PRE_DEFINED_SWING_MODES
else 0
705 fan_mode = attributes.get(ATTR_FAN_MODE)
706 fan_mode_lower = fan_mode.lower()
if isinstance(fan_mode, str)
else None
708 CHAR_ROTATION_SPEED
in self.fan_chars
712 ordered_list_item_to_percentage(self.
ordered_fan_speedsordered_fan_speeds, fan_mode_lower)
715 if CHAR_TARGET_FAN_STATE
in self.fan_chars:
718 if CHAR_CURRENT_FAN_STATE
in self.fan_chars
and (
719 hvac_action := attributes.get(ATTR_HVAC_ACTION)
722 HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action]
726 int(new_state.state != HVACMode.OFF
and fan_mode_lower != FAN_OFF)
730 @TYPES.register("WaterHeater")
732 """Generate a WaterHeater accessory for a water_heater."""
735 """Initialize a WaterHeater accessory object."""
736 super().
__init__(*args, category=CATEGORY_THERMOSTAT)
743 self.
_unit_unit = self.
hasshass.config.units.temperature_unit
748 serv_thermostat = self.add_preload_service(SERV_THERMOSTAT)
751 CHAR_CURRENT_HEATING_COOLING, value=1
754 CHAR_TARGET_HEATING_COOLING,
757 valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER,
761 CHAR_CURRENT_TEMPERATURE, value=50.0
764 CHAR_TARGET_TEMPERATURE,
769 properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp},
774 CHAR_TEMP_DISPLAY_UNITS, value=0
780 """Return min and max temperature range."""
784 DEFAULT_MIN_TEMP_WATER_HEATER,
785 DEFAULT_MAX_TEMP_WATER_HEATER,
789 """Change operation mode to value if call came from HomeKit."""
790 _LOGGER.debug(
"%s: Set heat-cool to %d", self.
entity_identity_id, value)
791 if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT:
795 """Set target temperature to value if call came from HomeKit."""
796 _LOGGER.debug(
"%s: Set target temperature to %.1f°C", self.
entity_identity_id, value)
797 temperature = temperature_to_states(value, self.
_unit_unit)
798 params = {ATTR_ENTITY_ID: self.
entity_identity_id, ATTR_TEMPERATURE: temperature}
801 SERVICE_SET_TEMPERATURE_WATER_HEATER,
803 f
"{temperature}{self._unit}",
808 """Update water_heater state after state change."""
811 if target_temperature
is not None:
815 if current_temperature
is not None:
819 if self.
_unit_unit
and self.
_unit_unit
in UNIT_HASS_TO_HOMEKIT:
820 unit = UNIT_HASS_TO_HOMEKIT[self.
_unit_unit]
829 state: State, unit: str, default_min: float, default_max: float
830 ) -> tuple[float, float]:
831 """Calculate the temperature range from a state."""
832 if min_temp := state.attributes.get(ATTR_MIN_TEMP):
833 min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2
835 min_temp = default_min
837 if max_temp := state.attributes.get(ATTR_MAX_TEMP):
838 max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2
840 max_temp = default_max
845 min_temp =
max(min_temp, 0)
846 max_temp =
max(max_temp, min_temp)
848 return min_temp, max_temp
852 """Calculate the target temperature from a state."""
853 target_temp = state.attributes.get(ATTR_TEMPERATURE)
854 if isinstance(target_temp, (int, float)):
855 return temperature_to_homekit(target_temp, unit)
860 """Calculate the current temperature from a state."""
861 target_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE)
862 if isinstance(target_temp, (int, float)):
863 return temperature_to_homekit(target_temp, unit)
None async_call_service(self, str domain, str service, dict[str, Any]|None service_data, Any|None value=None)
None async_update_state(self, State new_state)
float _temperature_to_homekit(self, float temp)
None _set_chars(self, dict[str, Any] char_values)
None async_update_state(self, State new_state)
float _temperature_to_states(self, float temp)
None _set_fan_auto(self, int auto)
None set_target_humidity(self, float value)
None _set_fan_active(self, int active)
None _configure_hvac_modes(self, State state)
tuple[float, float] get_temperature_range(self, State state)
None _set_fan_swing_mode(self, int swing_on)
None __init__(self, *Any args)
None _set_fan_speed(self, int speed)
None _async_update_fan_state(self, State new_state)
tuple[float, float] get_temperature_range(self, State state)
None __init__(self, *Any args)
None set_target_temperature(self, float value)
None set_heat_cool(self, int value)
None async_update_state(self, State new_state)
float|None _get_current_temperature(State state, str unit)
float|None _get_target_temperature(State state, str unit)
int|None _hk_hvac_mode_from_state(State state)
tuple[float, float] _get_temperature_range_from_state(State state, str unit, float default_min, float default_max)