Home Assistant Unofficial Reference 2024.12.1
trait.py
Go to the documentation of this file.
1 """Implement the Google Smart Home traits."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC, abstractmethod
6 from datetime import datetime, timedelta
7 import logging
8 from typing import Any
9 
10 from homeassistant.components import (
11  alarm_control_panel,
12  binary_sensor,
13  button,
14  camera,
15  climate,
16  cover,
17  event,
18  fan,
19  group,
20  humidifier,
21  input_boolean,
22  input_button,
23  input_select,
24  light,
25  lock,
26  media_player,
27  scene,
28  script,
29  select,
30  sensor,
31  switch,
32  vacuum,
33  valve,
34  water_heater,
35 )
37  AlarmControlPanelEntityFeature,
38  AlarmControlPanelState,
39 )
40 from homeassistant.components.camera import CameraEntityFeature
41 from homeassistant.components.climate import ClimateEntityFeature
42 from homeassistant.components.cover import CoverEntityFeature
43 from homeassistant.components.fan import FanEntityFeature
44 from homeassistant.components.humidifier import HumidifierEntityFeature
45 from homeassistant.components.light import LightEntityFeature
46 from homeassistant.components.lock import LockState
47 from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType
48 from homeassistant.components.vacuum import VacuumEntityFeature
49 from homeassistant.components.valve import ValveEntityFeature
50 from homeassistant.components.water_heater import WaterHeaterEntityFeature
51 from homeassistant.const import (
52  ATTR_ASSUMED_STATE,
53  ATTR_BATTERY_LEVEL,
54  ATTR_CODE,
55  ATTR_DEVICE_CLASS,
56  ATTR_ENTITY_ID,
57  ATTR_MODE,
58  ATTR_SUPPORTED_FEATURES,
59  ATTR_TEMPERATURE,
60  CAST_APP_ID_HOMEASSISTANT_MEDIA,
61  SERVICE_ALARM_ARM_AWAY,
62  SERVICE_ALARM_ARM_CUSTOM_BYPASS,
63  SERVICE_ALARM_ARM_HOME,
64  SERVICE_ALARM_ARM_NIGHT,
65  SERVICE_ALARM_DISARM,
66  SERVICE_ALARM_TRIGGER,
67  SERVICE_TURN_OFF,
68  SERVICE_TURN_ON,
69  STATE_IDLE,
70  STATE_OFF,
71  STATE_ON,
72  STATE_PAUSED,
73  STATE_PLAYING,
74  STATE_STANDBY,
75  STATE_UNAVAILABLE,
76  STATE_UNKNOWN,
77  UnitOfTemperature,
78 )
79 from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
80 from homeassistant.helpers.network import get_url
81 from homeassistant.util import color as color_util, dt as dt_util
82 from homeassistant.util.dt import utcnow
84  ordered_list_item_to_percentage,
85  percentage_to_ordered_list_item,
86 )
87 from homeassistant.util.unit_conversion import TemperatureConverter
88 
89 from .const import (
90  CHALLENGE_FAILED_PIN_NEEDED,
91  CHALLENGE_PIN_NEEDED,
92  ERR_ALREADY_ARMED,
93  ERR_ALREADY_DISARMED,
94  ERR_ALREADY_STOPPED,
95  ERR_CHALLENGE_NOT_SETUP,
96  ERR_FUNCTION_NOT_SUPPORTED,
97  ERR_NO_AVAILABLE_CHANNEL,
98  ERR_NOT_SUPPORTED,
99  ERR_UNSUPPORTED_INPUT,
100  ERR_VALUE_OUT_OF_RANGE,
101  FAN_SPEEDS,
102 )
103 from .error import ChallengeNeeded, SmartHomeError
104 
105 _LOGGER = logging.getLogger(__name__)
106 
107 PREFIX_TRAITS = "action.devices.traits."
108 TRAIT_ARM_DISARM = f"{PREFIX_TRAITS}ArmDisarm"
109 TRAIT_BRIGHTNESS = f"{PREFIX_TRAITS}Brightness"
110 TRAIT_CAMERA_STREAM = f"{PREFIX_TRAITS}CameraStream"
111 TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel"
112 TRAIT_COLOR_SETTING = f"{PREFIX_TRAITS}ColorSetting"
113 TRAIT_DOCK = f"{PREFIX_TRAITS}Dock"
114 TRAIT_ENERGY_STORAGE = f"{PREFIX_TRAITS}EnergyStorage"
115 TRAIT_FAN_SPEED = f"{PREFIX_TRAITS}FanSpeed"
116 TRAIT_HUMIDITY_SETTING = f"{PREFIX_TRAITS}HumiditySetting"
117 TRAIT_INPUT_SELECTOR = f"{PREFIX_TRAITS}InputSelector"
118 TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator"
119 TRAIT_LOCK_UNLOCK = f"{PREFIX_TRAITS}LockUnlock"
120 TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState"
121 TRAIT_MODES = f"{PREFIX_TRAITS}Modes"
122 TRAIT_OBJECT_DETECTION = f"{PREFIX_TRAITS}ObjectDetection"
123 TRAIT_ON_OFF = f"{PREFIX_TRAITS}OnOff"
124 TRAIT_OPEN_CLOSE = f"{PREFIX_TRAITS}OpenClose"
125 TRAIT_SCENE = f"{PREFIX_TRAITS}Scene"
126 TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState"
127 TRAIT_START_STOP = f"{PREFIX_TRAITS}StartStop"
128 TRAIT_TEMPERATURE_CONTROL = f"{PREFIX_TRAITS}TemperatureControl"
129 TRAIT_TEMPERATURE_SETTING = f"{PREFIX_TRAITS}TemperatureSetting"
130 TRAIT_TRANSPORT_CONTROL = f"{PREFIX_TRAITS}TransportControl"
131 TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume"
132 
133 PREFIX_COMMANDS = "action.devices.commands."
134 COMMAND_ACTIVATE_SCENE = f"{PREFIX_COMMANDS}ActivateScene"
135 COMMAND_ARM_DISARM = f"{PREFIX_COMMANDS}ArmDisarm"
136 COMMAND_BRIGHTNESS_ABSOLUTE = f"{PREFIX_COMMANDS}BrightnessAbsolute"
137 COMMAND_CHARGE = f"{PREFIX_COMMANDS}Charge"
138 COMMAND_COLOR_ABSOLUTE = f"{PREFIX_COMMANDS}ColorAbsolute"
139 COMMAND_DOCK = f"{PREFIX_COMMANDS}Dock"
140 COMMAND_GET_CAMERA_STREAM = f"{PREFIX_COMMANDS}GetCameraStream"
141 COMMAND_LOCK_UNLOCK = f"{PREFIX_COMMANDS}LockUnlock"
142 COMMAND_LOCATE = f"{PREFIX_COMMANDS}Locate"
143 COMMAND_NEXT_INPUT = f"{PREFIX_COMMANDS}NextInput"
144 COMMAND_MEDIA_NEXT = f"{PREFIX_COMMANDS}mediaNext"
145 COMMAND_MEDIA_PAUSE = f"{PREFIX_COMMANDS}mediaPause"
146 COMMAND_MEDIA_PREVIOUS = f"{PREFIX_COMMANDS}mediaPrevious"
147 COMMAND_MEDIA_RESUME = f"{PREFIX_COMMANDS}mediaResume"
148 COMMAND_MEDIA_SEEK_RELATIVE = f"{PREFIX_COMMANDS}mediaSeekRelative"
149 COMMAND_MEDIA_SEEK_TO_POSITION = f"{PREFIX_COMMANDS}mediaSeekToPosition"
150 COMMAND_MEDIA_SHUFFLE = f"{PREFIX_COMMANDS}mediaShuffle"
151 COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop"
152 COMMAND_MUTE = f"{PREFIX_COMMANDS}mute"
153 COMMAND_OPEN_CLOSE = f"{PREFIX_COMMANDS}OpenClose"
154 COMMAND_ON_OFF = f"{PREFIX_COMMANDS}OnOff"
155 COMMAND_OPEN_CLOSE_RELATIVE = f"{PREFIX_COMMANDS}OpenCloseRelative"
156 COMMAND_PAUSE_UNPAUSE = f"{PREFIX_COMMANDS}PauseUnpause"
157 COMMAND_REVERSE = f"{PREFIX_COMMANDS}Reverse"
158 COMMAND_PREVIOUS_INPUT = f"{PREFIX_COMMANDS}PreviousInput"
159 COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel"
160 COMMAND_SET_TEMPERATURE = f"{PREFIX_COMMANDS}SetTemperature"
161 COMMAND_SET_FAN_SPEED = f"{PREFIX_COMMANDS}SetFanSpeed"
162 COMMAND_SET_FAN_SPEED_RELATIVE = f"{PREFIX_COMMANDS}SetFanSpeedRelative"
163 COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity"
164 COMMAND_SET_INPUT = f"{PREFIX_COMMANDS}SetInput"
165 COMMAND_SET_MODES = f"{PREFIX_COMMANDS}SetModes"
166 COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume"
167 COMMAND_START_STOP = f"{PREFIX_COMMANDS}StartStop"
168 COMMAND_THERMOSTAT_SET_MODE = f"{PREFIX_COMMANDS}ThermostatSetMode"
169 COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = (
170  f"{PREFIX_COMMANDS}ThermostatTemperatureSetpoint"
171 )
172 COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = (
173  f"{PREFIX_COMMANDS}ThermostatTemperatureSetRange"
174 )
175 COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative"
176 
177 TRAITS: list[type[_Trait]] = []
178 
179 FAN_SPEED_MAX_SPEED_COUNT = 5
180 
181 COVER_VALVE_STATES = {
182  cover.DOMAIN: {
183  "closed": cover.STATE_CLOSED,
184  "closing": cover.STATE_CLOSING,
185  "open": cover.STATE_OPEN,
186  "opening": cover.STATE_OPENING,
187  },
188  valve.DOMAIN: {
189  "closed": valve.STATE_CLOSED,
190  "closing": valve.STATE_CLOSING,
191  "open": valve.STATE_OPEN,
192  "opening": valve.STATE_OPENING,
193  },
194 }
195 
196 SERVICE_STOP_COVER_VALVE = {
197  cover.DOMAIN: cover.SERVICE_STOP_COVER,
198  valve.DOMAIN: valve.SERVICE_STOP_VALVE,
199 }
200 SERVICE_OPEN_COVER_VALVE = {
201  cover.DOMAIN: cover.SERVICE_OPEN_COVER,
202  valve.DOMAIN: valve.SERVICE_OPEN_VALVE,
203 }
204 SERVICE_CLOSE_COVER_VALVE = {
205  cover.DOMAIN: cover.SERVICE_CLOSE_COVER,
206  valve.DOMAIN: valve.SERVICE_CLOSE_VALVE,
207 }
208 SERVICE_TOGGLE_COVER_VALVE = {
209  cover.DOMAIN: cover.SERVICE_TOGGLE,
210  valve.DOMAIN: valve.SERVICE_TOGGLE,
211 }
212 SERVICE_SET_POSITION_COVER_VALVE = {
213  cover.DOMAIN: cover.SERVICE_SET_COVER_POSITION,
214  valve.DOMAIN: valve.SERVICE_SET_VALVE_POSITION,
215 }
216 
217 COVER_VALVE_CURRENT_POSITION = {
218  cover.DOMAIN: cover.ATTR_CURRENT_POSITION,
219  valve.DOMAIN: valve.ATTR_CURRENT_POSITION,
220 }
221 
222 COVER_VALVE_POSITION = {
223  cover.DOMAIN: cover.ATTR_POSITION,
224  valve.DOMAIN: valve.ATTR_POSITION,
225 }
226 
227 COVER_VALVE_SET_POSITION_FEATURE = {
228  cover.DOMAIN: CoverEntityFeature.SET_POSITION,
229  valve.DOMAIN: ValveEntityFeature.SET_POSITION,
230 }
231 COVER_VALVE_STOP_FEATURE = {
232  cover.DOMAIN: CoverEntityFeature.STOP,
233  valve.DOMAIN: ValveEntityFeature.STOP,
234 }
235 
236 COVER_VALVE_DOMAINS = {cover.DOMAIN, valve.DOMAIN}
237 
238 FRIENDLY_DOMAIN = {cover.DOMAIN: "Cover", valve.DOMAIN: "Valve"}
239 
240 
241 def register_trait[_TraitT: _Trait](trait: type[_TraitT]) -> type[_TraitT]:
242  """Decorate a class to register a trait."""
243  TRAITS.append(trait)
244  return trait
245 
246 
247 def _google_temp_unit(units):
248  """Return Google temperature unit."""
249  if units == UnitOfTemperature.FAHRENHEIT:
250  return "F"
251  return "C"
252 
253 
254 def _next_selected(items: list[str], selected: str | None) -> str | None:
255  """Return the next item in an item list starting at given value.
256 
257  If selected is missing in items, None is returned
258  """
259  if selected is None:
260  return None
261  try:
262  index = items.index(selected)
263  except ValueError:
264  return None
265 
266  next_item = 0 if index == len(items) - 1 else index + 1
267  return items[next_item]
268 
269 
270 class _Trait(ABC):
271  """Represents a Trait inside Google Assistant skill."""
272 
273  name: str
274  commands: list[str] = []
275 
276  @staticmethod
277  def might_2fa(domain, features, device_class):
278  """Return if the trait might ask for 2FA."""
279  return False
280 
281  @staticmethod
282  @abstractmethod
283  def supported(domain, features, device_class, attributes):
284  """Test if state is supported."""
285 
286  def __init__(self, hass: HomeAssistant, state, config) -> None:
287  """Initialize a trait for a state."""
288  self.hasshass = hass
289  self.statestate = state
290  self.configconfig = config
291 
292  def sync_attributes(self) -> dict[str, Any]:
293  """Return attributes for a sync request."""
294  raise NotImplementedError
295 
296  def sync_options(self) -> dict[str, Any]:
297  """Add options for the sync request."""
298  return {}
299 
300  def query_attributes(self) -> dict[str, Any]:
301  """Return the attributes of this trait for this entity."""
302  raise NotImplementedError
303 
304  def query_notifications(self) -> dict[str, Any] | None:
305  """Return notifications payload."""
306 
307  def can_execute(self, command, params):
308  """Test if command can be executed."""
309  return command in self.commands
310 
311  async def execute(self, command, data, params, challenge):
312  """Execute a trait command."""
313  raise NotImplementedError
314 
315 
316 @register_trait
318  """Trait to control brightness of a device.
319 
320  https://developers.google.com/actions/smarthome/traits/brightness
321  """
322 
323  name = TRAIT_BRIGHTNESS
324  commands = [COMMAND_BRIGHTNESS_ABSOLUTE]
325 
326  @staticmethod
327  def supported(domain, features, device_class, attributes):
328  """Test if state is supported."""
329  if domain == light.DOMAIN:
330  color_modes = attributes.get(light.ATTR_SUPPORTED_COLOR_MODES)
331  return light.brightness_supported(color_modes)
332 
333  return False
334 
335  def sync_attributes(self) -> dict[str, Any]:
336  """Return brightness attributes for a sync request."""
337  return {}
338 
339  def query_attributes(self) -> dict[str, Any]:
340  """Return brightness query attributes."""
341  domain = self.statestate.domain
342  response = {}
343 
344  if domain == light.DOMAIN:
345  brightness = self.statestate.attributes.get(light.ATTR_BRIGHTNESS)
346  if brightness is not None:
347  response["brightness"] = round(100 * (brightness / 255))
348 
349  return response
350 
351  async def execute(self, command, data, params, challenge):
352  """Execute a brightness command."""
353  if self.statestate.domain == light.DOMAIN:
354  await self.hasshass.services.async_call(
355  light.DOMAIN,
356  light.SERVICE_TURN_ON,
357  {
358  ATTR_ENTITY_ID: self.statestate.entity_id,
359  light.ATTR_BRIGHTNESS_PCT: params["brightness"],
360  },
361  blocking=not self.configconfig.should_report_state,
362  context=data.context,
363  )
364 
365 
366 @register_trait
368  """Trait to stream from cameras.
369 
370  https://developers.google.com/actions/smarthome/traits/camerastream
371  """
372 
373  name = TRAIT_CAMERA_STREAM
374  commands = [COMMAND_GET_CAMERA_STREAM]
375 
376  stream_info: dict[str, str] | None = None
377 
378  @staticmethod
379  def supported(domain, features, device_class, _):
380  """Test if state is supported."""
381  if domain == camera.DOMAIN:
382  return features & CameraEntityFeature.STREAM
383 
384  return False
385 
386  def sync_attributes(self) -> dict[str, Any]:
387  """Return stream attributes for a sync request."""
388  return {
389  "cameraStreamSupportedProtocols": ["hls"],
390  "cameraStreamNeedAuthToken": False,
391  "cameraStreamNeedDrmEncryption": False,
392  }
393 
394  def query_attributes(self) -> dict[str, Any]:
395  """Return camera stream attributes."""
396  return self.stream_infostream_info or {}
397 
398  async def execute(self, command, data, params, challenge):
399  """Execute a get camera stream command."""
400  url = await camera.async_request_stream(self.hasshass, self.statestate.entity_id, "hls")
401  self.stream_infostream_info = {
402  "cameraStreamAccessUrl": f"{get_url(self.hass)}{url}",
403  "cameraStreamReceiverAppId": CAST_APP_ID_HOMEASSISTANT_MEDIA,
404  }
405 
406 
407 @register_trait
409  """Trait to object detection.
410 
411  https://developers.google.com/actions/smarthome/traits/objectdetection
412  """
413 
414  name = TRAIT_OBJECT_DETECTION
415  commands = []
416 
417  @staticmethod
418  def supported(domain, features, device_class, _) -> bool:
419  """Test if state is supported."""
420  return (
421  domain == event.DOMAIN and device_class == event.EventDeviceClass.DOORBELL
422  )
423 
424  def sync_attributes(self) -> dict[str, Any]:
425  """Return ObjectDetection attributes for a sync request."""
426  return {}
427 
428  def sync_options(self) -> dict[str, Any]:
429  """Add options for the sync request."""
430  return {"notificationSupportedByAgent": True}
431 
432  def query_attributes(self) -> dict[str, Any]:
433  """Return ObjectDetection query attributes."""
434  return {}
435 
436  def query_notifications(self) -> dict[str, Any] | None:
437  """Return notifications payload."""
438 
439  if self.statestate.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}:
440  return None
441 
442  # Only notify if last event was less then 30 seconds ago
443  time_stamp: datetime = datetime.fromisoformat(self.statestate.state)
444  if (utcnow() - time_stamp) > timedelta(seconds=30):
445  return None
446 
447  # A doorbell event is treated as an object detection of 1 unclassified object.
448  # The implementation follows the pattern from the Smart Home Doorbell Guide:
449  # https://developers.home.google.com/cloud-to-cloud/guides/doorbell
450  # The detectionTimestamp is the time in ms from January 1, 1970, 00:00:00 (UTC)
451  return {
452  "ObjectDetection": {
453  "objects": {
454  "unclassified": 1,
455  },
456  "priority": 0,
457  "detectionTimestamp": int(time_stamp.timestamp() * 1000),
458  },
459  }
460 
461  async def execute(self, command, data, params, challenge):
462  """Execute an ObjectDetection command."""
463 
464 
465 @register_trait
467  """Trait to offer basic on and off functionality.
468 
469  https://developers.google.com/actions/smarthome/traits/onoff
470  """
471 
472  name = TRAIT_ON_OFF
473  commands = [COMMAND_ON_OFF]
474 
475  @staticmethod
476  def supported(domain, features, device_class, _):
477  """Test if state is supported."""
478  if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF:
479  return True
480 
481  if domain == climate.DOMAIN and features & (
482  ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
483  ):
484  return True
485 
486  return domain in (
487  group.DOMAIN,
488  input_boolean.DOMAIN,
489  switch.DOMAIN,
490  fan.DOMAIN,
491  light.DOMAIN,
492  media_player.DOMAIN,
493  humidifier.DOMAIN,
494  )
495 
496  def sync_attributes(self) -> dict[str, Any]:
497  """Return OnOff attributes for a sync request."""
498  if self.statestate.attributes.get(ATTR_ASSUMED_STATE, False):
499  return {"commandOnlyOnOff": True}
500  return {}
501 
502  def query_attributes(self) -> dict[str, Any]:
503  """Return OnOff query attributes."""
504  return {"on": self.statestate.state not in (STATE_OFF, STATE_UNKNOWN)}
505 
506  async def execute(self, command, data, params, challenge):
507  """Execute an OnOff command."""
508  if (domain := self.statestate.domain) == group.DOMAIN:
509  service_domain = HOMEASSISTANT_DOMAIN
510  service = SERVICE_TURN_ON if params["on"] else SERVICE_TURN_OFF
511 
512  else:
513  service_domain = domain
514  service = SERVICE_TURN_ON if params["on"] else SERVICE_TURN_OFF
515 
516  await self.hasshass.services.async_call(
517  service_domain,
518  service,
519  {ATTR_ENTITY_ID: self.statestate.entity_id},
520  blocking=not self.configconfig.should_report_state,
521  context=data.context,
522  )
523 
524 
525 @register_trait
527  """Trait to offer color temperature functionality.
528 
529  https://developers.google.com/actions/smarthome/traits/colortemperature
530  """
531 
532  name = TRAIT_COLOR_SETTING
533  commands = [COMMAND_COLOR_ABSOLUTE]
534 
535  @staticmethod
536  def supported(domain, features, device_class, attributes):
537  """Test if state is supported."""
538  if domain != light.DOMAIN:
539  return False
540 
541  color_modes = attributes.get(light.ATTR_SUPPORTED_COLOR_MODES)
542  return light.color_temp_supported(color_modes) or light.color_supported(
543  color_modes
544  )
545 
546  def sync_attributes(self) -> dict[str, Any]:
547  """Return color temperature attributes for a sync request."""
548  attrs = self.statestate.attributes
549  color_modes = attrs.get(light.ATTR_SUPPORTED_COLOR_MODES)
550  response: dict[str, Any] = {}
551 
552  if light.color_supported(color_modes):
553  response["colorModel"] = "hsv"
554 
555  if light.color_temp_supported(color_modes):
556  # Max Kelvin is Min Mireds K = 1000000 / mireds
557  # Min Kelvin is Max Mireds K = 1000000 / mireds
558  response["colorTemperatureRange"] = {
559  "temperatureMaxK": color_util.color_temperature_mired_to_kelvin(
560  attrs.get(light.ATTR_MIN_MIREDS)
561  ),
562  "temperatureMinK": color_util.color_temperature_mired_to_kelvin(
563  attrs.get(light.ATTR_MAX_MIREDS)
564  ),
565  }
566 
567  return response
568 
569  def query_attributes(self) -> dict[str, Any]:
570  """Return color temperature query attributes."""
571  color_mode = self.statestate.attributes.get(light.ATTR_COLOR_MODE)
572 
573  color: dict[str, Any] = {}
574 
575  if light.color_supported([color_mode]):
576  color_hs = self.statestate.attributes.get(light.ATTR_HS_COLOR)
577  brightness = self.statestate.attributes.get(light.ATTR_BRIGHTNESS, 1)
578  if color_hs is not None:
579  color["spectrumHsv"] = {
580  "hue": color_hs[0],
581  "saturation": color_hs[1] / 100,
582  "value": brightness / 255,
583  }
584 
585  if light.color_temp_supported([color_mode]):
586  temp = self.statestate.attributes.get(light.ATTR_COLOR_TEMP)
587  # Some faulty integrations might put 0 in here, raising exception.
588  if temp == 0:
589  _LOGGER.warning(
590  "Entity %s has incorrect color temperature %s",
591  self.statestate.entity_id,
592  temp,
593  )
594  elif temp is not None:
595  color["temperatureK"] = color_util.color_temperature_mired_to_kelvin(
596  temp
597  )
598 
599  response = {}
600 
601  if color:
602  response["color"] = color
603 
604  return response
605 
606  async def execute(self, command, data, params, challenge):
607  """Execute a color temperature command."""
608  if "temperature" in params["color"]:
609  temp = color_util.color_temperature_kelvin_to_mired(
610  params["color"]["temperature"]
611  )
612  min_temp = self.statestate.attributes[light.ATTR_MIN_MIREDS]
613  max_temp = self.statestate.attributes[light.ATTR_MAX_MIREDS]
614 
615  if temp < min_temp or temp > max_temp:
616  raise SmartHomeError(
617  ERR_VALUE_OUT_OF_RANGE,
618  f"Temperature should be between {min_temp} and {max_temp}",
619  )
620 
621  await self.hasshass.services.async_call(
622  light.DOMAIN,
623  SERVICE_TURN_ON,
624  {ATTR_ENTITY_ID: self.statestate.entity_id, light.ATTR_COLOR_TEMP: temp},
625  blocking=not self.configconfig.should_report_state,
626  context=data.context,
627  )
628 
629  elif "spectrumRGB" in params["color"]:
630  # Convert integer to hex format and left pad with 0's till length 6
631  hex_value = f"{params['color']['spectrumRGB']:06x}"
632  color = color_util.color_RGB_to_hs(
633  *color_util.rgb_hex_to_rgb_list(hex_value)
634  )
635 
636  await self.hasshass.services.async_call(
637  light.DOMAIN,
638  SERVICE_TURN_ON,
639  {ATTR_ENTITY_ID: self.statestate.entity_id, light.ATTR_HS_COLOR: color},
640  blocking=not self.configconfig.should_report_state,
641  context=data.context,
642  )
643 
644  elif "spectrumHSV" in params["color"]:
645  color = params["color"]["spectrumHSV"]
646  saturation = color["saturation"] * 100
647  brightness = color["value"] * 255
648 
649  await self.hasshass.services.async_call(
650  light.DOMAIN,
651  SERVICE_TURN_ON,
652  {
653  ATTR_ENTITY_ID: self.statestate.entity_id,
654  light.ATTR_HS_COLOR: [color["hue"], saturation],
655  light.ATTR_BRIGHTNESS: brightness,
656  },
657  blocking=not self.configconfig.should_report_state,
658  context=data.context,
659  )
660 
661 
662 @register_trait
664  """Trait to offer scene functionality.
665 
666  https://developers.google.com/actions/smarthome/traits/scene
667  """
668 
669  name = TRAIT_SCENE
670  commands = [COMMAND_ACTIVATE_SCENE]
671 
672  @staticmethod
673  def supported(domain, features, device_class, _):
674  """Test if state is supported."""
675  return domain in (
676  button.DOMAIN,
677  input_button.DOMAIN,
678  scene.DOMAIN,
679  script.DOMAIN,
680  )
681 
682  def sync_attributes(self) -> dict[str, Any]:
683  """Return scene attributes for a sync request."""
684  # None of the supported domains can support sceneReversible
685  return {}
686 
687  def query_attributes(self) -> dict[str, Any]:
688  """Return scene query attributes."""
689  return {}
690 
691  async def execute(self, command, data, params, challenge):
692  """Execute a scene command."""
693  service = SERVICE_TURN_ON
694  if self.statestate.domain == button.DOMAIN:
695  service = button.SERVICE_PRESS
696  elif self.statestate.domain == input_button.DOMAIN:
697  service = input_button.SERVICE_PRESS
698 
699  # Don't block for scripts or buttons, as they can be slow.
700  await self.hasshass.services.async_call(
701  self.statestate.domain,
702  service,
703  {ATTR_ENTITY_ID: self.statestate.entity_id},
704  blocking=(not self.configconfig.should_report_state)
705  and self.statestate.domain
706  not in (button.DOMAIN, input_button.DOMAIN, script.DOMAIN),
707  context=data.context,
708  )
709 
710 
711 @register_trait
713  """Trait to offer dock functionality.
714 
715  https://developers.google.com/actions/smarthome/traits/dock
716  """
717 
718  name = TRAIT_DOCK
719  commands = [COMMAND_DOCK]
720 
721  @staticmethod
722  def supported(domain, features, device_class, _):
723  """Test if state is supported."""
724  return domain == vacuum.DOMAIN
725 
726  def sync_attributes(self) -> dict[str, Any]:
727  """Return dock attributes for a sync request."""
728  return {}
729 
730  def query_attributes(self) -> dict[str, Any]:
731  """Return dock query attributes."""
732  return {"isDocked": self.statestate.state == vacuum.STATE_DOCKED}
733 
734  async def execute(self, command, data, params, challenge):
735  """Execute a dock command."""
736  await self.hasshass.services.async_call(
737  self.statestate.domain,
738  vacuum.SERVICE_RETURN_TO_BASE,
739  {ATTR_ENTITY_ID: self.statestate.entity_id},
740  blocking=not self.configconfig.should_report_state,
741  context=data.context,
742  )
743 
744 
745 @register_trait
747  """Trait to offer locate functionality.
748 
749  https://developers.google.com/actions/smarthome/traits/locator
750  """
751 
752  name = TRAIT_LOCATOR
753  commands = [COMMAND_LOCATE]
754 
755  @staticmethod
756  def supported(domain, features, device_class, _):
757  """Test if state is supported."""
758  return domain == vacuum.DOMAIN and features & VacuumEntityFeature.LOCATE
759 
760  def sync_attributes(self) -> dict[str, Any]:
761  """Return locator attributes for a sync request."""
762  return {}
763 
764  def query_attributes(self) -> dict[str, Any]:
765  """Return locator query attributes."""
766  return {}
767 
768  async def execute(self, command, data, params, challenge):
769  """Execute a locate command."""
770  if params.get("silence", False):
771  raise SmartHomeError(
772  ERR_FUNCTION_NOT_SUPPORTED,
773  "Silencing a Locate request is not yet supported",
774  )
775 
776  await self.hasshass.services.async_call(
777  self.statestate.domain,
778  vacuum.SERVICE_LOCATE,
779  {ATTR_ENTITY_ID: self.statestate.entity_id},
780  blocking=not self.configconfig.should_report_state,
781  context=data.context,
782  )
783 
784 
785 @register_trait
787  """Trait to offer EnergyStorage functionality.
788 
789  https://developers.google.com/actions/smarthome/traits/energystorage
790  """
791 
792  name = TRAIT_ENERGY_STORAGE
793  commands = [COMMAND_CHARGE]
794 
795  @staticmethod
796  def supported(domain, features, device_class, _):
797  """Test if state is supported."""
798  return domain == vacuum.DOMAIN and features & VacuumEntityFeature.BATTERY
799 
800  def sync_attributes(self) -> dict[str, Any]:
801  """Return EnergyStorage attributes for a sync request."""
802  return {
803  "isRechargeable": True,
804  "queryOnlyEnergyStorage": True,
805  }
806 
807  def query_attributes(self) -> dict[str, Any]:
808  """Return EnergyStorage query attributes."""
809  battery_level = self.statestate.attributes.get(ATTR_BATTERY_LEVEL)
810  if battery_level is None:
811  return {}
812  if battery_level == 100:
813  descriptive_capacity_remaining = "FULL"
814  elif 75 <= battery_level < 100:
815  descriptive_capacity_remaining = "HIGH"
816  elif 50 <= battery_level < 75:
817  descriptive_capacity_remaining = "MEDIUM"
818  elif 25 <= battery_level < 50:
819  descriptive_capacity_remaining = "LOW"
820  elif 0 <= battery_level < 25:
821  descriptive_capacity_remaining = "CRITICALLY_LOW"
822  return {
823  "descriptiveCapacityRemaining": descriptive_capacity_remaining,
824  "capacityRemaining": [{"rawValue": battery_level, "unit": "PERCENTAGE"}],
825  "capacityUntilFull": [
826  {"rawValue": 100 - battery_level, "unit": "PERCENTAGE"}
827  ],
828  "isCharging": self.statestate.state == vacuum.STATE_DOCKED,
829  "isPluggedIn": self.statestate.state == vacuum.STATE_DOCKED,
830  }
831 
832  async def execute(self, command, data, params, challenge):
833  """Execute a dock command."""
834  raise SmartHomeError(
835  ERR_FUNCTION_NOT_SUPPORTED,
836  "Controlling charging of a vacuum is not yet supported",
837  )
838 
839 
840 @register_trait
842  """Trait to offer StartStop functionality.
843 
844  https://developers.google.com/actions/smarthome/traits/startstop
845  """
846 
847  name = TRAIT_START_STOP
848  commands = [COMMAND_START_STOP, COMMAND_PAUSE_UNPAUSE]
849 
850  @staticmethod
851  def supported(domain, features, device_class, _):
852  """Test if state is supported."""
853  if domain == vacuum.DOMAIN:
854  return True
855 
856  if (
857  domain in COVER_VALVE_DOMAINS
858  and features & COVER_VALVE_STOP_FEATURE[domain]
859  ):
860  return True
861 
862  return False
863 
864  def sync_attributes(self) -> dict[str, Any]:
865  """Return StartStop attributes for a sync request."""
866  domain = self.statestate.domain
867  if domain == vacuum.DOMAIN:
868  return {
869  "pausable": self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
870  & VacuumEntityFeature.PAUSE
871  != 0
872  }
873  if domain in COVER_VALVE_DOMAINS:
874  return {}
875 
876  raise NotImplementedError(f"Unsupported domain {domain}")
877 
878  def query_attributes(self) -> dict[str, Any]:
879  """Return StartStop query attributes."""
880  domain = self.statestate.domain
881  state = self.statestate.state
882 
883  if domain == vacuum.DOMAIN:
884  return {
885  "isRunning": state == vacuum.STATE_CLEANING,
886  "isPaused": state == vacuum.STATE_PAUSED,
887  }
888 
889  if domain in COVER_VALVE_DOMAINS:
890  return {
891  "isRunning": state
892  in (
893  COVER_VALVE_STATES[domain]["closing"],
894  COVER_VALVE_STATES[domain]["opening"],
895  )
896  }
897 
898  raise NotImplementedError(f"Unsupported domain {domain}")
899 
900  async def execute(self, command, data, params, challenge):
901  """Execute a StartStop command."""
902  domain = self.statestate.domain
903  if domain == vacuum.DOMAIN:
904  await self._execute_vacuum_execute_vacuum(command, data, params, challenge)
905  return
906  if domain in COVER_VALVE_DOMAINS:
907  await self._execute_cover_or_valve_execute_cover_or_valve(command, data, params, challenge)
908  return
909 
910  async def _execute_vacuum(self, command, data, params, challenge):
911  """Execute a StartStop command."""
912  if command == COMMAND_START_STOP:
913  if params["start"]:
914  await self.hasshass.services.async_call(
915  self.statestate.domain,
916  vacuum.SERVICE_START,
917  {ATTR_ENTITY_ID: self.statestate.entity_id},
918  blocking=not self.configconfig.should_report_state,
919  context=data.context,
920  )
921  else:
922  await self.hasshass.services.async_call(
923  self.statestate.domain,
924  vacuum.SERVICE_STOP,
925  {ATTR_ENTITY_ID: self.statestate.entity_id},
926  blocking=not self.configconfig.should_report_state,
927  context=data.context,
928  )
929  elif command == COMMAND_PAUSE_UNPAUSE:
930  if params["pause"]:
931  await self.hasshass.services.async_call(
932  self.statestate.domain,
933  vacuum.SERVICE_PAUSE,
934  {ATTR_ENTITY_ID: self.statestate.entity_id},
935  blocking=not self.configconfig.should_report_state,
936  context=data.context,
937  )
938  else:
939  await self.hasshass.services.async_call(
940  self.statestate.domain,
941  vacuum.SERVICE_START,
942  {ATTR_ENTITY_ID: self.statestate.entity_id},
943  blocking=not self.configconfig.should_report_state,
944  context=data.context,
945  )
946 
947  async def _execute_cover_or_valve(self, command, data, params, challenge):
948  """Execute a StartStop command."""
949  domain = self.statestate.domain
950  if command == COMMAND_START_STOP:
951  if params["start"] is False:
952  if self.statestate.state in (
953  COVER_VALVE_STATES[domain]["closing"],
954  COVER_VALVE_STATES[domain]["opening"],
955  ) or self.statestate.attributes.get(ATTR_ASSUMED_STATE):
956  await self.hasshass.services.async_call(
957  domain,
958  SERVICE_STOP_COVER_VALVE[domain],
959  {ATTR_ENTITY_ID: self.statestate.entity_id},
960  blocking=not self.configconfig.should_report_state,
961  context=data.context,
962  )
963  else:
964  raise SmartHomeError(
965  ERR_ALREADY_STOPPED,
966  f"{FRIENDLY_DOMAIN[domain]} is already stopped",
967  )
968  else:
969  await self.hasshass.services.async_call(
970  domain,
971  SERVICE_TOGGLE_COVER_VALVE[domain],
972  {ATTR_ENTITY_ID: self.statestate.entity_id},
973  blocking=not self.configconfig.should_report_state,
974  context=data.context,
975  )
976  else:
977  raise SmartHomeError(
978  ERR_NOT_SUPPORTED, f"Command {command} is not supported"
979  )
980 
981 
982 @register_trait
984  """Trait for devices (other than thermostats) that support controlling temperature.
985 
986  Control the target temperature of water heaters.
987  Offers a workaround for Temperature sensors by setting queryOnlyTemperatureControl
988  in the response.
989 
990  https://developers.google.com/assistant/smarthome/traits/temperaturecontrol
991  """
992 
993  name = TRAIT_TEMPERATURE_CONTROL
994 
995  commands = [
996  COMMAND_SET_TEMPERATURE,
997  ]
998 
999  @staticmethod
1000  def supported(domain, features, device_class, _):
1001  """Test if state is supported."""
1002  return (
1003  domain == water_heater.DOMAIN
1004  and features & WaterHeaterEntityFeature.TARGET_TEMPERATURE
1005  ) or (
1006  domain == sensor.DOMAIN
1007  and device_class == sensor.SensorDeviceClass.TEMPERATURE
1008  )
1009 
1010  def sync_attributes(self) -> dict[str, Any]:
1011  """Return temperature attributes for a sync request."""
1012  response = {}
1013  domain = self.statestate.domain
1014  attrs = self.statestate.attributes
1015  unit = self.hasshass.config.units.temperature_unit
1016  response["temperatureUnitForUX"] = _google_temp_unit(unit)
1017 
1018  if domain == water_heater.DOMAIN:
1019  min_temp = round(
1020  TemperatureConverter.convert(
1021  float(attrs[water_heater.ATTR_MIN_TEMP]),
1022  unit,
1023  UnitOfTemperature.CELSIUS,
1024  )
1025  )
1026  max_temp = round(
1027  TemperatureConverter.convert(
1028  float(attrs[water_heater.ATTR_MAX_TEMP]),
1029  unit,
1030  UnitOfTemperature.CELSIUS,
1031  )
1032  )
1033  response["temperatureRange"] = {
1034  "minThresholdCelsius": min_temp,
1035  "maxThresholdCelsius": max_temp,
1036  }
1037  else:
1038  response["queryOnlyTemperatureControl"] = True
1039  response["temperatureRange"] = {
1040  "minThresholdCelsius": -100,
1041  "maxThresholdCelsius": 100,
1042  }
1043 
1044  return response
1045 
1046  def query_attributes(self) -> dict[str, Any]:
1047  """Return temperature states."""
1048  response = {}
1049  domain = self.statestate.domain
1050  unit = self.hasshass.config.units.temperature_unit
1051  if domain == water_heater.DOMAIN:
1052  target_temp = self.statestate.attributes[water_heater.ATTR_TEMPERATURE]
1053  current_temp = self.statestate.attributes[water_heater.ATTR_CURRENT_TEMPERATURE]
1054  if target_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
1055  response["temperatureSetpointCelsius"] = round(
1056  TemperatureConverter.convert(
1057  float(target_temp),
1058  unit,
1059  UnitOfTemperature.CELSIUS,
1060  ),
1061  1,
1062  )
1063  if current_temp is not None:
1064  response["temperatureAmbientCelsius"] = round(
1065  TemperatureConverter.convert(
1066  float(current_temp),
1067  unit,
1068  UnitOfTemperature.CELSIUS,
1069  ),
1070  1,
1071  )
1072  return response
1073 
1074  # domain == sensor.DOMAIN
1075  current_temp = self.statestate.state
1076  if current_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
1077  temp = round(
1078  TemperatureConverter.convert(
1079  float(current_temp), unit, UnitOfTemperature.CELSIUS
1080  ),
1081  1,
1082  )
1083  response["temperatureSetpointCelsius"] = temp
1084  response["temperatureAmbientCelsius"] = temp
1085 
1086  return response
1087 
1088  async def execute(self, command, data, params, challenge):
1089  """Execute a temperature point or mode command."""
1090  # All sent in temperatures are always in Celsius
1091  domain = self.statestate.domain
1092  unit = self.hasshass.config.units.temperature_unit
1093 
1094  if domain == water_heater.DOMAIN and command == COMMAND_SET_TEMPERATURE:
1095  min_temp = self.statestate.attributes[water_heater.ATTR_MIN_TEMP]
1096  max_temp = self.statestate.attributes[water_heater.ATTR_MAX_TEMP]
1097  temp = TemperatureConverter.convert(
1098  params["temperature"], UnitOfTemperature.CELSIUS, unit
1099  )
1100  if unit == UnitOfTemperature.FAHRENHEIT:
1101  temp = round(temp)
1102  if temp < min_temp or temp > max_temp:
1103  raise SmartHomeError(
1104  ERR_VALUE_OUT_OF_RANGE,
1105  f"Temperature should be between {min_temp} and {max_temp}",
1106  )
1107 
1108  await self.hasshass.services.async_call(
1109  water_heater.DOMAIN,
1110  water_heater.SERVICE_SET_TEMPERATURE,
1111  {ATTR_ENTITY_ID: self.statestate.entity_id, ATTR_TEMPERATURE: temp},
1112  blocking=not self.configconfig.should_report_state,
1113  context=data.context,
1114  )
1115  return
1116 
1117  raise SmartHomeError(ERR_NOT_SUPPORTED, f"Execute is not supported by {domain}")
1118 
1119 
1120 @register_trait
1122  """Trait to offer handling both temperature point and modes functionality.
1123 
1124  https://developers.google.com/actions/smarthome/traits/temperaturesetting
1125  """
1126 
1127  name = TRAIT_TEMPERATURE_SETTING
1128  commands = [
1129  COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT,
1130  COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE,
1131  COMMAND_THERMOSTAT_SET_MODE,
1132  ]
1133  # We do not support "on" as we are unable to know how to restore
1134  # the last mode.
1135  hvac_to_google = {
1136  climate.HVACMode.HEAT: "heat",
1137  climate.HVACMode.COOL: "cool",
1138  climate.HVACMode.OFF: "off",
1139  climate.HVACMode.AUTO: "auto",
1140  climate.HVACMode.HEAT_COOL: "heatcool",
1141  climate.HVACMode.FAN_ONLY: "fan-only",
1142  climate.HVACMode.DRY: "dry",
1143  }
1144  google_to_hvac = {value: key for key, value in hvac_to_google.items()}
1145 
1146  preset_to_google = {climate.PRESET_ECO: "eco"}
1147  google_to_preset = {value: key for key, value in preset_to_google.items()}
1148 
1149  @staticmethod
1150  def supported(domain, features, device_class, _):
1151  """Test if state is supported."""
1152  return domain == climate.DOMAIN
1153 
1154  @property
1156  """Return supported Google modes."""
1157  modes = []
1158  attrs = self.statestate.attributes
1159 
1160  for mode in attrs.get(climate.ATTR_HVAC_MODES) or []:
1161  google_mode = self.hvac_to_googlehvac_to_google.get(mode)
1162  if google_mode and google_mode not in modes:
1163  modes.append(google_mode)
1164 
1165  for preset in attrs.get(climate.ATTR_PRESET_MODES) or []:
1166  google_mode = self.preset_to_googlepreset_to_google.get(preset)
1167  if google_mode and google_mode not in modes:
1168  modes.append(google_mode)
1169 
1170  return modes
1171 
1172  def sync_attributes(self) -> dict[str, Any]:
1173  """Return temperature point and modes attributes for a sync request."""
1174  response = {}
1175  attrs = self.statestate.attributes
1176  unit = self.hasshass.config.units.temperature_unit
1177  response["thermostatTemperatureUnit"] = _google_temp_unit(unit)
1178 
1179  min_temp = round(
1180  TemperatureConverter.convert(
1181  float(attrs[climate.ATTR_MIN_TEMP]),
1182  unit,
1183  UnitOfTemperature.CELSIUS,
1184  )
1185  )
1186  max_temp = round(
1187  TemperatureConverter.convert(
1188  float(attrs[climate.ATTR_MAX_TEMP]),
1189  unit,
1190  UnitOfTemperature.CELSIUS,
1191  )
1192  )
1193  response["thermostatTemperatureRange"] = {
1194  "minThresholdCelsius": min_temp,
1195  "maxThresholdCelsius": max_temp,
1196  }
1197 
1198  modes = self.climate_google_modesclimate_google_modes
1199 
1200  # Some integrations don't support modes (e.g. opentherm), but Google doesn't
1201  # support changing the temperature if we don't have any modes. If there's
1202  # only one Google doesn't support changing it, so the default mode here is
1203  # only cosmetic.
1204  if len(modes) == 0:
1205  modes.append("heat")
1206 
1207  if "off" in modes and any(
1208  mode in modes for mode in ("heatcool", "heat", "cool")
1209  ):
1210  modes.append("on")
1211  response["availableThermostatModes"] = modes
1212 
1213  return response
1214 
1215  def query_attributes(self) -> dict[str, Any]:
1216  """Return temperature point and modes query attributes."""
1217  response: dict[str, Any] = {}
1218  attrs = self.statestate.attributes
1219  unit = self.hasshass.config.units.temperature_unit
1220 
1221  operation = self.statestate.state
1222  preset = attrs.get(climate.ATTR_PRESET_MODE)
1223  supported = attrs.get(ATTR_SUPPORTED_FEATURES, 0)
1224 
1225  if preset in self.preset_to_googlepreset_to_google:
1226  response["thermostatMode"] = self.preset_to_googlepreset_to_google[preset]
1227  else:
1228  response["thermostatMode"] = self.hvac_to_googlehvac_to_google.get(operation, "none")
1229 
1230  current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
1231  if current_temp is not None:
1232  response["thermostatTemperatureAmbient"] = round(
1233  TemperatureConverter.convert(
1234  current_temp, unit, UnitOfTemperature.CELSIUS
1235  ),
1236  1,
1237  )
1238 
1239  current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY)
1240  if current_humidity is not None:
1241  response["thermostatHumidityAmbient"] = current_humidity
1242 
1243  if operation in (climate.HVACMode.AUTO, climate.HVACMode.HEAT_COOL):
1244  if supported & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
1245  response["thermostatTemperatureSetpointHigh"] = round(
1246  TemperatureConverter.convert(
1247  attrs[climate.ATTR_TARGET_TEMP_HIGH],
1248  unit,
1249  UnitOfTemperature.CELSIUS,
1250  ),
1251  1,
1252  )
1253  response["thermostatTemperatureSetpointLow"] = round(
1254  TemperatureConverter.convert(
1255  attrs[climate.ATTR_TARGET_TEMP_LOW],
1256  unit,
1257  UnitOfTemperature.CELSIUS,
1258  ),
1259  1,
1260  )
1261  elif (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None:
1262  target_temp = round(
1263  TemperatureConverter.convert(
1264  target_temp, unit, UnitOfTemperature.CELSIUS
1265  ),
1266  1,
1267  )
1268  response["thermostatTemperatureSetpointHigh"] = target_temp
1269  response["thermostatTemperatureSetpointLow"] = target_temp
1270  elif (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None:
1271  response["thermostatTemperatureSetpoint"] = round(
1272  TemperatureConverter.convert(
1273  target_temp, unit, UnitOfTemperature.CELSIUS
1274  ),
1275  1,
1276  )
1277 
1278  return response
1279 
1280  async def execute(self, command, data, params, challenge):
1281  """Execute a temperature point or mode command."""
1282  # All sent in temperatures are always in Celsius
1283  unit = self.hasshass.config.units.temperature_unit
1284  min_temp = self.statestate.attributes[climate.ATTR_MIN_TEMP]
1285  max_temp = self.statestate.attributes[climate.ATTR_MAX_TEMP]
1286 
1287  if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT:
1288  temp = TemperatureConverter.convert(
1289  params["thermostatTemperatureSetpoint"], UnitOfTemperature.CELSIUS, unit
1290  )
1291  if unit == UnitOfTemperature.FAHRENHEIT:
1292  temp = round(temp)
1293 
1294  if temp < min_temp or temp > max_temp:
1295  raise SmartHomeError(
1296  ERR_VALUE_OUT_OF_RANGE,
1297  f"Temperature should be between {min_temp} and {max_temp}",
1298  )
1299 
1300  await self.hasshass.services.async_call(
1301  climate.DOMAIN,
1302  climate.SERVICE_SET_TEMPERATURE,
1303  {ATTR_ENTITY_ID: self.statestate.entity_id, ATTR_TEMPERATURE: temp},
1304  blocking=not self.configconfig.should_report_state,
1305  context=data.context,
1306  )
1307 
1308  elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE:
1309  temp_high = TemperatureConverter.convert(
1310  params["thermostatTemperatureSetpointHigh"],
1311  UnitOfTemperature.CELSIUS,
1312  unit,
1313  )
1314  if unit == UnitOfTemperature.FAHRENHEIT:
1315  temp_high = round(temp_high)
1316 
1317  if temp_high < min_temp or temp_high > max_temp:
1318  raise SmartHomeError(
1319  ERR_VALUE_OUT_OF_RANGE,
1320  (
1321  "Upper bound for temperature range should be between "
1322  f"{min_temp} and {max_temp}"
1323  ),
1324  )
1325 
1326  temp_low = TemperatureConverter.convert(
1327  params["thermostatTemperatureSetpointLow"],
1328  UnitOfTemperature.CELSIUS,
1329  unit,
1330  )
1331  if unit == UnitOfTemperature.FAHRENHEIT:
1332  temp_low = round(temp_low)
1333 
1334  if temp_low < min_temp or temp_low > max_temp:
1335  raise SmartHomeError(
1336  ERR_VALUE_OUT_OF_RANGE,
1337  (
1338  "Lower bound for temperature range should be between "
1339  f"{min_temp} and {max_temp}"
1340  ),
1341  )
1342 
1343  supported = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES)
1344  svc_data = {ATTR_ENTITY_ID: self.statestate.entity_id}
1345 
1346  if supported & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
1347  svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
1348  svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
1349  else:
1350  svc_data[ATTR_TEMPERATURE] = (temp_high + temp_low) / 2
1351 
1352  await self.hasshass.services.async_call(
1353  climate.DOMAIN,
1354  climate.SERVICE_SET_TEMPERATURE,
1355  svc_data,
1356  blocking=not self.configconfig.should_report_state,
1357  context=data.context,
1358  )
1359 
1360  elif command == COMMAND_THERMOSTAT_SET_MODE:
1361  target_mode = params["thermostatMode"]
1362  supported = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES)
1363 
1364  if target_mode == "on":
1365  await self.hasshass.services.async_call(
1366  climate.DOMAIN,
1367  SERVICE_TURN_ON,
1368  {ATTR_ENTITY_ID: self.statestate.entity_id},
1369  blocking=not self.configconfig.should_report_state,
1370  context=data.context,
1371  )
1372  return
1373 
1374  if target_mode == "off":
1375  await self.hasshass.services.async_call(
1376  climate.DOMAIN,
1377  SERVICE_TURN_OFF,
1378  {ATTR_ENTITY_ID: self.statestate.entity_id},
1379  blocking=not self.configconfig.should_report_state,
1380  context=data.context,
1381  )
1382  return
1383 
1384  if target_mode in self.google_to_presetgoogle_to_preset:
1385  await self.hasshass.services.async_call(
1386  climate.DOMAIN,
1387  climate.SERVICE_SET_PRESET_MODE,
1388  {
1389  climate.ATTR_PRESET_MODE: self.google_to_presetgoogle_to_preset[target_mode],
1390  ATTR_ENTITY_ID: self.statestate.entity_id,
1391  },
1392  blocking=not self.configconfig.should_report_state,
1393  context=data.context,
1394  )
1395  return
1396 
1397  await self.hasshass.services.async_call(
1398  climate.DOMAIN,
1399  climate.SERVICE_SET_HVAC_MODE,
1400  {
1401  ATTR_ENTITY_ID: self.statestate.entity_id,
1402  climate.ATTR_HVAC_MODE: self.google_to_hvacgoogle_to_hvac[target_mode],
1403  },
1404  blocking=not self.configconfig.should_report_state,
1405  context=data.context,
1406  )
1407 
1408 
1409 @register_trait
1411  """Trait to offer humidity setting functionality.
1412 
1413  https://developers.google.com/actions/smarthome/traits/humiditysetting
1414  """
1415 
1416  name = TRAIT_HUMIDITY_SETTING
1417  commands = [COMMAND_SET_HUMIDITY]
1418 
1419  @staticmethod
1420  def supported(domain, features, device_class, _):
1421  """Test if state is supported."""
1422  if domain == humidifier.DOMAIN:
1423  return True
1424 
1425  return (
1426  domain == sensor.DOMAIN
1427  and device_class == sensor.SensorDeviceClass.HUMIDITY
1428  )
1429 
1430  def sync_attributes(self) -> dict[str, Any]:
1431  """Return humidity attributes for a sync request."""
1432  response: dict[str, Any] = {}
1433  attrs = self.statestate.attributes
1434  domain = self.statestate.domain
1435 
1436  if domain == sensor.DOMAIN:
1437  device_class = attrs.get(ATTR_DEVICE_CLASS)
1438  if device_class == sensor.SensorDeviceClass.HUMIDITY:
1439  response["queryOnlyHumiditySetting"] = True
1440 
1441  elif domain == humidifier.DOMAIN:
1442  response["humiditySetpointRange"] = {
1443  "minPercent": round(
1444  float(self.statestate.attributes[humidifier.ATTR_MIN_HUMIDITY])
1445  ),
1446  "maxPercent": round(
1447  float(self.statestate.attributes[humidifier.ATTR_MAX_HUMIDITY])
1448  ),
1449  }
1450 
1451  return response
1452 
1453  def query_attributes(self) -> dict[str, Any]:
1454  """Return humidity query attributes."""
1455  response = {}
1456  attrs = self.statestate.attributes
1457  domain = self.statestate.domain
1458 
1459  if domain == sensor.DOMAIN:
1460  device_class = attrs.get(ATTR_DEVICE_CLASS)
1461  if device_class == sensor.SensorDeviceClass.HUMIDITY:
1462  humidity_state = self.statestate.state
1463  if humidity_state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
1464  response["humidityAmbientPercent"] = round(float(humidity_state))
1465 
1466  elif domain == humidifier.DOMAIN:
1467  target_humidity: int | None = attrs.get(humidifier.ATTR_HUMIDITY)
1468  if target_humidity is not None:
1469  response["humiditySetpointPercent"] = target_humidity
1470  current_humidity: int | None = attrs.get(humidifier.ATTR_CURRENT_HUMIDITY)
1471  if current_humidity is not None:
1472  response["humidityAmbientPercent"] = current_humidity
1473 
1474  return response
1475 
1476  async def execute(self, command, data, params, challenge):
1477  """Execute a humidity command."""
1478  if self.statestate.domain == sensor.DOMAIN:
1479  raise SmartHomeError(
1480  ERR_NOT_SUPPORTED, "Execute is not supported by sensor"
1481  )
1482 
1483  if command == COMMAND_SET_HUMIDITY:
1484  await self.hasshass.services.async_call(
1485  humidifier.DOMAIN,
1486  humidifier.SERVICE_SET_HUMIDITY,
1487  {
1488  ATTR_ENTITY_ID: self.statestate.entity_id,
1489  humidifier.ATTR_HUMIDITY: params["humidity"],
1490  },
1491  blocking=not self.configconfig.should_report_state,
1492  context=data.context,
1493  )
1494 
1495 
1496 @register_trait
1498  """Trait to lock or unlock a lock.
1499 
1500  https://developers.google.com/actions/smarthome/traits/lockunlock
1501  """
1502 
1503  name = TRAIT_LOCK_UNLOCK
1504  commands = [COMMAND_LOCK_UNLOCK]
1505 
1506  @staticmethod
1507  def supported(domain, features, device_class, _):
1508  """Test if state is supported."""
1509  return domain == lock.DOMAIN
1510 
1511  @staticmethod
1512  def might_2fa(domain, features, device_class):
1513  """Return if the trait might ask for 2FA."""
1514  return True
1515 
1516  def sync_attributes(self) -> dict[str, Any]:
1517  """Return LockUnlock attributes for a sync request."""
1518  return {}
1519 
1520  def query_attributes(self) -> dict[str, Any]:
1521  """Return LockUnlock query attributes."""
1522  if self.statestate.state == LockState.JAMMED:
1523  return {"isJammed": True}
1524 
1525  # If its unlocking its not yet unlocked so we consider is locked
1526  return {"isLocked": self.statestate.state in (LockState.UNLOCKING, LockState.LOCKED)}
1527 
1528  async def execute(self, command, data, params, challenge):
1529  """Execute an LockUnlock command."""
1530  if params["lock"]:
1531  service = lock.SERVICE_LOCK
1532  else:
1533  _verify_pin_challenge(data, self.statestate, challenge)
1534  service = lock.SERVICE_UNLOCK
1535 
1536  await self.hasshass.services.async_call(
1537  lock.DOMAIN,
1538  service,
1539  {ATTR_ENTITY_ID: self.statestate.entity_id},
1540  blocking=not self.configconfig.should_report_state,
1541  context=data.context,
1542  )
1543 
1544 
1545 @register_trait
1547  """Trait to Arm or Disarm a Security System.
1548 
1549  https://developers.google.com/actions/smarthome/traits/armdisarm
1550  """
1551 
1552  name = TRAIT_ARM_DISARM
1553  commands = [COMMAND_ARM_DISARM]
1554 
1555  state_to_service = {
1556  AlarmControlPanelState.ARMED_HOME: SERVICE_ALARM_ARM_HOME,
1557  AlarmControlPanelState.ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT,
1558  AlarmControlPanelState.ARMED_AWAY: SERVICE_ALARM_ARM_AWAY,
1559  AlarmControlPanelState.ARMED_CUSTOM_BYPASS: SERVICE_ALARM_ARM_CUSTOM_BYPASS,
1560  AlarmControlPanelState.TRIGGERED: SERVICE_ALARM_TRIGGER,
1561  }
1562 
1563  state_to_support = {
1564  AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME,
1565  AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT,
1566  AlarmControlPanelState.ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY,
1567  AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS,
1568  AlarmControlPanelState.TRIGGERED: AlarmControlPanelEntityFeature.TRIGGER,
1569  }
1570  """The list of states to support in increasing security state."""
1571 
1572  @staticmethod
1573  def supported(domain, features, device_class, _):
1574  """Test if state is supported."""
1575  return domain == alarm_control_panel.DOMAIN
1576 
1577  @staticmethod
1578  def might_2fa(domain, features, device_class):
1579  """Return if the trait might ask for 2FA."""
1580  return True
1581 
1583  """Return supported states."""
1584  features = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
1585  return [
1586  state
1587  for state, required_feature in self.state_to_supportstate_to_support.items()
1588  if features & required_feature != 0
1589  ]
1590 
1592  states = self._supported_states_supported_states()
1593 
1594  if AlarmControlPanelState.TRIGGERED in states:
1595  states.remove(AlarmControlPanelState.TRIGGERED)
1596 
1597  if not states:
1598  raise SmartHomeError(ERR_NOT_SUPPORTED, "ArmLevel missing")
1599 
1600  return states[0]
1601 
1602  def sync_attributes(self) -> dict[str, Any]:
1603  """Return ArmDisarm attributes for a sync request."""
1604  response = {}
1605  levels = []
1606  for state in self._supported_states_supported_states():
1607  # level synonyms are generated from state names
1608  # 'armed_away' becomes 'armed away' or 'away'
1609  level_synonym = [state.replace("_", " ")]
1610  if state != AlarmControlPanelState.TRIGGERED:
1611  level_synonym.append(state.split("_")[1])
1612 
1613  level = {
1614  "level_name": state,
1615  "level_values": [{"level_synonym": level_synonym, "lang": "en"}],
1616  }
1617  levels.append(level)
1618 
1619  response["availableArmLevels"] = {"levels": levels, "ordered": True}
1620  return response
1621 
1622  def query_attributes(self) -> dict[str, Any]:
1623  """Return ArmDisarm query attributes."""
1624  armed_state = self.statestate.attributes.get("next_state", self.statestate.state)
1625 
1626  if armed_state in self.state_to_servicestate_to_service:
1627  return {"isArmed": True, "currentArmLevel": armed_state}
1628  return {
1629  "isArmed": False,
1630  "currentArmLevel": self._default_arm_state_default_arm_state(),
1631  }
1632 
1633  async def execute(self, command, data, params, challenge):
1634  """Execute an ArmDisarm command."""
1635  if params["arm"] and not params.get("cancel"):
1636  # If no arm level given, we we arm the first supported
1637  # level in state_to_support.
1638  if not (arm_level := params.get("armLevel")):
1639  arm_level = self._default_arm_state_default_arm_state()
1640 
1641  if self.statestate.state == arm_level:
1642  raise SmartHomeError(ERR_ALREADY_ARMED, "System is already armed")
1643  if self.statestate.attributes["code_arm_required"]:
1644  _verify_pin_challenge(data, self.statestate, challenge)
1645  service = self.state_to_servicestate_to_service[arm_level]
1646  # disarm the system without asking for code when
1647  # 'cancel' arming action is received while current status is pending
1648  elif (
1649  params["arm"]
1650  and params.get("cancel")
1651  and self.statestate.state == AlarmControlPanelState.PENDING
1652  ):
1653  service = SERVICE_ALARM_DISARM
1654  else:
1655  if self.statestate.state == AlarmControlPanelState.DISARMED:
1656  raise SmartHomeError(ERR_ALREADY_DISARMED, "System is already disarmed")
1657  _verify_pin_challenge(data, self.statestate, challenge)
1658  service = SERVICE_ALARM_DISARM
1659 
1660  await self.hasshass.services.async_call(
1661  alarm_control_panel.DOMAIN,
1662  service,
1663  {
1664  ATTR_ENTITY_ID: self.statestate.entity_id,
1665  ATTR_CODE: data.config.secure_devices_pin,
1666  },
1667  blocking=not self.configconfig.should_report_state,
1668  context=data.context,
1669  )
1670 
1671 
1672 def _get_fan_speed(speed_name: str) -> dict[str, Any]:
1673  """Return a fan speed synonyms for a speed name."""
1674  speed_synonyms = FAN_SPEEDS.get(speed_name, [f"{speed_name}"])
1675  return {
1676  "speed_name": speed_name,
1677  "speed_values": [
1678  {
1679  "speed_synonym": speed_synonyms,
1680  "lang": "en",
1681  }
1682  ],
1683  }
1684 
1685 
1686 @register_trait
1688  """Trait to control speed of Fan.
1689 
1690  https://developers.google.com/actions/smarthome/traits/fanspeed
1691  """
1692 
1693  name = TRAIT_FAN_SPEED
1694  commands = [COMMAND_SET_FAN_SPEED, COMMAND_REVERSE]
1695 
1696  def __init__(self, hass, state, config):
1697  """Initialize a trait for a state."""
1698  super().__init__(hass, state, config)
1699  if state.domain == fan.DOMAIN:
1700  speed_count = min(
1701  FAN_SPEED_MAX_SPEED_COUNT,
1702  round(
1703  100 / (self.statestate.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
1704  ),
1705  )
1706  self._ordered_speed_ordered_speed = [
1707  f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
1708  ]
1709 
1710  @staticmethod
1711  def supported(domain, features, device_class, _):
1712  """Test if state is supported."""
1713  if domain == fan.DOMAIN:
1714  return features & FanEntityFeature.SET_SPEED
1715  if domain == climate.DOMAIN:
1716  return features & ClimateEntityFeature.FAN_MODE
1717  return False
1718 
1719  def sync_attributes(self) -> dict[str, Any]:
1720  """Return speed point and modes attributes for a sync request."""
1721  domain = self.statestate.domain
1722  speeds = []
1723  result: dict[str, Any] = {}
1724 
1725  if domain == fan.DOMAIN:
1726  reversible = bool(
1727  self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
1728  & FanEntityFeature.DIRECTION
1729  )
1730 
1731  result.update(
1732  {
1733  "reversible": reversible,
1734  "supportsFanSpeedPercent": True,
1735  }
1736  )
1737 
1738  if self._ordered_speed_ordered_speed:
1739  result.update(
1740  {
1741  "availableFanSpeeds": {
1742  "speeds": [
1743  _get_fan_speed(speed) for speed in self._ordered_speed_ordered_speed
1744  ],
1745  "ordered": True,
1746  },
1747  }
1748  )
1749 
1750  elif domain == climate.DOMAIN:
1751  modes = self.statestate.attributes.get(climate.ATTR_FAN_MODES) or []
1752  for mode in modes:
1753  speed = {
1754  "speed_name": mode,
1755  "speed_values": [{"speed_synonym": [mode], "lang": "en"}],
1756  }
1757  speeds.append(speed)
1758 
1759  result.update(
1760  {
1761  "reversible": False,
1762  "availableFanSpeeds": {"speeds": speeds, "ordered": True},
1763  }
1764  )
1765 
1766  return result
1767 
1768  def query_attributes(self) -> dict[str, Any]:
1769  """Return speed point and modes query attributes."""
1770 
1771  attrs = self.statestate.attributes
1772  domain = self.statestate.domain
1773  response = {}
1774  if domain == climate.DOMAIN:
1775  speed = attrs.get(climate.ATTR_FAN_MODE) or "off"
1776  response["currentFanSpeedSetting"] = speed
1777 
1778  if domain == fan.DOMAIN:
1779  percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
1780  response["currentFanSpeedPercent"] = percent
1781  response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
1782  self._ordered_speed_ordered_speed, percent
1783  )
1784 
1785  return response
1786 
1787  async def execute_fanspeed(self, data, params):
1788  """Execute an SetFanSpeed command."""
1789  domain = self.statestate.domain
1790  if domain == climate.DOMAIN:
1791  await self.hasshass.services.async_call(
1792  climate.DOMAIN,
1793  climate.SERVICE_SET_FAN_MODE,
1794  {
1795  ATTR_ENTITY_ID: self.statestate.entity_id,
1796  climate.ATTR_FAN_MODE: params["fanSpeed"],
1797  },
1798  blocking=not self.configconfig.should_report_state,
1799  context=data.context,
1800  )
1801 
1802  if domain == fan.DOMAIN:
1803  if fan_speed := params.get("fanSpeed"):
1804  fan_speed_percent = ordered_list_item_to_percentage(
1805  self._ordered_speed_ordered_speed, fan_speed
1806  )
1807  else:
1808  fan_speed_percent = params.get("fanSpeedPercent")
1809 
1810  await self.hasshass.services.async_call(
1811  fan.DOMAIN,
1812  fan.SERVICE_SET_PERCENTAGE,
1813  {
1814  ATTR_ENTITY_ID: self.statestate.entity_id,
1815  fan.ATTR_PERCENTAGE: fan_speed_percent,
1816  },
1817  blocking=not self.configconfig.should_report_state,
1818  context=data.context,
1819  )
1820 
1821  async def execute_reverse(self, data, params):
1822  """Execute a Reverse command."""
1823  if self.statestate.domain == fan.DOMAIN:
1824  if self.statestate.attributes.get(fan.ATTR_DIRECTION) == fan.DIRECTION_FORWARD:
1825  direction = fan.DIRECTION_REVERSE
1826  else:
1827  direction = fan.DIRECTION_FORWARD
1828 
1829  await self.hasshass.services.async_call(
1830  fan.DOMAIN,
1831  fan.SERVICE_SET_DIRECTION,
1832  {ATTR_ENTITY_ID: self.statestate.entity_id, fan.ATTR_DIRECTION: direction},
1833  blocking=not self.configconfig.should_report_state,
1834  context=data.context,
1835  )
1836 
1837  async def execute(self, command, data, params, challenge):
1838  """Execute a smart home command."""
1839  if command == COMMAND_SET_FAN_SPEED:
1840  await self.execute_fanspeedexecute_fanspeed(data, params)
1841  elif command == COMMAND_REVERSE:
1842  await self.execute_reverseexecute_reverse(data, params)
1843 
1844 
1845 @register_trait
1847  """Trait to set modes.
1848 
1849  https://developers.google.com/actions/smarthome/traits/modes
1850  """
1851 
1852  name = TRAIT_MODES
1853  commands = [COMMAND_SET_MODES]
1854 
1855  SYNONYMS = {
1856  "preset mode": ["preset mode", "mode", "preset"],
1857  "sound mode": ["sound mode", "effects"],
1858  "option": ["option", "setting", "mode", "value"],
1859  }
1860 
1861  @staticmethod
1862  def supported(domain, features, device_class, _):
1863  """Test if state is supported."""
1864  if domain == fan.DOMAIN and features & FanEntityFeature.PRESET_MODE:
1865  return True
1866 
1867  if domain == input_select.DOMAIN:
1868  return True
1869 
1870  if domain == select.DOMAIN:
1871  return True
1872 
1873  if domain == humidifier.DOMAIN and features & HumidifierEntityFeature.MODES:
1874  return True
1875 
1876  if domain == light.DOMAIN and features & LightEntityFeature.EFFECT:
1877  return True
1878 
1879  if (
1880  domain == water_heater.DOMAIN
1881  and features & WaterHeaterEntityFeature.OPERATION_MODE
1882  ):
1883  return True
1884 
1885  if domain != media_player.DOMAIN:
1886  return False
1887 
1888  return features & MediaPlayerEntityFeature.SELECT_SOUND_MODE
1889 
1890  def _generate(self, name, settings):
1891  """Generate a list of modes."""
1892  mode = {
1893  "name": name,
1894  "name_values": [
1895  {"name_synonym": self.SYNONYMSSYNONYMS.get(name, [name]), "lang": "en"}
1896  ],
1897  "settings": [],
1898  "ordered": False,
1899  }
1900  for setting in settings:
1901  mode["settings"].append(
1902  {
1903  "setting_name": setting,
1904  "setting_values": [
1905  {
1906  "setting_synonym": self.SYNONYMSSYNONYMS.get(setting, [setting]),
1907  "lang": "en",
1908  }
1909  ],
1910  }
1911  )
1912  return mode
1913 
1914  def sync_attributes(self) -> dict[str, Any]:
1915  """Return mode attributes for a sync request."""
1916  modes = []
1917 
1918  for domain, attr, name in (
1919  (fan.DOMAIN, fan.ATTR_PRESET_MODES, "preset mode"),
1920  (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"),
1921  (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"),
1922  (select.DOMAIN, select.ATTR_OPTIONS, "option"),
1923  (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"),
1924  (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"),
1925  (water_heater.DOMAIN, water_heater.ATTR_OPERATION_LIST, "operation mode"),
1926  ):
1927  if self.statestate.domain != domain:
1928  continue
1929 
1930  if (items := self.statestate.attributes.get(attr)) is not None:
1931  modes.append(self._generate_generate(name, items))
1932 
1933  # Shortcut since all domains are currently unique
1934  break
1935 
1936  return {"availableModes": modes}
1937 
1938  def query_attributes(self) -> dict[str, Any]:
1939  """Return current modes."""
1940  attrs = self.statestate.attributes
1941  response: dict[str, Any] = {}
1942  mode_settings = {}
1943 
1944  if self.statestate.domain == fan.DOMAIN:
1945  if fan.ATTR_PRESET_MODES in attrs:
1946  mode_settings["preset mode"] = attrs.get(fan.ATTR_PRESET_MODE)
1947  elif self.statestate.domain == media_player.DOMAIN:
1948  if media_player.ATTR_SOUND_MODE_LIST in attrs:
1949  mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE)
1950  elif self.statestate.domain in (input_select.DOMAIN, select.DOMAIN):
1951  mode_settings["option"] = self.statestate.state
1952  elif self.statestate.domain == humidifier.DOMAIN:
1953  if ATTR_MODE in attrs:
1954  mode_settings["mode"] = attrs.get(ATTR_MODE)
1955  elif self.statestate.domain == water_heater.DOMAIN:
1956  if water_heater.ATTR_OPERATION_MODE in attrs:
1957  mode_settings["operation mode"] = attrs.get(
1958  water_heater.ATTR_OPERATION_MODE
1959  )
1960  elif self.statestate.domain == light.DOMAIN and (
1961  effect := attrs.get(light.ATTR_EFFECT)
1962  ):
1963  mode_settings["effect"] = effect
1964 
1965  if mode_settings:
1966  response["on"] = self.statestate.state not in (STATE_OFF, STATE_UNKNOWN)
1967  response["currentModeSettings"] = mode_settings
1968 
1969  return response
1970 
1971  async def execute(self, command, data, params, challenge):
1972  """Execute a SetModes command."""
1973  settings = params.get("updateModeSettings")
1974 
1975  if self.statestate.domain == fan.DOMAIN:
1976  preset_mode = settings["preset mode"]
1977  await self.hasshass.services.async_call(
1978  fan.DOMAIN,
1979  fan.SERVICE_SET_PRESET_MODE,
1980  {
1981  ATTR_ENTITY_ID: self.statestate.entity_id,
1982  fan.ATTR_PRESET_MODE: preset_mode,
1983  },
1984  blocking=not self.configconfig.should_report_state,
1985  context=data.context,
1986  )
1987  return
1988 
1989  if self.statestate.domain == input_select.DOMAIN:
1990  option = settings["option"]
1991  await self.hasshass.services.async_call(
1992  input_select.DOMAIN,
1993  input_select.SERVICE_SELECT_OPTION,
1994  {
1995  ATTR_ENTITY_ID: self.statestate.entity_id,
1996  input_select.ATTR_OPTION: option,
1997  },
1998  blocking=not self.configconfig.should_report_state,
1999  context=data.context,
2000  )
2001  return
2002 
2003  if self.statestate.domain == select.DOMAIN:
2004  option = settings["option"]
2005  await self.hasshass.services.async_call(
2006  select.DOMAIN,
2007  select.SERVICE_SELECT_OPTION,
2008  {
2009  ATTR_ENTITY_ID: self.statestate.entity_id,
2010  select.ATTR_OPTION: option,
2011  },
2012  blocking=not self.configconfig.should_report_state,
2013  context=data.context,
2014  )
2015  return
2016 
2017  if self.statestate.domain == humidifier.DOMAIN:
2018  requested_mode = settings["mode"]
2019  await self.hasshass.services.async_call(
2020  humidifier.DOMAIN,
2021  humidifier.SERVICE_SET_MODE,
2022  {
2023  ATTR_MODE: requested_mode,
2024  ATTR_ENTITY_ID: self.statestate.entity_id,
2025  },
2026  blocking=not self.configconfig.should_report_state,
2027  context=data.context,
2028  )
2029  return
2030 
2031  if self.statestate.domain == water_heater.DOMAIN:
2032  requested_mode = settings["operation mode"]
2033  await self.hasshass.services.async_call(
2034  water_heater.DOMAIN,
2035  water_heater.SERVICE_SET_OPERATION_MODE,
2036  {
2037  water_heater.ATTR_OPERATION_MODE: requested_mode,
2038  ATTR_ENTITY_ID: self.statestate.entity_id,
2039  },
2040  blocking=not self.configconfig.should_report_state,
2041  context=data.context,
2042  )
2043  return
2044 
2045  if self.statestate.domain == light.DOMAIN:
2046  requested_effect = settings["effect"]
2047  await self.hasshass.services.async_call(
2048  light.DOMAIN,
2049  SERVICE_TURN_ON,
2050  {
2051  ATTR_ENTITY_ID: self.statestate.entity_id,
2052  light.ATTR_EFFECT: requested_effect,
2053  },
2054  blocking=not self.configconfig.should_report_state,
2055  context=data.context,
2056  )
2057  return
2058 
2059  if self.statestate.domain == media_player.DOMAIN and (
2060  sound_mode := settings.get("sound mode")
2061  ):
2062  await self.hasshass.services.async_call(
2063  media_player.DOMAIN,
2064  media_player.SERVICE_SELECT_SOUND_MODE,
2065  {
2066  ATTR_ENTITY_ID: self.statestate.entity_id,
2067  media_player.ATTR_SOUND_MODE: sound_mode,
2068  },
2069  blocking=not self.configconfig.should_report_state,
2070  context=data.context,
2071  )
2072 
2073  _LOGGER.info(
2074  "Received an Options command for unrecognised domain %s",
2075  self.statestate.domain,
2076  )
2077  return
2078 
2079 
2080 @register_trait
2082  """Trait to set modes.
2083 
2084  https://developers.google.com/assistant/smarthome/traits/inputselector
2085  """
2086 
2087  name = TRAIT_INPUT_SELECTOR
2088  commands = [COMMAND_SET_INPUT, COMMAND_NEXT_INPUT, COMMAND_PREVIOUS_INPUT]
2089 
2090  SYNONYMS: dict[str, list[str]] = {}
2091 
2092  @staticmethod
2093  def supported(domain, features, device_class, _):
2094  """Test if state is supported."""
2095  if domain == media_player.DOMAIN and (
2096  features & MediaPlayerEntityFeature.SELECT_SOURCE
2097  ):
2098  return True
2099 
2100  return False
2101 
2102  def sync_attributes(self) -> dict[str, Any]:
2103  """Return mode attributes for a sync request."""
2104  attrs = self.statestate.attributes
2105  sourcelist: list[str] = attrs.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
2106  inputs = [
2107  {"key": source, "names": [{"name_synonym": [source], "lang": "en"}]}
2108  for source in sourcelist
2109  ]
2110 
2111  return {"availableInputs": inputs, "orderedInputs": True}
2112 
2113  def query_attributes(self) -> dict[str, Any]:
2114  """Return current modes."""
2115  attrs = self.statestate.attributes
2116  return {"currentInput": attrs.get(media_player.ATTR_INPUT_SOURCE, "")}
2117 
2118  async def execute(self, command, data, params, challenge):
2119  """Execute an SetInputSource command."""
2120  sources = self.statestate.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
2121  source = self.statestate.attributes.get(media_player.ATTR_INPUT_SOURCE)
2122 
2123  if command == COMMAND_SET_INPUT:
2124  requested_source = params.get("newInput")
2125  elif command == COMMAND_NEXT_INPUT:
2126  requested_source = _next_selected(sources, source)
2127  elif command == COMMAND_PREVIOUS_INPUT:
2128  requested_source = _next_selected(list(reversed(sources)), source)
2129  else:
2130  raise SmartHomeError(ERR_NOT_SUPPORTED, "Unsupported command")
2131 
2132  if requested_source not in sources:
2133  raise SmartHomeError(ERR_UNSUPPORTED_INPUT, "Unsupported input")
2134 
2135  await self.hasshass.services.async_call(
2136  media_player.DOMAIN,
2137  media_player.SERVICE_SELECT_SOURCE,
2138  {
2139  ATTR_ENTITY_ID: self.statestate.entity_id,
2140  media_player.ATTR_INPUT_SOURCE: requested_source,
2141  },
2142  blocking=not self.configconfig.should_report_state,
2143  context=data.context,
2144  )
2145 
2146 
2147 @register_trait
2149  """Trait to open and close a cover.
2150 
2151  https://developers.google.com/actions/smarthome/traits/openclose
2152  """
2153 
2154  # Cover device classes that require 2FA
2155  COVER_2FA = (
2156  cover.CoverDeviceClass.DOOR,
2157  cover.CoverDeviceClass.GARAGE,
2158  cover.CoverDeviceClass.GATE,
2159  )
2160 
2161  name = TRAIT_OPEN_CLOSE
2162  commands = [COMMAND_OPEN_CLOSE, COMMAND_OPEN_CLOSE_RELATIVE]
2163 
2164  @staticmethod
2165  def supported(domain, features, device_class, _):
2166  """Test if state is supported."""
2167  if domain in COVER_VALVE_DOMAINS:
2168  return True
2169 
2170  return domain == binary_sensor.DOMAIN and device_class in (
2171  binary_sensor.BinarySensorDeviceClass.DOOR,
2172  binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR,
2173  binary_sensor.BinarySensorDeviceClass.LOCK,
2174  binary_sensor.BinarySensorDeviceClass.OPENING,
2175  binary_sensor.BinarySensorDeviceClass.WINDOW,
2176  )
2177 
2178  @staticmethod
2179  def might_2fa(domain, features, device_class):
2180  """Return if the trait might ask for 2FA."""
2181  return domain == cover.DOMAIN and device_class in OpenCloseTrait.COVER_2FA
2182 
2183  def sync_attributes(self) -> dict[str, Any]:
2184  """Return opening direction."""
2185  response = {}
2186  features = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2187 
2188  if self.statestate.domain == binary_sensor.DOMAIN:
2189  response["queryOnlyOpenClose"] = True
2190  response["discreteOnlyOpenClose"] = True
2191  elif (
2192  self.statestate.domain == cover.DOMAIN
2193  and features & CoverEntityFeature.SET_POSITION == 0
2194  ):
2195  response["discreteOnlyOpenClose"] = True
2196 
2197  if (
2198  features & CoverEntityFeature.OPEN == 0
2199  and features & CoverEntityFeature.CLOSE == 0
2200  ):
2201  response["queryOnlyOpenClose"] = True
2202  elif (
2203  self.statestate.domain == valve.DOMAIN
2204  and features & ValveEntityFeature.SET_POSITION == 0
2205  ):
2206  response["discreteOnlyOpenClose"] = True
2207 
2208  if (
2209  features & ValveEntityFeature.OPEN == 0
2210  and features & ValveEntityFeature.CLOSE == 0
2211  ):
2212  response["queryOnlyOpenClose"] = True
2213 
2214  if self.statestate.attributes.get(ATTR_ASSUMED_STATE):
2215  response["commandOnlyOpenClose"] = True
2216 
2217  return response
2218 
2219  def query_attributes(self) -> dict[str, Any]:
2220  """Return state query attributes."""
2221  domain = self.statestate.domain
2222  response: dict[str, Any] = {}
2223 
2224  # When it's an assumed state, we will return empty state
2225  # This shouldn't happen because we set `commandOnlyOpenClose`
2226  # but Google still queries. Erroring here will cause device
2227  # to show up offline.
2228  if self.statestate.attributes.get(ATTR_ASSUMED_STATE):
2229  return response
2230 
2231  if domain in COVER_VALVE_DOMAINS:
2232  if self.statestate.state == STATE_UNKNOWN:
2233  raise SmartHomeError(
2234  ERR_NOT_SUPPORTED, "Querying state is not supported"
2235  )
2236 
2237  position = self.statestate.attributes.get(COVER_VALVE_CURRENT_POSITION[domain])
2238 
2239  if position is not None:
2240  response["openPercent"] = position
2241  elif self.statestate.state != COVER_VALVE_STATES[domain]["closed"]:
2242  response["openPercent"] = 100
2243  else:
2244  response["openPercent"] = 0
2245 
2246  elif domain == binary_sensor.DOMAIN:
2247  if self.statestate.state == STATE_ON:
2248  response["openPercent"] = 100
2249  else:
2250  response["openPercent"] = 0
2251 
2252  return response
2253 
2254  async def execute(self, command, data, params, challenge):
2255  """Execute an Open, close, Set position command."""
2256  domain = self.statestate.domain
2257  features = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2258 
2259  if domain in COVER_VALVE_DOMAINS:
2260  svc_params = {ATTR_ENTITY_ID: self.statestate.entity_id}
2261  should_verify = False
2262  if command == COMMAND_OPEN_CLOSE_RELATIVE:
2263  position = self.statestate.attributes.get(
2264  COVER_VALVE_CURRENT_POSITION[domain]
2265  )
2266  if position is None:
2267  raise SmartHomeError(
2268  ERR_NOT_SUPPORTED,
2269  "Current position not know for relative command",
2270  )
2271  position = max(0, min(100, position + params["openRelativePercent"]))
2272  else:
2273  position = params["openPercent"]
2274 
2275  if position == 0:
2276  service = SERVICE_CLOSE_COVER_VALVE[domain]
2277  should_verify = False
2278  elif position == 100:
2279  service = SERVICE_OPEN_COVER_VALVE[domain]
2280  should_verify = True
2281  elif features & COVER_VALVE_SET_POSITION_FEATURE[domain]:
2282  service = SERVICE_SET_POSITION_COVER_VALVE[domain]
2283  if position > 0:
2284  should_verify = True
2285  svc_params[COVER_VALVE_POSITION[domain]] = position
2286  else:
2287  raise SmartHomeError(
2288  ERR_NOT_SUPPORTED, "No support for partial open close"
2289  )
2290 
2291  if (
2292  should_verify
2293  and self.statestate.attributes.get(ATTR_DEVICE_CLASS)
2294  in OpenCloseTrait.COVER_2FA
2295  ):
2296  _verify_pin_challenge(data, self.statestate, challenge)
2297 
2298  await self.hasshass.services.async_call(
2299  domain,
2300  service,
2301  svc_params,
2302  blocking=not self.configconfig.should_report_state,
2303  context=data.context,
2304  )
2305 
2306 
2307 @register_trait
2309  """Trait to control volume of a device.
2310 
2311  https://developers.google.com/actions/smarthome/traits/volume
2312  """
2313 
2314  name = TRAIT_VOLUME
2315  commands = [COMMAND_SET_VOLUME, COMMAND_VOLUME_RELATIVE, COMMAND_MUTE]
2316 
2317  @staticmethod
2318  def supported(domain, features, device_class, _):
2319  """Test if trait is supported."""
2320  if domain == media_player.DOMAIN:
2321  return features & (
2322  MediaPlayerEntityFeature.VOLUME_SET
2323  | MediaPlayerEntityFeature.VOLUME_STEP
2324  )
2325 
2326  return False
2327 
2328  def sync_attributes(self) -> dict[str, Any]:
2329  """Return volume attributes for a sync request."""
2330  features = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2331  return {
2332  "volumeCanMuteAndUnmute": bool(
2333  features & MediaPlayerEntityFeature.VOLUME_MUTE
2334  ),
2335  "commandOnlyVolume": self.statestate.attributes.get(ATTR_ASSUMED_STATE, False),
2336  # Volume amounts in SET_VOLUME and VOLUME_RELATIVE are on a scale
2337  # from 0 to this value.
2338  "volumeMaxLevel": 100,
2339  # Default change for queries like "Hey Google, volume up".
2340  # 10% corresponds to the default behavior for the
2341  # media_player.volume{up,down} services.
2342  "levelStepSize": 10,
2343  }
2344 
2345  def query_attributes(self) -> dict[str, Any]:
2346  """Return volume query attributes."""
2347  response = {}
2348 
2349  level = self.statestate.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)
2350  if level is not None:
2351  # Convert 0.0-1.0 to 0-100
2352  response["currentVolume"] = round(level * 100)
2353 
2354  muted = self.statestate.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED)
2355  if muted is not None:
2356  response["isMuted"] = bool(muted)
2357 
2358  return response
2359 
2360  async def _set_volume_absolute(self, data, level):
2361  await self.hasshass.services.async_call(
2362  media_player.DOMAIN,
2363  media_player.SERVICE_VOLUME_SET,
2364  {
2365  ATTR_ENTITY_ID: self.statestate.entity_id,
2366  media_player.ATTR_MEDIA_VOLUME_LEVEL: level,
2367  },
2368  blocking=not self.configconfig.should_report_state,
2369  context=data.context,
2370  )
2371 
2372  async def _execute_set_volume(self, data, params):
2373  level = max(0, min(100, params["volumeLevel"]))
2374 
2375  if not (
2376  self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2377  & MediaPlayerEntityFeature.VOLUME_SET
2378  ):
2379  raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
2380 
2381  await self._set_volume_absolute_set_volume_absolute(data, level / 100)
2382 
2383  async def _execute_volume_relative(self, data, params):
2384  relative = params["relativeSteps"]
2385  features = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2386 
2387  if features & MediaPlayerEntityFeature.VOLUME_SET:
2388  current = self.statestate.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)
2389  target = max(0.0, min(1.0, current + relative / 100))
2390 
2391  await self._set_volume_absolute_set_volume_absolute(data, target)
2392 
2393  elif features & MediaPlayerEntityFeature.VOLUME_STEP:
2394  svc = media_player.SERVICE_VOLUME_UP
2395  if relative < 0:
2396  svc = media_player.SERVICE_VOLUME_DOWN
2397  relative = -relative
2398 
2399  for _ in range(relative):
2400  await self.hasshass.services.async_call(
2401  media_player.DOMAIN,
2402  svc,
2403  {ATTR_ENTITY_ID: self.statestate.entity_id},
2404  blocking=not self.configconfig.should_report_state,
2405  context=data.context,
2406  )
2407  else:
2408  raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
2409 
2410  async def _execute_mute(self, data, params):
2411  mute = params["mute"]
2412 
2413  if not (
2414  self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2415  & MediaPlayerEntityFeature.VOLUME_MUTE
2416  ):
2417  raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
2418 
2419  await self.hasshass.services.async_call(
2420  media_player.DOMAIN,
2421  media_player.SERVICE_VOLUME_MUTE,
2422  {
2423  ATTR_ENTITY_ID: self.statestate.entity_id,
2424  media_player.ATTR_MEDIA_VOLUME_MUTED: mute,
2425  },
2426  blocking=not self.configconfig.should_report_state,
2427  context=data.context,
2428  )
2429 
2430  async def execute(self, command, data, params, challenge):
2431  """Execute a volume command."""
2432  if command == COMMAND_SET_VOLUME:
2433  await self._execute_set_volume_execute_set_volume(data, params)
2434  elif command == COMMAND_VOLUME_RELATIVE:
2435  await self._execute_volume_relative_execute_volume_relative(data, params)
2436  elif command == COMMAND_MUTE:
2437  await self._execute_mute_execute_mute(data, params)
2438  else:
2439  raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
2440 
2441 
2442 def _verify_pin_challenge(data, state, challenge):
2443  """Verify a pin challenge."""
2444  if not data.config.should_2fa(state):
2445  return
2446  if not data.config.secure_devices_pin:
2447  raise SmartHomeError(ERR_CHALLENGE_NOT_SETUP, "Challenge is not set up")
2448 
2449  if not challenge:
2450  raise ChallengeNeeded(CHALLENGE_PIN_NEEDED)
2451 
2452  if challenge.get("pin") != data.config.secure_devices_pin:
2453  raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED)
2454 
2455 
2456 MEDIA_COMMAND_SUPPORT_MAPPING = {
2457  COMMAND_MEDIA_NEXT: MediaPlayerEntityFeature.NEXT_TRACK,
2458  COMMAND_MEDIA_PAUSE: MediaPlayerEntityFeature.PAUSE,
2459  COMMAND_MEDIA_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK,
2460  COMMAND_MEDIA_RESUME: MediaPlayerEntityFeature.PLAY,
2461  COMMAND_MEDIA_SEEK_RELATIVE: MediaPlayerEntityFeature.SEEK,
2462  COMMAND_MEDIA_SEEK_TO_POSITION: MediaPlayerEntityFeature.SEEK,
2463  COMMAND_MEDIA_SHUFFLE: MediaPlayerEntityFeature.SHUFFLE_SET,
2464  COMMAND_MEDIA_STOP: MediaPlayerEntityFeature.STOP,
2465 }
2466 
2467 MEDIA_COMMAND_ATTRIBUTES = {
2468  COMMAND_MEDIA_NEXT: "NEXT",
2469  COMMAND_MEDIA_PAUSE: "PAUSE",
2470  COMMAND_MEDIA_PREVIOUS: "PREVIOUS",
2471  COMMAND_MEDIA_RESUME: "RESUME",
2472  COMMAND_MEDIA_SEEK_RELATIVE: "SEEK_RELATIVE",
2473  COMMAND_MEDIA_SEEK_TO_POSITION: "SEEK_TO_POSITION",
2474  COMMAND_MEDIA_SHUFFLE: "SHUFFLE",
2475  COMMAND_MEDIA_STOP: "STOP",
2476 }
2477 
2478 
2479 @register_trait
2481  """Trait to control media playback.
2482 
2483  https://developers.google.com/actions/smarthome/traits/transportcontrol
2484  """
2485 
2486  name = TRAIT_TRANSPORT_CONTROL
2487  commands = [
2488  COMMAND_MEDIA_NEXT,
2489  COMMAND_MEDIA_PAUSE,
2490  COMMAND_MEDIA_PREVIOUS,
2491  COMMAND_MEDIA_RESUME,
2492  COMMAND_MEDIA_SEEK_RELATIVE,
2493  COMMAND_MEDIA_SEEK_TO_POSITION,
2494  COMMAND_MEDIA_SHUFFLE,
2495  COMMAND_MEDIA_STOP,
2496  ]
2497 
2498  @staticmethod
2499  def supported(domain, features, device_class, _):
2500  """Test if state is supported."""
2501  if domain == media_player.DOMAIN:
2502  for feature in MEDIA_COMMAND_SUPPORT_MAPPING.values():
2503  if features & feature:
2504  return True
2505 
2506  return False
2507 
2508  def sync_attributes(self) -> dict[str, Any]:
2509  """Return opening direction."""
2510  response = {}
2511 
2512  if self.statestate.domain == media_player.DOMAIN:
2513  features = self.statestate.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
2514 
2515  support = []
2516  for command, feature in MEDIA_COMMAND_SUPPORT_MAPPING.items():
2517  if features & feature:
2518  support.append(MEDIA_COMMAND_ATTRIBUTES[command])
2519  response["transportControlSupportedCommands"] = support
2520 
2521  return response
2522 
2523  def query_attributes(self) -> dict[str, Any]:
2524  """Return the attributes of this trait for this entity."""
2525  return {}
2526 
2527  async def execute(self, command, data, params, challenge):
2528  """Execute a media command."""
2529  service_attrs = {ATTR_ENTITY_ID: self.statestate.entity_id}
2530 
2531  if command == COMMAND_MEDIA_SEEK_RELATIVE:
2532  service = media_player.SERVICE_MEDIA_SEEK
2533 
2534  rel_position = params["relativePositionMs"] / 1000
2535  seconds_since = 0 # Default to 0 seconds
2536  if self.statestate.state == STATE_PLAYING:
2537  now = dt_util.utcnow()
2538  upd_at = self.statestate.attributes.get(
2539  media_player.ATTR_MEDIA_POSITION_UPDATED_AT, now
2540  )
2541  seconds_since = (now - upd_at).total_seconds()
2542  position = self.statestate.attributes.get(media_player.ATTR_MEDIA_POSITION, 0)
2543  max_position = self.statestate.attributes.get(
2544  media_player.ATTR_MEDIA_DURATION, 0
2545  )
2546  service_attrs[media_player.ATTR_MEDIA_SEEK_POSITION] = min(
2547  max(position + seconds_since + rel_position, 0), max_position
2548  )
2549  elif command == COMMAND_MEDIA_SEEK_TO_POSITION:
2550  service = media_player.SERVICE_MEDIA_SEEK
2551 
2552  max_position = self.statestate.attributes.get(
2553  media_player.ATTR_MEDIA_DURATION, 0
2554  )
2555  service_attrs[media_player.ATTR_MEDIA_SEEK_POSITION] = min(
2556  max(params["absPositionMs"] / 1000, 0), max_position
2557  )
2558  elif command == COMMAND_MEDIA_NEXT:
2559  service = media_player.SERVICE_MEDIA_NEXT_TRACK
2560  elif command == COMMAND_MEDIA_PAUSE:
2561  service = media_player.SERVICE_MEDIA_PAUSE
2562  elif command == COMMAND_MEDIA_PREVIOUS:
2563  service = media_player.SERVICE_MEDIA_PREVIOUS_TRACK
2564  elif command == COMMAND_MEDIA_RESUME:
2565  service = media_player.SERVICE_MEDIA_PLAY
2566  elif command == COMMAND_MEDIA_SHUFFLE:
2567  service = media_player.SERVICE_SHUFFLE_SET
2568 
2569  # Google Assistant only supports enabling shuffle
2570  service_attrs[media_player.ATTR_MEDIA_SHUFFLE] = True
2571  elif command == COMMAND_MEDIA_STOP:
2572  service = media_player.SERVICE_MEDIA_STOP
2573  else:
2574  raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
2575 
2576  await self.hasshass.services.async_call(
2577  media_player.DOMAIN,
2578  service,
2579  service_attrs,
2580  blocking=not self.configconfig.should_report_state,
2581  context=data.context,
2582  )
2583 
2584 
2585 @register_trait
2587  """Trait to get media playback state.
2588 
2589  https://developers.google.com/actions/smarthome/traits/mediastate
2590  """
2591 
2592  name = TRAIT_MEDIA_STATE
2593  commands: list[str] = []
2594 
2595  activity_lookup = {
2596  STATE_OFF: "INACTIVE",
2597  STATE_IDLE: "STANDBY",
2598  STATE_PLAYING: "ACTIVE",
2599  STATE_ON: "STANDBY",
2600  STATE_PAUSED: "STANDBY",
2601  STATE_STANDBY: "STANDBY",
2602  STATE_UNAVAILABLE: "INACTIVE",
2603  STATE_UNKNOWN: "INACTIVE",
2604  }
2605 
2606  playback_lookup = {
2607  STATE_OFF: "STOPPED",
2608  STATE_IDLE: "STOPPED",
2609  STATE_PLAYING: "PLAYING",
2610  STATE_ON: "STOPPED",
2611  STATE_PAUSED: "PAUSED",
2612  STATE_STANDBY: "STOPPED",
2613  STATE_UNAVAILABLE: "STOPPED",
2614  STATE_UNKNOWN: "STOPPED",
2615  }
2616 
2617  @staticmethod
2618  def supported(domain, features, device_class, _):
2619  """Test if state is supported."""
2620  return domain == media_player.DOMAIN
2621 
2622  def sync_attributes(self) -> dict[str, Any]:
2623  """Return attributes for a sync request."""
2624  return {"supportActivityState": True, "supportPlaybackState": True}
2625 
2626  def query_attributes(self) -> dict[str, Any]:
2627  """Return the attributes of this trait for this entity."""
2628  return {
2629  "activityState": self.activity_lookupactivity_lookup.get(self.statestate.state, "INACTIVE"),
2630  "playbackState": self.playback_lookupplayback_lookup.get(self.statestate.state, "STOPPED"),
2631  }
2632 
2633 
2634 @register_trait
2636  """Trait to get media playback state.
2637 
2638  https://developers.google.com/actions/smarthome/traits/channel
2639  """
2640 
2641  name = TRAIT_CHANNEL
2642  commands = [COMMAND_SELECT_CHANNEL]
2643 
2644  @staticmethod
2645  def supported(domain, features, device_class, _):
2646  """Test if state is supported."""
2647  if (
2648  domain == media_player.DOMAIN
2649  and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
2650  and device_class == media_player.MediaPlayerDeviceClass.TV
2651  ):
2652  return True
2653 
2654  return False
2655 
2656  def sync_attributes(self) -> dict[str, Any]:
2657  """Return attributes for a sync request."""
2658  return {"availableChannels": [], "commandOnlyChannels": True}
2659 
2660  def query_attributes(self) -> dict[str, Any]:
2661  """Return channel query attributes."""
2662  return {}
2663 
2664  async def execute(self, command, data, params, challenge):
2665  """Execute an setChannel command."""
2666  if command == COMMAND_SELECT_CHANNEL:
2667  channel_number = params.get("channelNumber")
2668  else:
2669  raise SmartHomeError(ERR_NOT_SUPPORTED, "Unsupported command")
2670 
2671  if not channel_number:
2672  raise SmartHomeError(
2673  ERR_NO_AVAILABLE_CHANNEL,
2674  "Channel is not available",
2675  )
2676 
2677  await self.hasshass.services.async_call(
2678  media_player.DOMAIN,
2679  media_player.SERVICE_PLAY_MEDIA,
2680  {
2681  ATTR_ENTITY_ID: self.statestate.entity_id,
2682  media_player.ATTR_MEDIA_CONTENT_ID: channel_number,
2683  media_player.ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL,
2684  },
2685  blocking=not self.configconfig.should_report_state,
2686  context=data.context,
2687  )
2688 
2689 
2690 @register_trait
2692  """Trait to get sensor state.
2693 
2694  https://developers.google.com/actions/smarthome/traits/sensorstate
2695  """
2696 
2697  sensor_types = {
2698  sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"),
2699  sensor.SensorDeviceClass.CO: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"),
2700  sensor.SensorDeviceClass.CO2: ("CarbonDioxideLevel", "PARTS_PER_MILLION"),
2701  sensor.SensorDeviceClass.PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"),
2702  sensor.SensorDeviceClass.PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"),
2703  sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: (
2704  "VolatileOrganicCompounds",
2705  "PARTS_PER_MILLION",
2706  ),
2707  }
2708 
2709  binary_sensor_types = {
2710  binary_sensor.BinarySensorDeviceClass.CO: (
2711  "CarbonMonoxideLevel",
2712  ["carbon monoxide detected", "no carbon monoxide detected", "unknown"],
2713  ),
2714  binary_sensor.BinarySensorDeviceClass.SMOKE: (
2715  "SmokeLevel",
2716  ["smoke detected", "no smoke detected", "unknown"],
2717  ),
2718  binary_sensor.BinarySensorDeviceClass.MOISTURE: (
2719  "WaterLeak",
2720  ["leak", "no leak", "unknown"],
2721  ),
2722  }
2723 
2724  name = TRAIT_SENSOR_STATE
2725  commands: list[str] = []
2726 
2727  def _air_quality_description_for_aqi(self, aqi: float | None) -> str:
2728  if aqi is None or aqi < 0:
2729  return "unknown"
2730  if aqi <= 50:
2731  return "healthy"
2732  if aqi <= 100:
2733  return "moderate"
2734  if aqi <= 150:
2735  return "unhealthy for sensitive groups"
2736  if aqi <= 200:
2737  return "unhealthy"
2738  if aqi <= 300:
2739  return "very unhealthy"
2740 
2741  return "hazardous"
2742 
2743  @classmethod
2744  def supported(cls, domain, features, device_class, _):
2745  """Test if state is supported."""
2746  return (domain == sensor.DOMAIN and device_class in cls.sensor_typessensor_types) or (
2747  domain == binary_sensor.DOMAIN and device_class in cls.binary_sensor_typesbinary_sensor_types
2748  )
2749 
2750  def sync_attributes(self) -> dict[str, Any]:
2751  """Return attributes for a sync request."""
2752  device_class = self.statestate.attributes.get(ATTR_DEVICE_CLASS)
2753 
2754  def create_sensor_state(
2755  name: str,
2756  raw_value_unit: str | None = None,
2757  available_states: list[str] | None = None,
2758  ) -> dict[str, Any]:
2759  sensor_state: dict[str, Any] = {
2760  "name": name,
2761  }
2762  if raw_value_unit:
2763  sensor_state["numericCapabilities"] = {"rawValueUnit": raw_value_unit}
2764  if available_states:
2765  sensor_state["descriptiveCapabilities"] = {
2766  "availableStates": available_states
2767  }
2768  return {"sensorStatesSupported": [sensor_state]}
2769 
2770  if self.statestate.domain == sensor.DOMAIN:
2771  sensor_data = self.sensor_typessensor_types.get(device_class)
2772  if device_class is None or sensor_data is None:
2773  return {}
2774  available_states: list[str] | None = None
2775  if device_class == sensor.SensorDeviceClass.AQI:
2776  available_states = [
2777  "healthy",
2778  "moderate",
2779  "unhealthy for sensitive groups",
2780  "unhealthy",
2781  "very unhealthy",
2782  "hazardous",
2783  "unknown",
2784  ]
2785  return create_sensor_state(sensor_data[0], sensor_data[1], available_states)
2786  binary_sensor_data = self.binary_sensor_typesbinary_sensor_types.get(device_class)
2787  if device_class is None or binary_sensor_data is None:
2788  return {}
2789  return create_sensor_state(
2790  binary_sensor_data[0], available_states=binary_sensor_data[1]
2791  )
2792 
2793  def query_attributes(self) -> dict[str, Any]:
2794  """Return the attributes of this trait for this entity."""
2795  device_class = self.statestate.attributes.get(ATTR_DEVICE_CLASS)
2796 
2797  def create_sensor_state(
2798  name: str, raw_value: float | None = None, current_state: str | None = None
2799  ) -> dict[str, Any]:
2800  sensor_state: dict[str, Any] = {
2801  "name": name,
2802  "rawValue": raw_value,
2803  }
2804  if current_state:
2805  sensor_state["currentSensorState"] = current_state
2806  return {"currentSensorStateData": [sensor_state]}
2807 
2808  if self.statestate.domain == sensor.DOMAIN:
2809  sensor_data = self.sensor_typessensor_types.get(device_class)
2810  if device_class is None or sensor_data is None:
2811  return {}
2812  try:
2813  value = float(self.statestate.state)
2814  except ValueError:
2815  value = None
2816  if self.statestate.state == STATE_UNKNOWN:
2817  value = None
2818  current_state: str | None = None
2819  if device_class == sensor.SensorDeviceClass.AQI:
2820  current_state = self._air_quality_description_for_aqi_air_quality_description_for_aqi(value)
2821  return create_sensor_state(sensor_data[0], value, current_state)
2822 
2823  binary_sensor_data = self.binary_sensor_typesbinary_sensor_types.get(device_class)
2824  if device_class is None or binary_sensor_data is None:
2825  return {}
2826  value = {
2827  STATE_ON: 0,
2828  STATE_OFF: 1,
2829  STATE_UNKNOWN: 2,
2830  }[self.statestate.state]
2831  return create_sensor_state(
2832  binary_sensor_data[0], current_state=binary_sensor_data[1][value]
2833  )
def execute(self, command, data, params, challenge)
Definition: trait.py:1633
def supported(domain, features, device_class, _)
Definition: trait.py:1573
def might_2fa(domain, features, device_class)
Definition: trait.py:1578
def supported(domain, features, device_class, attributes)
Definition: trait.py:327
def execute(self, command, data, params, challenge)
Definition: trait.py:351
def execute(self, command, data, params, challenge)
Definition: trait.py:398
def supported(domain, features, device_class, _)
Definition: trait.py:379
def supported(domain, features, device_class, _)
Definition: trait.py:2645
def execute(self, command, data, params, challenge)
Definition: trait.py:2664
def execute(self, command, data, params, challenge)
Definition: trait.py:606
def supported(domain, features, device_class, attributes)
Definition: trait.py:536
def execute(self, command, data, params, challenge)
Definition: trait.py:734
def supported(domain, features, device_class, _)
Definition: trait.py:722
def execute(self, command, data, params, challenge)
Definition: trait.py:832
def supported(domain, features, device_class, _)
Definition: trait.py:796
def supported(domain, features, device_class, _)
Definition: trait.py:1711
def execute(self, command, data, params, challenge)
Definition: trait.py:1837
def execute(self, command, data, params, challenge)
Definition: trait.py:1476
def supported(domain, features, device_class, _)
Definition: trait.py:2093
def execute(self, command, data, params, challenge)
Definition: trait.py:2118
def execute(self, command, data, params, challenge)
Definition: trait.py:768
def supported(domain, features, device_class, _)
Definition: trait.py:756
def supported(domain, features, device_class, _)
Definition: trait.py:1507
def execute(self, command, data, params, challenge)
Definition: trait.py:1528
def supported(domain, features, device_class, _)
Definition: trait.py:2618
def execute(self, command, data, params, challenge)
Definition: trait.py:1971
def supported(domain, features, device_class, _)
Definition: trait.py:1862
def execute(self, command, data, params, challenge)
Definition: trait.py:461
bool supported(domain, features, device_class, _)
Definition: trait.py:418
def execute(self, command, data, params, challenge)
Definition: trait.py:506
def supported(domain, features, device_class, _)
Definition: trait.py:476
def might_2fa(domain, features, device_class)
Definition: trait.py:2179
def execute(self, command, data, params, challenge)
Definition: trait.py:2254
def supported(domain, features, device_class, _)
Definition: trait.py:2165
def supported(domain, features, device_class, _)
Definition: trait.py:673
def execute(self, command, data, params, challenge)
Definition: trait.py:691
def supported(cls, domain, features, device_class, _)
Definition: trait.py:2744
def execute(self, command, data, params, challenge)
Definition: trait.py:900
def _execute_cover_or_valve(self, command, data, params, challenge)
Definition: trait.py:947
def supported(domain, features, device_class, _)
Definition: trait.py:851
def _execute_vacuum(self, command, data, params, challenge)
Definition: trait.py:910
def execute(self, command, data, params, challenge)
Definition: trait.py:1088
def execute(self, command, data, params, challenge)
Definition: trait.py:1280
def execute(self, command, data, params, challenge)
Definition: trait.py:2527
def supported(domain, features, device_class, _)
Definition: trait.py:2318
def execute(self, command, data, params, challenge)
Definition: trait.py:2430
def execute(self, command, data, params, challenge)
Definition: trait.py:311
None __init__(self, HomeAssistant hass, state, config)
Definition: trait.py:286
def supported(domain, features, device_class, attributes)
Definition: trait.py:283
def might_2fa(domain, features, device_class)
Definition: trait.py:277
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str|None _next_selected(list[str] items, str|None selected)
Definition: trait.py:254
def _verify_pin_challenge(data, state, challenge)
Definition: trait.py:2442
dict[str, Any] _get_fan_speed(str speed_name)
Definition: trait.py:1672