Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for Google Nest SDM climate devices."""
2 
3 from __future__ import annotations
4 
5 from typing import Any, cast
6 
7 from google_nest_sdm.device import Device
8 from google_nest_sdm.device_manager import DeviceManager
9 from google_nest_sdm.device_traits import FanTrait, TemperatureTrait
10 from google_nest_sdm.exceptions import ApiException
11 from google_nest_sdm.thermostat_traits import (
12  ThermostatEcoTrait,
13  ThermostatHvacTrait,
14  ThermostatModeTrait,
15  ThermostatTemperatureSetpointTrait,
16 )
17 
19  ATTR_HVAC_MODE,
20  ATTR_TARGET_TEMP_HIGH,
21  ATTR_TARGET_TEMP_LOW,
22  FAN_OFF,
23  FAN_ON,
24  PRESET_ECO,
25  PRESET_NONE,
26  ClimateEntity,
27  ClimateEntityFeature,
28  HVACAction,
29  HVACMode,
30 )
31 from homeassistant.config_entries import ConfigEntry
32 from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
33 from homeassistant.core import HomeAssistant
34 from homeassistant.exceptions import HomeAssistantError
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 
37 from .const import DATA_DEVICE_MANAGER, DOMAIN
38 from .device_info import NestDeviceInfo
39 
40 # Mapping for sdm.devices.traits.ThermostatMode mode field
41 THERMOSTAT_MODE_MAP: dict[str, HVACMode] = {
42  "OFF": HVACMode.OFF,
43  "HEAT": HVACMode.HEAT,
44  "COOL": HVACMode.COOL,
45  "HEATCOOL": HVACMode.HEAT_COOL,
46 }
47 THERMOSTAT_INV_MODE_MAP = {v: k for k, v in THERMOSTAT_MODE_MAP.items()}
48 
49 # Mode for sdm.devices.traits.ThermostatEco
50 THERMOSTAT_ECO_MODE = "MANUAL_ECO"
51 
52 # Mapping for sdm.devices.traits.ThermostatHvac status field
53 THERMOSTAT_HVAC_STATUS_MAP = {
54  "OFF": HVACAction.OFF,
55  "HEATING": HVACAction.HEATING,
56  "COOLING": HVACAction.COOLING,
57 }
58 
59 THERMOSTAT_RANGE_MODES = [HVACMode.HEAT_COOL, HVACMode.AUTO]
60 
61 PRESET_MODE_MAP = {
62  "MANUAL_ECO": PRESET_ECO,
63  "OFF": PRESET_NONE,
64 }
65 PRESET_INV_MODE_MAP = {v: k for k, v in PRESET_MODE_MAP.items()}
66 
67 FAN_MODE_MAP = {
68  "ON": FAN_ON,
69  "OFF": FAN_OFF,
70 }
71 FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()}
72 FAN_INV_MODES = list(FAN_INV_MODE_MAP)
73 
74 MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API
75 MIN_TEMP = 10
76 MAX_TEMP = 32
77 MIN_TEMP_RANGE = 1.66667
78 
79 
81  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
82 ) -> None:
83  """Set up the client entities."""
84 
85  device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
86  DATA_DEVICE_MANAGER
87  ]
88 
90  ThermostatEntity(device)
91  for device in device_manager.devices.values()
92  if ThermostatHvacTrait.NAME in device.traits
93  )
94 
95 
97  """A nest thermostat climate entity."""
98 
99  _attr_min_temp = MIN_TEMP
100  _attr_max_temp = MAX_TEMP
101  _attr_has_entity_name = True
102  _attr_should_poll = False
103  _attr_name = None
104  _enable_turn_on_off_backwards_compatibility = False
105 
106  def __init__(self, device: Device) -> None:
107  """Initialize ThermostatEntity."""
108  self._device_device = device
109  self._device_info_device_info = NestDeviceInfo(device)
110  # The API "name" field is a unique device identifier.
111  self._attr_unique_id_attr_unique_id = device.name
112  self._attr_device_info_attr_device_info = self._device_info_device_info.device_info
113  self._attr_temperature_unit_attr_temperature_unit = UnitOfTemperature.CELSIUS
114  if mode_trait := device.traits.get(ThermostatModeTrait.NAME):
115  self._attr_hvac_modes_attr_hvac_modes = [
116  THERMOSTAT_MODE_MAP[mode]
117  for mode in mode_trait.available_modes
118  if mode in THERMOSTAT_MODE_MAP
119  ]
120  else:
121  self._attr_hvac_modes_attr_hvac_modes = []
122 
123  @property
124  def available(self) -> bool:
125  """Return device availability."""
126  return self._device_info_device_info.available
127 
128  async def async_added_to_hass(self) -> None:
129  """Run when entity is added to register update signal handler."""
130  self._attr_supported_features_attr_supported_features = self._get_supported_features_get_supported_features()
131  self.async_on_removeasync_on_remove(
132  self._device_device.add_update_listener(self.async_write_ha_stateasync_write_ha_state)
133  )
134 
135  @property
136  def current_temperature(self) -> float | None:
137  """Return the current temperature."""
138  if TemperatureTrait.NAME not in self._device_device.traits:
139  return None
140  trait: TemperatureTrait = self._device_device.traits[TemperatureTrait.NAME]
141  return trait.ambient_temperature_celsius
142 
143  @property
144  def target_temperature(self) -> float | None:
145  """Return the temperature currently set to be reached."""
146  if not (trait := self._target_temperature_trait_target_temperature_trait):
147  return None
148  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.HEAT:
149  return trait.heat_celsius
150  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.COOL:
151  return trait.cool_celsius
152  return None
153 
154  @property
155  def target_temperature_high(self) -> float | None:
156  """Return the upper bound target temperature."""
157  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode != HVACMode.HEAT_COOL:
158  return None
159  if not (trait := self._target_temperature_trait_target_temperature_trait):
160  return None
161  return trait.cool_celsius
162 
163  @property
164  def target_temperature_low(self) -> float | None:
165  """Return the lower bound target temperature."""
166  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode != HVACMode.HEAT_COOL:
167  return None
168  if not (trait := self._target_temperature_trait_target_temperature_trait):
169  return None
170  return trait.heat_celsius
171 
172  @property
174  self,
175  ) -> ThermostatEcoTrait | ThermostatTemperatureSetpointTrait | None:
176  """Return the correct trait with a target temp depending on mode."""
177  if (
178  self.preset_modepreset_modepreset_modepreset_mode == PRESET_ECO
179  and ThermostatEcoTrait.NAME in self._device_device.traits
180  ):
181  return cast(
182  ThermostatEcoTrait, self._device_device.traits[ThermostatEcoTrait.NAME]
183  )
184  if ThermostatTemperatureSetpointTrait.NAME in self._device_device.traits:
185  return cast(
186  ThermostatTemperatureSetpointTrait,
187  self._device_device.traits[ThermostatTemperatureSetpointTrait.NAME],
188  )
189  return None
190 
191  @property
192  def hvac_mode(self) -> HVACMode:
193  """Return the current operation (e.g. heat, cool, idle)."""
194  hvac_mode = HVACMode.OFF
195  if ThermostatModeTrait.NAME in self._device_device.traits:
196  trait = self._device_device.traits[ThermostatModeTrait.NAME]
197  if trait.mode in THERMOSTAT_MODE_MAP:
198  hvac_mode = THERMOSTAT_MODE_MAP[trait.mode]
199  return hvac_mode
200 
201  @property
202  def hvac_action(self) -> HVACAction | None:
203  """Return the current HVAC action (heating, cooling)."""
204  trait = self._device_device.traits[ThermostatHvacTrait.NAME]
205  if trait.status == "OFF" and self.hvac_modehvac_modehvac_modehvac_modehvac_mode != HVACMode.OFF:
206  return HVACAction.IDLE
207  return THERMOSTAT_HVAC_STATUS_MAP.get(trait.status)
208 
209  @property
210  def preset_mode(self) -> str:
211  """Return the current active preset."""
212  if ThermostatEcoTrait.NAME in self._device_device.traits:
213  trait = self._device_device.traits[ThermostatEcoTrait.NAME]
214  return PRESET_MODE_MAP.get(trait.mode, PRESET_NONE)
215  return PRESET_NONE
216 
217  @property
218  def preset_modes(self) -> list[str]:
219  """Return the available presets."""
220  if ThermostatEcoTrait.NAME not in self._device_device.traits:
221  return []
222  return [
223  PRESET_MODE_MAP[mode]
224  for mode in self._device_device.traits[ThermostatEcoTrait.NAME].available_modes
225  if mode in PRESET_MODE_MAP
226  ]
227 
228  @property
229  def fan_mode(self) -> str:
230  """Return the current fan mode."""
231  if (
232  self.supported_featuressupported_featuressupported_features & ClimateEntityFeature.FAN_MODE
233  and FanTrait.NAME in self._device_device.traits
234  ):
235  trait = self._device_device.traits[FanTrait.NAME]
236  return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF)
237  return FAN_OFF
238 
239  @property
240  def fan_modes(self) -> list[str]:
241  """Return the list of available fan modes."""
242  if (
243  self.supported_featuressupported_featuressupported_features & ClimateEntityFeature.FAN_MODE
244  and FanTrait.NAME in self._device_device.traits
245  ):
246  return FAN_INV_MODES
247  return []
248 
249  def _get_supported_features(self) -> ClimateEntityFeature:
250  """Compute the bitmap of supported features from the current state."""
251  features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
252  if HVACMode.HEAT_COOL in self.hvac_modeshvac_modes:
253  features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
254  if HVACMode.HEAT in self.hvac_modeshvac_modes or HVACMode.COOL in self.hvac_modeshvac_modes:
255  features |= ClimateEntityFeature.TARGET_TEMPERATURE
256  if ThermostatEcoTrait.NAME in self._device_device.traits:
257  features |= ClimateEntityFeature.PRESET_MODE
258  if FanTrait.NAME in self._device_device.traits:
259  # Fan trait may be present without actually support fan mode
260  fan_trait = self._device_device.traits[FanTrait.NAME]
261  if fan_trait.timer_mode is not None:
262  features |= ClimateEntityFeature.FAN_MODE
263  return features
264 
265  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
266  """Set new target hvac mode."""
267  if hvac_mode not in self.hvac_modeshvac_modes:
268  raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'")
269  api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode]
270  trait = self._device_device.traits[ThermostatModeTrait.NAME]
271  try:
272  await trait.set_mode(api_mode)
273  except ApiException as err:
274  raise HomeAssistantError(
275  f"Error setting {self.entity_id} HVAC mode to {hvac_mode}: {err}"
276  ) from err
277 
278  async def async_set_temperature(self, **kwargs: Any) -> None:
279  """Set new target temperature."""
280  hvac_mode = self.hvac_modehvac_modehvac_modehvac_modehvac_mode
281  if kwargs.get(ATTR_HVAC_MODE) is not None:
282  hvac_mode = kwargs[ATTR_HVAC_MODE]
283  await self.async_set_hvac_modeasync_set_hvac_modeasync_set_hvac_mode(hvac_mode)
284  low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
285  high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
286  temp = kwargs.get(ATTR_TEMPERATURE)
287  if ThermostatTemperatureSetpointTrait.NAME not in self._device_device.traits:
288  raise HomeAssistantError(
289  f"Error setting {self.entity_id} temperature to {kwargs}: "
290  "Unable to find setpoint trait."
291  )
292  trait = self._device_device.traits[ThermostatTemperatureSetpointTrait.NAME]
293  try:
294  if self.preset_modepreset_modepreset_modepreset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL:
295  if low_temp and high_temp:
296  if high_temp - low_temp < MIN_TEMP_RANGE:
297  # Ensure there is a minimum gap from the new temp. Pick
298  # the temp that is not changing as the one to move.
299  if abs(high_temp - self.target_temperature_hightarget_temperature_hightarget_temperature_high) < 0.01:
300  high_temp = low_temp + MIN_TEMP_RANGE
301  else:
302  low_temp = high_temp - MIN_TEMP_RANGE
303  await trait.set_range(low_temp, high_temp)
304  elif hvac_mode == HVACMode.COOL and temp:
305  await trait.set_cool(temp)
306  elif hvac_mode == HVACMode.HEAT and temp:
307  await trait.set_heat(temp)
308  except ApiException as err:
309  raise HomeAssistantError(
310  f"Error setting {self.entity_id} temperature to {kwargs}: {err}"
311  ) from err
312 
313  async def async_set_preset_mode(self, preset_mode: str) -> None:
314  """Set new target preset mode."""
315  if preset_mode not in self.preset_modespreset_modespreset_modes:
316  raise ValueError(f"Unsupported preset_mode '{preset_mode}'")
317  if self.preset_modepreset_modepreset_modepreset_mode == preset_mode: # API doesn't like duplicate preset modes
318  return
319  trait = self._device_device.traits[ThermostatEcoTrait.NAME]
320  try:
321  await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode])
322  except ApiException as err:
323  raise HomeAssistantError(
324  f"Error setting {self.entity_id} preset mode to {preset_mode}: {err}"
325  ) from err
326 
327  async def async_set_fan_mode(self, fan_mode: str) -> None:
328  """Set new target fan mode."""
329  if fan_mode not in self.fan_modesfan_modesfan_modes:
330  raise ValueError(f"Unsupported fan_mode '{fan_mode}'")
331  if fan_mode == FAN_ON and self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.OFF:
332  raise ValueError(
333  "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first"
334  )
335  trait = self._device_device.traits[FanTrait.NAME]
336  duration = None
337  if fan_mode != FAN_OFF:
338  duration = MAX_FAN_DURATION
339  try:
340  await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration)
341  except ApiException as err:
342  raise HomeAssistantError(
343  f"Error setting {self.entity_id} fan mode to {fan_mode}: {err}"
344  ) from err
ClimateEntityFeature supported_features(self)
Definition: __init__.py:945
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: __init__.py:813
ThermostatEcoTrait|ThermostatTemperatureSetpointTrait|None _target_temperature_trait(self)
Definition: climate.py:175
ClimateEntityFeature _get_supported_features(self)
Definition: climate.py:249
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:313
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:265
int|None supported_features(self)
Definition: entity.py:861
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:82