Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for Sensibo wifi-enabled home thermostats."""
2 
3 from __future__ import annotations
4 
5 from bisect import bisect_left
6 from typing import TYPE_CHECKING, Any
7 
8 import voluptuous as vol
9 
11  ATTR_FAN_MODE,
12  ATTR_SWING_MODE,
13  ClimateEntity,
14  ClimateEntityFeature,
15  HVACMode,
16 )
17 from homeassistant.const import (
18  ATTR_MODE,
19  ATTR_STATE,
20  ATTR_TEMPERATURE,
21  PRECISION_TENTHS,
22  UnitOfTemperature,
23 )
24 from homeassistant.core import HomeAssistant
25 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
26 from homeassistant.helpers import config_validation as cv, entity_platform
27 from homeassistant.helpers.entity_platform import AddEntitiesCallback
28 from homeassistant.util.unit_conversion import TemperatureConverter
29 
30 from . import SensiboConfigEntry
31 from .const import DOMAIN
32 from .coordinator import SensiboDataUpdateCoordinator
33 from .entity import SensiboDeviceBaseEntity, async_handle_api_call
34 
35 SERVICE_ASSUME_STATE = "assume_state"
36 SERVICE_ENABLE_TIMER = "enable_timer"
37 ATTR_MINUTES = "minutes"
38 SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost"
39 SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost"
40 SERVICE_FULL_STATE = "full_state"
41 SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react"
42 ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold"
43 ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state"
44 ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold"
45 ATTR_LOW_TEMPERATURE_STATE = "low_temperature_state"
46 ATTR_SMART_TYPE = "smart_type"
47 
48 ATTR_AC_INTEGRATION = "ac_integration"
49 ATTR_GEO_INTEGRATION = "geo_integration"
50 ATTR_INDOOR_INTEGRATION = "indoor_integration"
51 ATTR_OUTDOOR_INTEGRATION = "outdoor_integration"
52 ATTR_SENSITIVITY = "sensitivity"
53 ATTR_TARGET_TEMPERATURE = "target_temperature"
54 ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode"
55 ATTR_LIGHT = "light"
56 BOOST_INCLUSIVE = "boost_inclusive"
57 
58 AVAILABLE_FAN_MODES = {
59  "quiet",
60  "low",
61  "medium_low",
62  "medium",
63  "medium_high",
64  "high",
65  "strong",
66  "auto",
67 }
68 AVAILABLE_SWING_MODES = {
69  "stopped",
70  "fixedtop",
71  "fixedmiddletop",
72  "fixedmiddle",
73  "fixedmiddlebottom",
74  "fixedbottom",
75  "rangetop",
76  "rangemiddle",
77  "rangebottom",
78  "rangefull",
79  "horizontal",
80  "both",
81 }
82 
83 PARALLEL_UPDATES = 0
84 
85 FIELD_TO_FLAG = {
86  "fanLevel": ClimateEntityFeature.FAN_MODE,
87  "swing": ClimateEntityFeature.SWING_MODE,
88  "targetTemperature": ClimateEntityFeature.TARGET_TEMPERATURE,
89 }
90 
91 SENSIBO_TO_HA = {
92  "cool": HVACMode.COOL,
93  "heat": HVACMode.HEAT,
94  "fan": HVACMode.FAN_ONLY,
95  "auto": HVACMode.HEAT_COOL,
96  "dry": HVACMode.DRY,
97  "off": HVACMode.OFF,
98 }
99 
100 HA_TO_SENSIBO = {value: key for key, value in SENSIBO_TO_HA.items()}
101 
102 AC_STATE_TO_DATA = {
103  "targetTemperature": "target_temp",
104  "fanLevel": "fan_mode",
105  "on": "device_on",
106  "mode": "hvac_mode",
107  "swing": "swing_mode",
108 }
109 
110 
111 def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int:
112  if target <= valid_targets[0]:
113  return valid_targets[0]
114  if target >= valid_targets[-1]:
115  return valid_targets[-1]
116  return valid_targets[bisect_left(valid_targets, target)]
117 
118 
120  hass: HomeAssistant,
121  entry: SensiboConfigEntry,
122  async_add_entities: AddEntitiesCallback,
123 ) -> None:
124  """Set up the Sensibo climate entry."""
125 
126  coordinator = entry.runtime_data
127 
128  entities = [
129  SensiboClimate(coordinator, device_id)
130  for device_id, device_data in coordinator.data.parsed.items()
131  ]
132 
133  async_add_entities(entities)
134 
135  platform = entity_platform.async_get_current_platform()
136  platform.async_register_entity_service(
137  SERVICE_ASSUME_STATE,
138  {
139  vol.Required(ATTR_STATE): vol.In(["on", "off"]),
140  },
141  "async_assume_state",
142  )
143  platform.async_register_entity_service(
144  SERVICE_ENABLE_TIMER,
145  {
146  vol.Required(ATTR_MINUTES): cv.positive_int,
147  },
148  "async_enable_timer",
149  )
150  platform.async_register_entity_service(
151  SERVICE_ENABLE_PURE_BOOST,
152  {
153  vol.Required(ATTR_AC_INTEGRATION): bool,
154  vol.Required(ATTR_GEO_INTEGRATION): bool,
155  vol.Required(ATTR_INDOOR_INTEGRATION): bool,
156  vol.Required(ATTR_OUTDOOR_INTEGRATION): bool,
157  vol.Required(ATTR_SENSITIVITY): vol.In(["Normal", "Sensitive"]),
158  },
159  "async_enable_pure_boost",
160  )
161  platform.async_register_entity_service(
162  SERVICE_FULL_STATE,
163  {
164  vol.Required(ATTR_MODE): vol.In(
165  ["cool", "heat", "fan", "auto", "dry", "off"]
166  ),
167  vol.Optional(ATTR_TARGET_TEMPERATURE): int,
168  vol.Optional(ATTR_FAN_MODE): str,
169  vol.Optional(ATTR_SWING_MODE): str,
170  vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str,
171  vol.Optional(ATTR_LIGHT): vol.In(["on", "off"]),
172  },
173  "async_full_ac_state",
174  )
175 
176  platform.async_register_entity_service(
177  SERVICE_ENABLE_CLIMATE_REACT,
178  {
179  vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float),
180  vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict,
181  vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float),
182  vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict,
183  vol.Required(ATTR_SMART_TYPE): vol.In(
184  ["temperature", "feelsLike", "humidity"]
185  ),
186  },
187  "async_enable_climate_react",
188  )
189 
190 
192  """Representation of a Sensibo device."""
193 
194  _attr_name = None
195  _attr_precision = PRECISION_TENTHS
196  _attr_translation_key = "climate_device"
197  _enable_turn_on_off_backwards_compatibility = False
198 
199  def __init__(
200  self, coordinator: SensiboDataUpdateCoordinator, device_id: str
201  ) -> None:
202  """Initiate Sensibo Climate."""
203  super().__init__(coordinator, device_id)
204  self._attr_unique_id_attr_unique_id = device_id
205  self._attr_temperature_unit_attr_temperature_unit = (
206  UnitOfTemperature.CELSIUS
207  if self.device_datadevice_data.temp_unit == "C"
208  else UnitOfTemperature.FAHRENHEIT
209  )
210  self._attr_supported_features_attr_supported_features = self.get_featuresget_features()
211 
212  def get_features(self) -> ClimateEntityFeature:
213  """Get supported features."""
214  features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
215  for key in self.device_datadevice_data.full_features:
216  if key in FIELD_TO_FLAG:
217  features |= FIELD_TO_FLAG[key]
218  return features
219 
220  @property
221  def current_humidity(self) -> int | None:
222  """Return the current humidity."""
223  return self.device_datadevice_data.humidity
224 
225  @property
226  def hvac_mode(self) -> HVACMode:
227  """Return hvac operation."""
228  if self.device_datadevice_data.device_on and self.device_datadevice_data.hvac_mode:
229  return SENSIBO_TO_HA[self.device_datadevice_data.hvac_mode]
230  return HVACMode.OFF
231 
232  @property
233  def hvac_modes(self) -> list[HVACMode]:
234  """Return the list of available hvac operation modes."""
235  if TYPE_CHECKING:
236  assert self.device_datadevice_data.hvac_modes
237  hvac_modes = [SENSIBO_TO_HA[mode] for mode in self.device_datadevice_data.hvac_modes]
238  return hvac_modes if hvac_modes else [HVACMode.OFF]
239 
240  @property
241  def current_temperature(self) -> float | None:
242  """Return the current temperature."""
243  if self.device_datadevice_data.temp:
244  return TemperatureConverter.convert(
245  self.device_datadevice_data.temp,
246  UnitOfTemperature.CELSIUS,
247  self.temperature_unittemperature_unittemperature_unit,
248  )
249  return None
250 
251  @property
252  def temperature_unit(self) -> str:
253  """Return temperature unit."""
254  return (
255  UnitOfTemperature.CELSIUS
256  if self.device_datadevice_data.temp_unit == "C"
257  else UnitOfTemperature.FAHRENHEIT
258  )
259 
260  @property
261  def target_temperature(self) -> float | None:
262  """Return the temperature we try to reach."""
263  target_temp: int | None = self.device_datadevice_data.target_temp
264  return target_temp
265 
266  @property
267  def target_temperature_step(self) -> float | None:
268  """Return the supported step of target temperature."""
269  target_temp_step: int = self.device_datadevice_data.temp_step
270  return target_temp_step
271 
272  @property
273  def fan_mode(self) -> str | None:
274  """Return the fan setting."""
275  fan_mode: str | None = self.device_datadevice_data.fan_mode
276  return fan_mode
277 
278  @property
279  def fan_modes(self) -> list[str] | None:
280  """Return the list of available fan modes."""
281  if self.device_datadevice_data.fan_modes:
282  return self.device_datadevice_data.fan_modes
283  return None
284 
285  @property
286  def swing_mode(self) -> str | None:
287  """Return the swing setting."""
288  swing_mode: str | None = self.device_datadevice_data.swing_mode
289  return swing_mode
290 
291  @property
292  def swing_modes(self) -> list[str] | None:
293  """Return the list of available swing modes."""
294  if self.device_datadevice_data.swing_modes:
295  return self.device_datadevice_data.swing_modes
296  return None
297 
298  @property
299  def min_temp(self) -> float:
300  """Return the minimum temperature."""
301  min_temp: int = self.device_datadevice_data.temp_list[0]
302  return min_temp
303 
304  @property
305  def max_temp(self) -> float:
306  """Return the maximum temperature."""
307  max_temp: int = self.device_datadevice_data.temp_list[-1]
308  return max_temp
309 
310  @property
311  def available(self) -> bool:
312  """Return True if entity is available."""
313  return self.device_datadevice_data.available and super().available
314 
315  async def async_set_temperature(self, **kwargs: Any) -> None:
316  """Set new target temperature."""
317  if "targetTemperature" not in self.device_datadevice_data.active_features:
318  raise HomeAssistantError(
319  translation_domain=DOMAIN,
320  translation_key="no_target_temperature_in_features",
321  )
322 
323  if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
325  translation_domain=DOMAIN,
326  translation_key="no_target_temperature",
327  )
328 
329  if temperature == self.target_temperaturetarget_temperaturetarget_temperature:
330  return
331 
332  new_temp = _find_valid_target_temp(temperature, self.device_datadevice_data.temp_list)
333  await self.async_send_api_callasync_send_api_call(
334  key=AC_STATE_TO_DATA["targetTemperature"],
335  value=new_temp,
336  name="targetTemperature",
337  assumed_state=False,
338  )
339 
340  async def async_set_fan_mode(self, fan_mode: str) -> None:
341  """Set new target fan mode."""
342  if "fanLevel" not in self.device_datadevice_data.active_features:
343  raise HomeAssistantError(
344  translation_domain=DOMAIN,
345  translation_key="no_fan_level_in_features",
346  )
347  if fan_mode not in AVAILABLE_FAN_MODES:
348  raise HomeAssistantError(
349  translation_domain=DOMAIN,
350  translation_key="fan_mode_not_supported",
351  translation_placeholders={"fan_mode": fan_mode},
352  )
353 
354  transformation = self.device_datadevice_data.fan_modes_translated
355  await self.async_send_api_callasync_send_api_call(
356  key=AC_STATE_TO_DATA["fanLevel"],
357  value=fan_mode,
358  name="fanLevel",
359  assumed_state=False,
360  transformation=transformation,
361  )
362 
363  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
364  """Set new target operation mode."""
365  if hvac_mode == HVACMode.OFF:
366  await self.async_send_api_callasync_send_api_call(
367  key=AC_STATE_TO_DATA["on"],
368  value=False,
369  name="on",
370  assumed_state=False,
371  )
372  return
373 
374  # Turn on if not currently on.
375  if not self.device_datadevice_data.device_on:
376  await self.async_send_api_callasync_send_api_call(
377  key=AC_STATE_TO_DATA["on"],
378  value=True,
379  name="on",
380  assumed_state=False,
381  )
382 
383  await self.async_send_api_callasync_send_api_call(
384  key=AC_STATE_TO_DATA["mode"],
385  value=HA_TO_SENSIBO[hvac_mode],
386  name="mode",
387  assumed_state=False,
388  )
389 
390  async def async_set_swing_mode(self, swing_mode: str) -> None:
391  """Set new target swing operation."""
392  if "swing" not in self.device_datadevice_data.active_features:
393  raise HomeAssistantError(
394  translation_domain=DOMAIN,
395  translation_key="no_swing_in_features",
396  )
397  if swing_mode not in AVAILABLE_SWING_MODES:
398  raise HomeAssistantError(
399  translation_domain=DOMAIN,
400  translation_key="swing_not_supported",
401  translation_placeholders={"swing_mode": swing_mode},
402  )
403 
404  transformation = self.device_datadevice_data.swing_modes_translated
405  await self.async_send_api_callasync_send_api_call(
406  key=AC_STATE_TO_DATA["swing"],
407  value=swing_mode,
408  name="swing",
409  assumed_state=False,
410  transformation=transformation,
411  )
412 
413  async def async_turn_on(self) -> None:
414  """Turn Sensibo unit on."""
415  await self.async_send_api_callasync_send_api_call(
416  key=AC_STATE_TO_DATA["on"],
417  value=True,
418  name="on",
419  assumed_state=False,
420  )
421 
422  async def async_turn_off(self) -> None:
423  """Turn Sensibo unit on."""
424  await self.async_send_api_callasync_send_api_call(
425  key=AC_STATE_TO_DATA["on"],
426  value=False,
427  name="on",
428  assumed_state=False,
429  )
430 
431  async def async_assume_state(self, state: str) -> None:
432  """Sync state with api."""
433  await self.async_send_api_callasync_send_api_call(
434  key=AC_STATE_TO_DATA["on"],
435  value=state != HVACMode.OFF,
436  name="on",
437  assumed_state=True,
438  )
439 
441  self,
442  mode: str,
443  target_temperature: int | None = None,
444  fan_mode: str | None = None,
445  swing_mode: str | None = None,
446  horizontal_swing_mode: str | None = None,
447  light: str | None = None,
448  ) -> None:
449  """Set full AC state."""
450  new_ac_state = self.device_datadevice_data.ac_states
451  new_ac_state.pop("timestamp")
452  new_ac_state["on"] = False
453  if mode != "off":
454  new_ac_state["on"] = True
455  new_ac_state["mode"] = mode
456  if target_temperature:
457  new_ac_state["targetTemperature"] = target_temperature
458  if fan_mode:
459  new_ac_state["fanLevel"] = fan_mode
460  if swing_mode:
461  new_ac_state["swing"] = swing_mode
462  if horizontal_swing_mode:
463  new_ac_state["horizontalSwing"] = horizontal_swing_mode
464  if light:
465  new_ac_state["light"] = light
466 
467  await self.api_call_custom_service_full_ac_stateapi_call_custom_service_full_ac_state(
468  key="hvac_mode", value=mode, data=new_ac_state
469  )
470 
471  async def async_enable_timer(self, minutes: int) -> None:
472  """Enable the timer."""
473  new_state = bool(self.device_datadevice_data.ac_states["on"] is False)
474  params = {
475  "minutesFromNow": minutes,
476  "acState": {**self.device_datadevice_data.ac_states, "on": new_state},
477  }
478  await self.api_call_custom_service_timerapi_call_custom_service_timer(
479  key="timer_on",
480  value=True,
481  data=params,
482  )
483 
485  self,
486  ac_integration: bool | None = None,
487  geo_integration: bool | None = None,
488  indoor_integration: bool | None = None,
489  outdoor_integration: bool | None = None,
490  sensitivity: str | None = None,
491  ) -> None:
492  """Enable Pure Boost Configuration."""
493 
494  params: dict[str, str | bool] = {
495  "enabled": True,
496  }
497  if sensitivity is not None:
498  params["sensitivity"] = sensitivity[0]
499  if indoor_integration is not None:
500  params["measurementsIntegration"] = indoor_integration
501  if ac_integration is not None:
502  params["acIntegration"] = ac_integration
503  if geo_integration is not None:
504  params["geoIntegration"] = geo_integration
505  if outdoor_integration is not None:
506  params["primeIntegration"] = outdoor_integration
507 
508  await self.api_call_custom_service_pure_boostapi_call_custom_service_pure_boost(
509  key="pure_boost_enabled",
510  value=True,
511  data=params,
512  )
513 
515  self,
516  high_temperature_threshold: float,
517  high_temperature_state: dict[str, Any],
518  low_temperature_threshold: float,
519  low_temperature_state: dict[str, Any],
520  smart_type: str,
521  ) -> None:
522  """Enable Climate React Configuration."""
523  high_temp = high_temperature_threshold
524  low_temp = low_temperature_threshold
525 
526  if high_temperature_state.get("temperatureUnit") == "F":
527  high_temp = TemperatureConverter.convert(
528  high_temperature_threshold,
529  UnitOfTemperature.FAHRENHEIT,
530  UnitOfTemperature.CELSIUS,
531  )
532  low_temp = TemperatureConverter.convert(
533  low_temperature_threshold,
534  UnitOfTemperature.FAHRENHEIT,
535  UnitOfTemperature.CELSIUS,
536  )
537 
538  params: dict[str, str | bool | float | dict] = {
539  "enabled": True,
540  "deviceUid": self._device_id_device_id,
541  "highTemperatureState": high_temperature_state,
542  "highTemperatureThreshold": high_temp,
543  "lowTemperatureState": low_temperature_state,
544  "lowTemperatureThreshold": low_temp,
545  "type": smart_type,
546  }
547 
548  await self.api_call_custom_service_climate_reactapi_call_custom_service_climate_react(
549  key="smart_on",
550  value=True,
551  data=params,
552  )
553 
554  @async_handle_api_call
556  self,
557  key: str,
558  value: Any,
559  name: str,
560  assumed_state: bool = False,
561  transformation: dict | None = None,
562  ) -> bool:
563  """Make service call to api."""
564  if transformation:
565  value = transformation[value]
566  result = await self._client_client.async_set_ac_state_property(
567  self._device_id_device_id,
568  name,
569  value,
570  self.device_datadevice_data.ac_states,
571  assumed_state,
572  )
573  return bool(result.get("result", {}).get("status") == "Success")
574 
575  @async_handle_api_call
577  self,
578  key: str,
579  value: Any,
580  data: dict,
581  ) -> bool:
582  """Make service call to api."""
583  result = await self._client_client.async_set_timer(self._device_id_device_id, data)
584  return bool(result.get("status") == "success")
585 
586  @async_handle_api_call
588  self,
589  key: str,
590  value: Any,
591  data: dict,
592  ) -> bool:
593  """Make service call to api."""
594  result = await self._client_client.async_set_pureboost(self._device_id_device_id, data)
595  return bool(result.get("status") == "success")
596 
597  @async_handle_api_call
599  self,
600  key: str,
601  value: Any,
602  data: dict,
603  ) -> bool:
604  """Make service call to api."""
605  result = await self._client_client.async_set_climate_react(self._device_id_device_id, data)
606  return bool(result.get("status") == "success")
607 
608  @async_handle_api_call
610  self,
611  key: str,
612  value: Any,
613  data: dict,
614  ) -> bool:
615  """Make service call to api."""
616  result = await self._client_client.async_set_ac_states(self._device_id_device_id, data)
617  return bool(result.get("result", {}).get("status") == "Success")
bool api_call_custom_service_full_ac_state(self, str key, Any value, dict data)
Definition: climate.py:614
None async_full_ac_state(self, str mode, int|None target_temperature=None, str|None fan_mode=None, str|None swing_mode=None, str|None horizontal_swing_mode=None, str|None light=None)
Definition: climate.py:448
bool api_call_custom_service_timer(self, str key, Any value, dict data)
Definition: climate.py:581
bool async_send_api_call(self, str key, Any value, str name, bool assumed_state=False, dict|None transformation=None)
Definition: climate.py:562
None async_enable_climate_react(self, float high_temperature_threshold, dict[str, Any] high_temperature_state, float low_temperature_threshold, dict[str, Any] low_temperature_state, str smart_type)
Definition: climate.py:521
None async_enable_pure_boost(self, bool|None ac_integration=None, bool|None geo_integration=None, bool|None indoor_integration=None, bool|None outdoor_integration=None, str|None sensitivity=None)
Definition: climate.py:491
bool api_call_custom_service_climate_react(self, str key, Any value, dict data)
Definition: climate.py:603
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:363
None __init__(self, SensiboDataUpdateCoordinator coordinator, str device_id)
Definition: climate.py:201
bool api_call_custom_service_pure_boost(self, str key, Any value, dict data)
Definition: climate.py:592
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, SensiboConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:123
int _find_valid_target_temp(int target, list[int] valid_targets)
Definition: climate.py:111