Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for Ecobee Thermostats."""
2 
3 from __future__ import annotations
4 
5 import collections
6 from typing import Any
7 
8 import voluptuous as vol
9 
11  ATTR_TARGET_TEMP_HIGH,
12  ATTR_TARGET_TEMP_LOW,
13  FAN_AUTO,
14  FAN_ON,
15  PRESET_AWAY,
16  PRESET_HOME,
17  PRESET_NONE,
18  PRESET_SLEEP,
19  ClimateEntity,
20  ClimateEntityFeature,
21  HVACAction,
22  HVACMode,
23 )
24 from homeassistant.config_entries import ConfigEntry
25 from homeassistant.const import (
26  ATTR_ENTITY_ID,
27  ATTR_TEMPERATURE,
28  PRECISION_HALVES,
29  PRECISION_TENTHS,
30  STATE_OFF,
31  STATE_ON,
32  UnitOfTemperature,
33 )
34 from homeassistant.core import HomeAssistant, ServiceCall
35 from homeassistant.exceptions import ServiceValidationError
36 from homeassistant.helpers import device_registry as dr, entity_platform
38 from homeassistant.helpers.device_registry import DeviceInfo
39 from homeassistant.helpers.entity_platform import AddEntitiesCallback
40 from homeassistant.util.unit_conversion import TemperatureConverter
41 
42 from . import EcobeeData
43 from .const import (
44  _LOGGER,
45  ATTR_ACTIVE_SENSORS,
46  ATTR_AVAILABLE_SENSORS,
47  DOMAIN,
48  ECOBEE_AUX_HEAT_ONLY,
49  ECOBEE_MODEL_TO_NAME,
50  MANUFACTURER,
51 )
52 from .util import ecobee_date, ecobee_time, is_indefinite_hold
53 
54 ATTR_COOL_TEMP = "cool_temp"
55 ATTR_END_DATE = "end_date"
56 ATTR_END_TIME = "end_time"
57 ATTR_FAN_MIN_ON_TIME = "fan_min_on_time"
58 ATTR_FAN_MODE = "fan_mode"
59 ATTR_HEAT_TEMP = "heat_temp"
60 ATTR_RESUME_ALL = "resume_all"
61 ATTR_START_DATE = "start_date"
62 ATTR_START_TIME = "start_time"
63 ATTR_VACATION_NAME = "vacation_name"
64 ATTR_DST_ENABLED = "dst_enabled"
65 ATTR_MIC_ENABLED = "mic_enabled"
66 ATTR_AUTO_AWAY = "auto_away"
67 ATTR_FOLLOW_ME = "follow_me"
68 ATTR_SENSOR_LIST = "device_ids"
69 ATTR_PRESET_MODE = "preset_mode"
70 
71 DEFAULT_RESUME_ALL = False
72 PRESET_AWAY_INDEFINITELY = "away_indefinitely"
73 PRESET_TEMPERATURE = "temp"
74 PRESET_VACATION = "vacation"
75 PRESET_HOLD_NEXT_TRANSITION = "next_transition"
76 PRESET_HOLD_INDEFINITE = "indefinite"
77 HAS_HEAT_PUMP = "hasHeatPump"
78 
79 DEFAULT_MIN_HUMIDITY = 15
80 DEFAULT_MAX_HUMIDITY = 50
81 HUMIDIFIER_MANUAL_MODE = "manual"
82 
83 # Order matters, because for reverse mapping we don't want to map HEAT to AUX
84 ECOBEE_HVAC_TO_HASS = collections.OrderedDict(
85  [
86  ("heat", HVACMode.HEAT),
87  ("cool", HVACMode.COOL),
88  ("auto", HVACMode.HEAT_COOL),
89  ("off", HVACMode.OFF),
90  (ECOBEE_AUX_HEAT_ONLY, HVACMode.HEAT),
91  ]
92 )
93 # Reverse key/value pair, drop auxHeatOnly as it doesn't map to specific HASS mode
94 HASS_TO_ECOBEE_HVAC = {
95  v: k for k, v in ECOBEE_HVAC_TO_HASS.items() if k != ECOBEE_AUX_HEAT_ONLY
96 }
97 
98 ECOBEE_HVAC_ACTION_TO_HASS = {
99  # Map to None if we do not know how to represent.
100  "heatPump": HVACAction.HEATING,
101  "heatPump2": HVACAction.HEATING,
102  "heatPump3": HVACAction.HEATING,
103  "compCool1": HVACAction.COOLING,
104  "compCool2": HVACAction.COOLING,
105  "auxHeat1": HVACAction.HEATING,
106  "auxHeat2": HVACAction.HEATING,
107  "auxHeat3": HVACAction.HEATING,
108  "fan": HVACAction.FAN,
109  "humidifier": None,
110  "dehumidifier": HVACAction.DRYING,
111  "ventilator": HVACAction.FAN,
112  "economizer": HVACAction.FAN,
113  "compHotWater": None,
114  "auxHotWater": None,
115  "compWaterHeater": None,
116 }
117 
118 ECOBEE_TO_HASS_PRESET = {
119  "Away": PRESET_AWAY,
120  "Home": PRESET_HOME,
121  "Sleep": PRESET_SLEEP,
122 }
123 HASS_TO_ECOBEE_PRESET = {v: k for k, v in ECOBEE_TO_HASS_PRESET.items()}
124 
125 PRESET_TO_ECOBEE_HOLD = {
126  PRESET_HOLD_NEXT_TRANSITION: "nextTransition",
127  PRESET_HOLD_INDEFINITE: "indefinite",
128 }
129 
130 SERVICE_CREATE_VACATION = "create_vacation"
131 SERVICE_DELETE_VACATION = "delete_vacation"
132 SERVICE_RESUME_PROGRAM = "resume_program"
133 SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time"
134 SERVICE_SET_DST_MODE = "set_dst_mode"
135 SERVICE_SET_MIC_MODE = "set_mic_mode"
136 SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes"
137 SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate"
138 
139 DTGROUP_START_INCLUSIVE_MSG = (
140  f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together"
141 )
142 
143 DTGROUP_END_INCLUSIVE_MSG = (
144  f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together"
145 )
146 
147 CREATE_VACATION_SCHEMA = vol.Schema(
148  {
149  vol.Required(ATTR_ENTITY_ID): cv.entity_id,
150  vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)),
151  vol.Required(ATTR_COOL_TEMP): vol.Coerce(float),
152  vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float),
153  vol.Inclusive(
154  ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG
155  ): ecobee_date,
156  vol.Inclusive(
157  ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG
158  ): ecobee_time,
159  vol.Inclusive(
160  ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG
161  ): ecobee_date,
162  vol.Inclusive(
163  ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG
164  ): ecobee_time,
165  vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"),
166  vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All(
167  int, vol.Range(min=0, max=60)
168  ),
169  }
170 )
171 
172 DELETE_VACATION_SCHEMA = vol.Schema(
173  {
174  vol.Required(ATTR_ENTITY_ID): cv.entity_id,
175  vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)),
176  }
177 )
178 
179 RESUME_PROGRAM_SCHEMA = vol.Schema(
180  {
181  vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
182  vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean,
183  }
184 )
185 
186 SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema(
187  {
188  vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
189  vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int),
190  }
191 )
192 
193 
194 SUPPORT_FLAGS = (
195  ClimateEntityFeature.TARGET_TEMPERATURE
196  | ClimateEntityFeature.PRESET_MODE
197  | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
198  | ClimateEntityFeature.FAN_MODE
199 )
200 
201 
203  hass: HomeAssistant,
204  config_entry: ConfigEntry,
205  async_add_entities: AddEntitiesCallback,
206 ) -> None:
207  """Set up the ecobee thermostat."""
208 
209  data = hass.data[DOMAIN]
210  entities = []
211 
212  for index in range(len(data.ecobee.thermostats)):
213  thermostat = data.ecobee.get_thermostat(index)
214  if thermostat["modelNumber"] not in ECOBEE_MODEL_TO_NAME:
215  _LOGGER.error(
216  (
217  "Model number for ecobee thermostat %s not recognized. "
218  "Please visit this link to open a new issue: "
219  "https://github.com/home-assistant/core/issues "
220  "and include the following information: "
221  "Unrecognized model number: %s"
222  ),
223  thermostat["name"],
224  thermostat["modelNumber"],
225  )
226  entities.append(Thermostat(data, index, thermostat, hass))
227 
228  async_add_entities(entities, True)
229 
230  platform = entity_platform.async_get_current_platform()
231 
232  def create_vacation_service(service: ServiceCall) -> None:
233  """Create a vacation on the target thermostat."""
234  entity_id = service.data[ATTR_ENTITY_ID]
235 
236  for thermostat in entities:
237  if thermostat.entity_id == entity_id:
238  thermostat.create_vacation(service.data)
239  thermostat.schedule_update_ha_state(True)
240  break
241 
242  def delete_vacation_service(service: ServiceCall) -> None:
243  """Delete a vacation on the target thermostat."""
244  entity_id = service.data[ATTR_ENTITY_ID]
245  vacation_name = service.data[ATTR_VACATION_NAME]
246 
247  for thermostat in entities:
248  if thermostat.entity_id == entity_id:
249  thermostat.delete_vacation(vacation_name)
250  thermostat.schedule_update_ha_state(True)
251  break
252 
253  def fan_min_on_time_set_service(service: ServiceCall) -> None:
254  """Set the minimum fan on time on the target thermostats."""
255  entity_id = service.data.get(ATTR_ENTITY_ID)
256  fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME]
257 
258  if entity_id:
259  target_thermostats = [
260  entity for entity in entities if entity.entity_id in entity_id
261  ]
262  else:
263  target_thermostats = entities
264 
265  for thermostat in target_thermostats:
266  thermostat.set_fan_min_on_time(str(fan_min_on_time))
267 
268  thermostat.schedule_update_ha_state(True)
269 
270  def resume_program_set_service(service: ServiceCall) -> None:
271  """Resume the program on the target thermostats."""
272  entity_id = service.data.get(ATTR_ENTITY_ID)
273  resume_all = service.data.get(ATTR_RESUME_ALL)
274 
275  if entity_id:
276  target_thermostats = [
277  entity for entity in entities if entity.entity_id in entity_id
278  ]
279  else:
280  target_thermostats = entities
281 
282  for thermostat in target_thermostats:
283  thermostat.resume_program(resume_all)
284 
285  thermostat.schedule_update_ha_state(True)
286 
287  hass.services.async_register(
288  DOMAIN,
289  SERVICE_CREATE_VACATION,
290  create_vacation_service,
291  schema=CREATE_VACATION_SCHEMA,
292  )
293 
294  hass.services.async_register(
295  DOMAIN,
296  SERVICE_DELETE_VACATION,
297  delete_vacation_service,
298  schema=DELETE_VACATION_SCHEMA,
299  )
300 
301  hass.services.async_register(
302  DOMAIN,
303  SERVICE_SET_FAN_MIN_ON_TIME,
304  fan_min_on_time_set_service,
305  schema=SET_FAN_MIN_ON_TIME_SCHEMA,
306  )
307 
308  hass.services.async_register(
309  DOMAIN,
310  SERVICE_RESUME_PROGRAM,
311  resume_program_set_service,
312  schema=RESUME_PROGRAM_SCHEMA,
313  )
314 
315  platform.async_register_entity_service(
316  SERVICE_SET_DST_MODE,
317  {vol.Required(ATTR_DST_ENABLED): cv.boolean},
318  "set_dst_mode",
319  )
320 
321  platform.async_register_entity_service(
322  SERVICE_SET_MIC_MODE,
323  {vol.Required(ATTR_MIC_ENABLED): cv.boolean},
324  "set_mic_mode",
325  )
326 
327  platform.async_register_entity_service(
328  SERVICE_SET_OCCUPANCY_MODES,
329  {
330  vol.Optional(ATTR_AUTO_AWAY): cv.boolean,
331  vol.Optional(ATTR_FOLLOW_ME): cv.boolean,
332  },
333  "set_occupancy_modes",
334  )
335 
336  platform.async_register_entity_service(
337  SERVICE_SET_SENSORS_USED_IN_CLIMATE,
338  {
339  vol.Optional(ATTR_PRESET_MODE): cv.string,
340  vol.Required(ATTR_SENSOR_LIST): cv.ensure_list,
341  },
342  "set_sensors_used_in_climate",
343  )
344 
345 
347  """A thermostat class for Ecobee."""
348 
349  _attr_precision = PRECISION_TENTHS
350  _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
351  _attr_min_humidity = DEFAULT_MIN_HUMIDITY
352  _attr_max_humidity = DEFAULT_MAX_HUMIDITY
353  _attr_fan_modes = [FAN_AUTO, FAN_ON]
354  _attr_name = None
355  _attr_has_entity_name = True
356  _enable_turn_on_off_backwards_compatibility = False
357  _attr_translation_key = "ecobee"
358 
359  def __init__(
360  self,
361  data: EcobeeData,
362  thermostat_index: int,
363  thermostat: dict,
364  hass: HomeAssistant,
365  ) -> None:
366  """Initialize the thermostat."""
367  self.datadata = data
368  self.thermostat_indexthermostat_index = thermostat_index
369  self.thermostatthermostat = thermostat
370  self._attr_unique_id_attr_unique_id = self.thermostatthermostat["identifier"]
371  self.vacationvacation = None
372  self._last_active_hvac_mode_last_active_hvac_mode = HVACMode.HEAT_COOL
373  self._last_hvac_mode_before_aux_heat_last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL
374  self._hass_hass = hass
375 
376  self._attr_hvac_modes_attr_hvac_modes = []
377  if self.settingssettings["heatStages"] or self.settingssettings["hasHeatPump"]:
378  self._attr_hvac_modes_attr_hvac_modes.append(HVACMode.HEAT)
379  if self.settingssettings["coolStages"]:
380  self._attr_hvac_modes_attr_hvac_modes.append(HVACMode.COOL)
381  if len(self._attr_hvac_modes_attr_hvac_modes) == 2:
382  self._attr_hvac_modes_attr_hvac_modes.insert(0, HVACMode.HEAT_COOL)
383  self._attr_hvac_modes_attr_hvac_modes.append(HVACMode.OFF)
384  self._sensors_sensors = self.remote_sensorsremote_sensors
385  self._preset_modes_preset_modes = {
386  comfort["climateRef"]: comfort["name"]
387  for comfort in self.thermostatthermostat["program"]["climates"]
388  }
389  self.update_without_throttleupdate_without_throttle = False
390 
391  async def async_update(self) -> None:
392  """Get the latest state from the thermostat."""
393  if self.update_without_throttleupdate_without_throttle:
394  await self.datadata.update(no_throttle=True)
395  self.update_without_throttleupdate_without_throttle = False
396  else:
397  await self.datadata.update()
398  self.thermostatthermostat = self.datadata.ecobee.get_thermostat(self.thermostat_indexthermostat_index)
399  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode != HVACMode.OFF:
400  self._last_active_hvac_mode_last_active_hvac_mode = self.hvac_modehvac_modehvac_modehvac_modehvac_mode
401 
402  @property
403  def available(self) -> bool:
404  """Return if device is available."""
405  return self.thermostatthermostat["runtime"]["connected"]
406 
407  @property
408  def supported_features(self) -> ClimateEntityFeature:
409  """Return the list of supported features."""
410  supported = SUPPORT_FLAGS
411  if self.has_humidifier_controlhas_humidifier_control:
412  supported = supported | ClimateEntityFeature.TARGET_HUMIDITY
413  if len(self.hvac_modeshvac_modes) > 1 and HVACMode.OFF in self.hvac_modeshvac_modes:
414  supported = (
415  supported | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
416  )
417  return supported
418 
419  @property
420  def device_info(self) -> DeviceInfo:
421  """Return device information for this ecobee thermostat."""
422  model: str | None
423  try:
424  model = f"{ECOBEE_MODEL_TO_NAME[self.thermostat['modelNumber']]} Thermostat"
425  except KeyError:
426  # Ecobee model is not in our list
427  model = None
428 
429  return DeviceInfo(
430  identifiers={(DOMAIN, self.thermostatthermostat["identifier"])},
431  manufacturer=MANUFACTURER,
432  model=model,
433  name=self.thermostatthermostat["name"],
434  )
435 
436  @property
437  def current_temperature(self) -> float:
438  """Return the current temperature."""
439  return self.thermostatthermostat["runtime"]["actualTemperature"] / 10.0
440 
441  @property
442  def target_temperature_low(self) -> float | None:
443  """Return the lower bound temperature we try to reach."""
444  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.HEAT_COOL:
445  return self.thermostatthermostat["runtime"]["desiredHeat"] / 10.0
446  return None
447 
448  @property
449  def target_temperature_high(self) -> float | None:
450  """Return the upper bound temperature we try to reach."""
451  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.HEAT_COOL:
452  return self.thermostatthermostat["runtime"]["desiredCool"] / 10.0
453  return None
454 
455  @property
456  def target_temperature_step(self) -> float:
457  """Set target temperature step to halves."""
458  return PRECISION_HALVES
459 
460  @property
461  def settings(self) -> dict[str, Any]:
462  """Return the settings of the thermostat."""
463  return self.thermostatthermostat["settings"]
464 
465  @property
466  def has_humidifier_control(self) -> bool:
467  """Return true if humidifier connected to thermostat and set to manual/on mode."""
468  return (
469  bool(self.settingssettings.get("hasHumidifier"))
470  and self.settingssettings.get("humidifierMode") == HUMIDIFIER_MANUAL_MODE
471  )
472 
473  @property
474  def target_humidity(self) -> int | None:
475  """Return the desired humidity set point."""
476  if self.has_humidifier_controlhas_humidifier_control:
477  return self.thermostatthermostat["runtime"]["desiredHumidity"]
478  return None
479 
480  @property
481  def target_temperature(self) -> float | None:
482  """Return the temperature we try to reach."""
483  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.HEAT_COOL:
484  return None
485  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.HEAT:
486  return self.thermostatthermostat["runtime"]["desiredHeat"] / 10.0
487  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.COOL:
488  return self.thermostatthermostat["runtime"]["desiredCool"] / 10.0
489  return None
490 
491  @property
492  def fan(self):
493  """Return the current fan status."""
494  if "fan" in self.thermostatthermostat["equipmentStatus"]:
495  return STATE_ON
496  return STATE_OFF
497 
498  @property
499  def fan_mode(self):
500  """Return the fan setting."""
501  return self.thermostatthermostat["runtime"]["desiredFanMode"]
502 
503  @property
504  def preset_mode(self) -> str | None:
505  """Return current preset mode."""
506  events = self.thermostatthermostat["events"]
507  for event in events:
508  if not event["running"]:
509  continue
510 
511  if event["type"] == "hold":
512  if event["holdClimateRef"] == "away" and is_indefinite_hold(
513  event["startDate"], event["endDate"]
514  ):
515  return PRESET_AWAY_INDEFINITELY
516 
517  if name := self.comfort_settingscomfort_settings.get(event["holdClimateRef"]):
518  return ECOBEE_TO_HASS_PRESET.get(name, name)
519 
520  # Any hold not based on a climate is a temp hold
521  return PRESET_TEMPERATURE
522  if event["type"].startswith("auto"):
523  # All auto modes are treated as holds
524  return event["type"][4:].lower()
525  if event["type"] == "vacation":
526  self.vacationvacation = event["name"]
527  return PRESET_VACATION
528 
529  if name := self.comfort_settingscomfort_settings.get(
530  self.thermostatthermostat["program"]["currentClimateRef"]
531  ):
532  return ECOBEE_TO_HASS_PRESET.get(name, name)
533 
534  return None
535 
536  @property
537  def hvac_mode(self):
538  """Return current operation."""
539  return ECOBEE_HVAC_TO_HASS[self.settingssettings["hvacMode"]]
540 
541  @property
542  def current_humidity(self) -> int | None:
543  """Return the current humidity."""
544  try:
545  return int(self.thermostatthermostat["runtime"]["actualHumidity"])
546  except KeyError:
547  return None
548 
549  @property
550  def hvac_action(self):
551  """Return current HVAC action.
552 
553  Ecobee returns a CSV string with different equipment that is active.
554  We are prioritizing any heating/cooling equipment, otherwise look at
555  drying/fanning. Idle if nothing going on.
556 
557  We are unable to map all actions to HA equivalents.
558  """
559  if self.thermostatthermostat["equipmentStatus"] == "":
560  return HVACAction.IDLE
561 
562  actions = [
563  ECOBEE_HVAC_ACTION_TO_HASS[status]
564  for status in self.thermostatthermostat["equipmentStatus"].split(",")
565  if ECOBEE_HVAC_ACTION_TO_HASS[status] is not None
566  ]
567 
568  for action in (
569  HVACAction.HEATING,
570  HVACAction.COOLING,
571  HVACAction.DRYING,
572  HVACAction.FAN,
573  ):
574  if action in actions:
575  return action
576 
577  return HVACAction.IDLE
578 
579  _unrecorded_attributes = frozenset({ATTR_AVAILABLE_SENSORS, ATTR_ACTIVE_SENSORS})
580 
581  @property
582  def extra_state_attributes(self) -> dict[str, Any] | None:
583  """Return device specific state attributes."""
584  status = self.thermostatthermostat["equipmentStatus"]
585  return {
586  "fan": self.fanfan,
587  "climate_mode": self.comfort_settingscomfort_settings.get(
588  self.thermostatthermostat["program"]["currentClimateRef"]
589  ),
590  "equipment_running": status,
591  "fan_min_on_time": self.settingssettings["fanMinOnTime"],
592  ATTR_AVAILABLE_SENSORS: self.remote_sensor_devicesremote_sensor_devices,
593  ATTR_ACTIVE_SENSORS: self.active_sensor_devices_in_preset_modeactive_sensor_devices_in_preset_mode,
594  }
595 
596  @property
597  def remote_sensors(self) -> list:
598  """Return the remote sensor names of the thermostat."""
599  sensors_info = self.thermostatthermostat.get("remoteSensors", [])
600  return [sensor["name"] for sensor in sensors_info if sensor.get("name")]
601 
602  @property
603  def remote_sensor_devices(self) -> list:
604  """Return the remote sensor device name_by_user or name for the thermostat."""
605  return sorted(
606  [
607  f'{item["name_by_user"]} ({item["id"]})'
608  for item in self.remote_sensor_ids_namesremote_sensor_ids_names
609  ]
610  )
611 
612  @property
613  def remote_sensor_ids_names(self) -> list:
614  """Return the remote sensor device id and name_by_user for the thermostat."""
615  sensors_info = self.thermostatthermostat.get("remoteSensors", [])
616  device_registry = dr.async_get(self._hass_hass)
617 
618  return [
619  {
620  "id": device.id,
621  "name_by_user": device.name_by_user
622  if device.name_by_user
623  else device.name,
624  }
625  for device in device_registry.devices.values()
626  for sensor_info in sensors_info
627  if device.name == sensor_info["name"]
628  ]
629 
630  @property
631  def active_sensors_in_preset_mode(self) -> list:
632  """Return the currently active/participating sensors."""
633  # https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
634  # During a manual hold, the ecobee will follow the Sensor Participation
635  # rules for the Home Comfort Settings
636  mode = self._preset_modes_preset_modes.get(self.preset_modepreset_modepreset_modepreset_mode, "Home")
637  return self._sensors_in_preset_mode_sensors_in_preset_mode(mode)
638 
639  @property
641  """Return the currently active/participating sensor devices."""
642  # https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
643  # During a manual hold, the ecobee will follow the Sensor Participation
644  # rules for the Home Comfort Settings
645  mode = self._preset_modes_preset_modes.get(self.preset_modepreset_modepreset_modepreset_mode, "Home")
646  return self._sensor_devices_in_preset_mode_sensor_devices_in_preset_mode(mode)
647 
648  def set_preset_mode(self, preset_mode: str) -> None:
649  """Activate a preset."""
650  preset_mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode)
651 
652  if preset_mode == self.preset_modepreset_modepreset_modepreset_mode:
653  return
654 
655  self.update_without_throttleupdate_without_throttle = True
656 
657  # If we are currently in vacation mode, cancel it.
658  if self.preset_modepreset_modepreset_modepreset_mode == PRESET_VACATION:
659  self.datadata.ecobee.delete_vacation(self.thermostat_indexthermostat_index, self.vacationvacation)
660 
661  if preset_mode == PRESET_AWAY_INDEFINITELY:
662  self.datadata.ecobee.set_climate_hold(
663  self.thermostat_indexthermostat_index, "away", "indefinite", self.hold_hourshold_hours()
664  )
665 
666  elif preset_mode == PRESET_TEMPERATURE:
667  self.set_temp_holdset_temp_hold(self.current_temperaturecurrent_temperaturecurrent_temperature)
668 
669  elif preset_mode in (PRESET_HOLD_NEXT_TRANSITION, PRESET_HOLD_INDEFINITE):
670  self.datadata.ecobee.set_climate_hold(
671  self.thermostat_indexthermostat_index,
672  PRESET_TO_ECOBEE_HOLD[preset_mode],
673  self.hold_preferencehold_preference(),
674  self.hold_hourshold_hours(),
675  )
676 
677  elif preset_mode == PRESET_NONE:
678  self.datadata.ecobee.resume_program(self.thermostat_indexthermostat_index)
679 
680  else:
681  for climate_ref, name in self.comfort_settingscomfort_settings.items():
682  if name == preset_mode:
683  preset_mode = climate_ref
684  break
685  else:
686  _LOGGER.warning("Received unknown preset mode: %s", preset_mode)
687 
688  self.datadata.ecobee.set_climate_hold(
689  self.thermostat_indexthermostat_index,
690  preset_mode,
691  self.hold_preferencehold_preference(),
692  self.hold_hourshold_hours(),
693  )
694 
695  @property
696  def preset_modes(self) -> list[str] | None:
697  """Return available preset modes."""
698  # Return presets provided by the ecobee API, and an indefinite away
699  # preset which we handle separately in set_preset_mode().
700  return [
701  ECOBEE_TO_HASS_PRESET.get(name, name)
702  for name in self.comfort_settingscomfort_settings.values()
703  ] + [PRESET_AWAY_INDEFINITELY]
704 
705  @property
706  def comfort_settings(self) -> dict[str, str]:
707  """Return ecobee API comfort settings."""
708  return {
709  comfort["climateRef"]: comfort["name"]
710  for comfort in self.thermostatthermostat["program"]["climates"]
711  }
712 
713  def set_auto_temp_hold(self, heat_temp, cool_temp):
714  """Set temperature hold in auto mode."""
715  if cool_temp is not None:
716  cool_temp_setpoint = cool_temp
717  else:
718  cool_temp_setpoint = self.thermostatthermostat["runtime"]["desiredCool"] / 10.0
719 
720  if heat_temp is not None:
721  heat_temp_setpoint = heat_temp
722  else:
723  heat_temp_setpoint = self.thermostatthermostat["runtime"]["desiredCool"] / 10.0
724 
725  self.datadata.ecobee.set_hold_temp(
726  self.thermostat_indexthermostat_index,
727  cool_temp_setpoint,
728  heat_temp_setpoint,
729  self.hold_preferencehold_preference(),
730  self.hold_hourshold_hours(),
731  )
732  _LOGGER.debug(
733  "Setting ecobee hold_temp to: heat=%s, is=%s, cool=%s, is=%s",
734  heat_temp,
735  isinstance(heat_temp, (int, float)),
736  cool_temp,
737  isinstance(cool_temp, (int, float)),
738  )
739 
740  self.update_without_throttleupdate_without_throttle = True
741 
742  def set_fan_mode(self, fan_mode: str) -> None:
743  """Set the fan mode. Valid values are "on" or "auto"."""
744  if fan_mode.lower() not in (FAN_ON, FAN_AUTO):
745  error = "Invalid fan_mode value: Valid values are 'on' or 'auto'"
746  _LOGGER.error(error)
747  return
748 
749  self.datadata.ecobee.set_fan_mode(
750  self.thermostat_indexthermostat_index,
751  fan_mode,
752  self.hold_preferencehold_preference(),
753  holdHours=self.hold_hourshold_hours(),
754  )
755 
756  _LOGGER.debug("Setting fan mode to: %s", fan_mode)
757 
758  def set_temp_hold(self, temp):
759  """Set temperature hold in modes other than auto.
760 
761  Ecobee API: It is good practice to set the heat and cool hold
762  temperatures to be the same, if the thermostat is in either heat, cool,
763  auxHeatOnly, or off mode. If the thermostat is in auto mode, an
764  additional rule is required. The cool hold temperature must be greater
765  than the heat hold temperature by at least the amount in the
766  heatCoolMinDelta property.
767  https://www.ecobee.com/home/developer/api/examples/ex5.shtml
768  """
769  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode in (HVACMode.HEAT, HVACMode.COOL):
770  heat_temp = temp
771  cool_temp = temp
772  else:
773  delta = self.settingssettings["heatCoolMinDelta"] / 10.0
774  heat_temp = temp - delta
775  cool_temp = temp + delta
776  self.set_auto_temp_holdset_auto_temp_hold(heat_temp, cool_temp)
777 
778  def set_temperature(self, **kwargs: Any) -> None:
779  """Set new target temperature."""
780  low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
781  high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
782  temp = kwargs.get(ATTR_TEMPERATURE)
783 
784  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.HEAT_COOL and (
785  low_temp is not None or high_temp is not None
786  ):
787  self.set_auto_temp_holdset_auto_temp_hold(low_temp, high_temp)
788  elif temp is not None:
789  self.set_temp_holdset_temp_hold(temp)
790  else:
791  _LOGGER.error("Missing valid arguments for set_temperature in %s", kwargs)
792 
793  def set_humidity(self, humidity: int) -> None:
794  """Set the humidity level."""
795  if not (0 <= humidity <= 100):
796  raise ValueError(
797  f"Invalid set_humidity value (must be in range 0-100): {humidity}"
798  )
799 
800  self.datadata.ecobee.set_humidity(self.thermostat_indexthermostat_index, int(humidity))
801  self.update_without_throttleupdate_without_throttle = True
802 
803  def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
804  """Set HVAC mode (auto, auxHeatOnly, cool, heat, off)."""
805  ecobee_value = HASS_TO_ECOBEE_HVAC.get(hvac_mode)
806  if ecobee_value is None:
807  _LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode)
808  return
809  self.datadata.ecobee.set_hvac_mode(self.thermostat_indexthermostat_index, ecobee_value)
810  self.update_without_throttleupdate_without_throttle = True
811 
812  def set_fan_min_on_time(self, fan_min_on_time):
813  """Set the minimum fan on time."""
814  self.datadata.ecobee.set_fan_min_on_time(self.thermostat_indexthermostat_index, fan_min_on_time)
815  self.update_without_throttleupdate_without_throttle = True
816 
817  def resume_program(self, resume_all):
818  """Resume the thermostat schedule program."""
819  self.datadata.ecobee.resume_program(
820  self.thermostat_indexthermostat_index, "true" if resume_all else "false"
821  )
822  self.update_without_throttleupdate_without_throttle = True
823 
825  self, device_ids: list[str], preset_mode: str | None = None
826  ) -> None:
827  """Set the sensors used on a climate for a thermostat."""
828  if preset_mode is None:
829  preset_mode = self.preset_modepreset_modepreset_modepreset_mode
830 
831  # Check if climate is an available preset option.
832  elif preset_mode not in self._preset_modes_preset_modes.values():
833  if self.preset_modespreset_modespreset_modes:
835  translation_domain=DOMAIN,
836  translation_key="invalid_preset",
837  translation_placeholders={
838  "options": ", ".join(self._preset_modes_preset_modes.values())
839  },
840  )
841 
842  # Get device name from device id.
843  device_registry = dr.async_get(self.hasshass)
844  sensor_names: list[str] = []
845  sensor_ids: list[str] = []
846  for device_id in device_ids:
847  device = device_registry.async_get(device_id)
848  if device and device.name:
849  r_sensors = self.thermostatthermostat.get("remoteSensors", [])
850  ecobee_identifier = next(
851  (
852  identifier
853  for identifier in device.identifiers
854  if identifier[0] == "ecobee"
855  ),
856  None,
857  )
858  if ecobee_identifier:
859  code = ecobee_identifier[1]
860  for r_sensor in r_sensors:
861  if ( # occurs if remote sensor
862  len(code) == 4 and r_sensor.get("code") == code
863  ) or ( # occurs if thermostat
864  len(code) != 4 and r_sensor.get("type") == "thermostat"
865  ):
866  sensor_ids.append(r_sensor.get("id")) # noqa: PERF401
867  sensor_names.append(device.name)
868 
869  # Ensure sensors provided are available for thermostat or not empty.
870  if not set(sensor_names).issubset(set(self._sensors_sensors)) or not sensor_names:
872  translation_domain=DOMAIN,
873  translation_key="invalid_sensor",
874  translation_placeholders={
875  "options": ", ".join(
876  [
877  f'{item["name_by_user"]} ({item["id"]})'
878  for item in self.remote_sensor_ids_namesremote_sensor_ids_names
879  ]
880  )
881  },
882  )
883 
884  # Check that an id was found for each sensor
885  if len(device_ids) != len(sensor_ids):
887  translation_domain=DOMAIN, translation_key="sensor_lookup_failed"
888  )
889 
890  # Check if sensors are currently used on the climate for the thermostat.
891  current_sensors_in_climate = self._sensors_in_preset_mode_sensors_in_preset_mode(preset_mode)
892  if set(sensor_names) == set(current_sensors_in_climate):
893  _LOGGER.debug(
894  "This action would not be an update, current sensors on climate (%s) are: %s",
895  preset_mode,
896  ", ".join(current_sensors_in_climate),
897  )
898  return
899 
900  _LOGGER.debug(
901  "Setting sensors %s to be used on thermostat %s for program %s",
902  sensor_names,
903  self.device_infodevice_infodevice_info.get("name"),
904  preset_mode,
905  )
906  self.datadata.ecobee.update_climate_sensors(
907  self.thermostat_indexthermostat_index, preset_mode, sensor_ids=sensor_ids
908  )
909  self.update_without_throttleupdate_without_throttle = True
910 
911  def _sensors_in_preset_mode(self, preset_mode: str | None) -> list[str]:
912  """Return current sensors used in climate."""
913  climates = self.thermostatthermostat["program"]["climates"]
914  for climate in climates:
915  if climate.get("name") == preset_mode:
916  return [sensor["name"] for sensor in climate["sensors"]]
917 
918  return []
919 
920  def _sensor_devices_in_preset_mode(self, preset_mode: str | None) -> list[str]:
921  """Return current sensor device name_by_user or name used in climate."""
922  device_registry = dr.async_get(self._hass_hass)
923  sensor_names = self._sensors_in_preset_mode_sensors_in_preset_mode(preset_mode)
924  return sorted(
925  [
926  device.name_by_user if device.name_by_user else device.name
927  for device in device_registry.devices.values()
928  for sensor_name in sensor_names
929  if device.name == sensor_name
930  ]
931  )
932 
933  def hold_preference(self):
934  """Return user preference setting for hold time."""
935  # Values returned from thermostat are:
936  # "useEndTime2hour", "useEndTime4hour"
937  # "nextPeriod", "askMe"
938  # "indefinite"
939  device_preference = self.settingssettings["holdAction"]
940  # Currently supported pyecobee holdTypes:
941  # dateTime, nextTransition, indefinite, holdHours
942  hold_pref_map = {
943  "useEndTime2hour": "holdHours",
944  "useEndTime4hour": "holdHours",
945  "indefinite": "indefinite",
946  }
947  return hold_pref_map.get(device_preference, "nextTransition")
948 
949  def hold_hours(self):
950  """Return user preference setting for hold duration in hours."""
951  # Values returned from thermostat are:
952  # "useEndTime2hour", "useEndTime4hour"
953  # "nextPeriod", "askMe"
954  # "indefinite"
955  device_preference = self.settingssettings["holdAction"]
956  hold_hours_map = {
957  "useEndTime2hour": 2,
958  "useEndTime4hour": 4,
959  }
960  return hold_hours_map.get(device_preference)
961 
962  def create_vacation(self, service_data):
963  """Create a vacation with user-specified parameters."""
964  vacation_name = service_data[ATTR_VACATION_NAME]
965  cool_temp = TemperatureConverter.convert(
966  service_data[ATTR_COOL_TEMP],
967  self.hasshass.config.units.temperature_unit,
968  UnitOfTemperature.FAHRENHEIT,
969  )
970  heat_temp = TemperatureConverter.convert(
971  service_data[ATTR_HEAT_TEMP],
972  self.hasshass.config.units.temperature_unit,
973  UnitOfTemperature.FAHRENHEIT,
974  )
975  start_date = service_data.get(ATTR_START_DATE)
976  start_time = service_data.get(ATTR_START_TIME)
977  end_date = service_data.get(ATTR_END_DATE)
978  end_time = service_data.get(ATTR_END_TIME)
979  fan_mode = service_data[ATTR_FAN_MODE]
980  fan_min_on_time = service_data[ATTR_FAN_MIN_ON_TIME]
981 
982  kwargs = {
983  key: value
984  for key, value in {
985  "start_date": start_date,
986  "start_time": start_time,
987  "end_date": end_date,
988  "end_time": end_time,
989  "fan_mode": fan_mode,
990  "fan_min_on_time": fan_min_on_time,
991  }.items()
992  if value is not None
993  }
994 
995  _LOGGER.debug(
996  (
997  "Creating a vacation on thermostat %s with name %s, cool temp %s, heat"
998  " temp %s, and the following other parameters: %s"
999  ),
1000  self.namename,
1001  vacation_name,
1002  cool_temp,
1003  heat_temp,
1004  kwargs,
1005  )
1006  self.datadata.ecobee.create_vacation(
1007  self.thermostat_indexthermostat_index, vacation_name, cool_temp, heat_temp, **kwargs
1008  )
1009 
1010  def delete_vacation(self, vacation_name):
1011  """Delete a vacation with the specified name."""
1012  _LOGGER.debug(
1013  "Deleting a vacation on thermostat %s with name %s",
1014  self.namename,
1015  vacation_name,
1016  )
1017  self.datadata.ecobee.delete_vacation(self.thermostat_indexthermostat_index, vacation_name)
1018 
1019  def turn_on(self) -> None:
1020  """Set the thermostat to the last active HVAC mode."""
1021  _LOGGER.debug(
1022  "Turning on ecobee thermostat %s in %s mode",
1023  self.namename,
1024  self._last_active_hvac_mode_last_active_hvac_mode,
1025  )
1026  self.set_hvac_modeset_hvac_modeset_hvac_mode(self._last_active_hvac_mode_last_active_hvac_mode)
1027 
1028  def set_dst_mode(self, dst_enabled):
1029  """Enable/disable automatic daylight savings time."""
1030  self.datadata.ecobee.set_dst_mode(self.thermostat_indexthermostat_index, dst_enabled)
1031 
1032  def set_mic_mode(self, mic_enabled):
1033  """Enable/disable Alexa mic (only for Ecobee 4)."""
1034  self.datadata.ecobee.set_mic_mode(self.thermostat_indexthermostat_index, mic_enabled)
1035 
1036  def set_occupancy_modes(self, auto_away=None, follow_me=None):
1037  """Enable/disable Smart Home/Away and Follow Me modes."""
1038  self.datadata.ecobee.set_occupancy_modes(
1039  self.thermostat_indexthermostat_index, auto_away, follow_me
1040  )
None set_hvac_mode(self, HVACMode hvac_mode)
Definition: __init__.py:809
None set_preset_mode(self, str preset_mode)
Definition: climate.py:648
def set_auto_temp_hold(self, heat_temp, cool_temp)
Definition: climate.py:713
list[str] _sensor_devices_in_preset_mode(self, str|None preset_mode)
Definition: climate.py:920
None set_sensors_used_in_climate(self, list[str] device_ids, str|None preset_mode=None)
Definition: climate.py:826
def set_occupancy_modes(self, auto_away=None, follow_me=None)
Definition: climate.py:1036
def set_fan_min_on_time(self, fan_min_on_time)
Definition: climate.py:812
None __init__(self, EcobeeData data, int thermostat_index, dict thermostat, HomeAssistant hass)
Definition: climate.py:365
ClimateEntityFeature supported_features(self)
Definition: climate.py:408
list[str] _sensors_in_preset_mode(self, str|None preset_mode)
Definition: climate.py:911
dict[str, Any]|None extra_state_attributes(self)
Definition: climate.py:582
None set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:803
DeviceInfo|None device_info(self)
Definition: entity.py:798
str|UndefinedType|None name(self)
Definition: entity.py:738
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:206
bool is_indefinite_hold(str start_date_string, str end_date_string)
Definition: util.py:28
IssData update(pyiss.ISS iss)
Definition: __init__.py:33