Home Assistant Unofficial Reference 2024.12.1
device.py
Go to the documentation of this file.
1 """Adapter to wrap the rachiopy api for home assistant."""
2 
3 from __future__ import annotations
4 
5 from http import HTTPStatus
6 import logging
7 from typing import Any
8 
9 from rachiopy import Rachio
10 import voluptuous as vol
11 
12 from homeassistant.config_entries import ConfigEntry
13 from homeassistant.const import EVENT_HOMEASSISTANT_STOP
14 from homeassistant.core import HomeAssistant, ServiceCall
15 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
16 from homeassistant.helpers import config_validation as cv
17 
18 from .const import (
19  DOMAIN,
20  KEY_BASE_STATIONS,
21  KEY_DEVICES,
22  KEY_ENABLED,
23  KEY_EXTERNAL_ID,
24  KEY_FLEX_SCHEDULES,
25  KEY_ID,
26  KEY_MAC_ADDRESS,
27  KEY_MODEL,
28  KEY_NAME,
29  KEY_SCHEDULES,
30  KEY_SERIAL_NUMBER,
31  KEY_STATUS,
32  KEY_USERNAME,
33  KEY_ZONES,
34  LISTEN_EVENT_TYPES,
35  MODEL_GENERATION_1,
36  SERVICE_PAUSE_WATERING,
37  SERVICE_RESUME_WATERING,
38  SERVICE_STOP_WATERING,
39  WEBHOOK_CONST_ID,
40 )
41 from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 ATTR_DEVICES = "devices"
46 ATTR_DURATION = "duration"
47 PERMISSION_ERROR = "7"
48 
49 PAUSE_SERVICE_SCHEMA = vol.Schema(
50  {
51  vol.Optional(ATTR_DEVICES): cv.string,
52  vol.Optional(ATTR_DURATION, default=60): cv.positive_int,
53  }
54 )
55 
56 RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
57 
58 STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
59 
60 
62  """Represent a Rachio user."""
63 
64  def __init__(self, rachio: Rachio, config_entry: ConfigEntry) -> None:
65  """Create an object from the provided API instance."""
66  # Use API token to get user ID
67  self.rachiorachio = rachio
68  self.config_entryconfig_entry = config_entry
69  self.usernameusername = None
70  self._id_id: str | None = None
71  self._controllers: list[RachioIro] = []
72  self._base_stations: list[RachioBaseStation] = []
73 
74  async def async_setup(self, hass: HomeAssistant) -> None:
75  """Create rachio devices and services."""
76  await hass.async_add_executor_job(self._setup_setup, hass)
77  can_pause = False
78  for rachio_iro in self._controllers:
79  # Generation 1 controllers don't support pause or resume
80  if rachio_iro.model.split("_")[0] != MODEL_GENERATION_1:
81  can_pause = True
82  break
83 
84  all_controllers = [rachio_iro.name for rachio_iro in self._controllers]
85 
86  def pause_water(service: ServiceCall) -> None:
87  """Service to pause watering on all or specific controllers."""
88  duration = service.data[ATTR_DURATION]
89  devices = service.data.get(ATTR_DEVICES, all_controllers)
90  for iro in self._controllers:
91  if iro.name in devices:
92  iro.pause_watering(duration)
93 
94  def resume_water(service: ServiceCall) -> None:
95  """Service to resume watering on all or specific controllers."""
96  devices = service.data.get(ATTR_DEVICES, all_controllers)
97  for iro in self._controllers:
98  if iro.name in devices:
99  iro.resume_watering()
100 
101  def stop_water(service: ServiceCall) -> None:
102  """Service to stop watering on all or specific controllers."""
103  devices = service.data.get(ATTR_DEVICES, all_controllers)
104  for iro in self._controllers:
105  if iro.name in devices:
106  iro.stop_watering()
107 
108  # If only hose timers on account, none of these services apply
109  if not all_controllers:
110  return
111 
112  hass.services.async_register(
113  DOMAIN,
114  SERVICE_STOP_WATERING,
115  stop_water,
116  schema=STOP_SERVICE_SCHEMA,
117  )
118 
119  if not can_pause:
120  return
121 
122  hass.services.async_register(
123  DOMAIN,
124  SERVICE_PAUSE_WATERING,
125  pause_water,
126  schema=PAUSE_SERVICE_SCHEMA,
127  )
128 
129  hass.services.async_register(
130  DOMAIN,
131  SERVICE_RESUME_WATERING,
132  resume_water,
133  schema=RESUME_SERVICE_SCHEMA,
134  )
135 
136  def _setup(self, hass: HomeAssistant) -> None:
137  """Rachio device setup."""
138  rachio = self.rachiorachio
139 
140  response = rachio.person.info()
141  if is_invalid_auth_code(int(response[0][KEY_STATUS])):
142  raise ConfigEntryAuthFailed(f"API key error: {response}")
143  if int(response[0][KEY_STATUS]) != HTTPStatus.OK:
144  raise ConfigEntryNotReady(f"API Error: {response}")
145  self._id_id = response[1][KEY_ID]
146 
147  # Use user ID to get user data
148  data = rachio.person.get(self._id_id)
149  if is_invalid_auth_code(int(data[0][KEY_STATUS])):
150  raise ConfigEntryAuthFailed(f"User ID error: {data}")
151  if int(data[0][KEY_STATUS]) != HTTPStatus.OK:
152  raise ConfigEntryNotReady(f"API Error: {data}")
153  self.usernameusername = data[1][KEY_USERNAME]
154  devices: list[dict[str, Any]] = data[1][KEY_DEVICES]
155  base_station_data = rachio.valve.list_base_stations(self._id_id)
156  base_stations: list[dict[str, Any]] = base_station_data[1][KEY_BASE_STATIONS]
157 
158  for controller in devices:
159  webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1]
160  # The API does not provide a way to tell if a controller is shared
161  # or if they are the owner. To work around this problem we fetch the webhooks
162  # before we setup the device so we can skip it instead of failing.
163  # webhooks are normally a list, however if there is an error
164  # rachio hands us back a dict
165  if isinstance(webhooks, dict):
166  if webhooks.get("code") == PERMISSION_ERROR:
167  _LOGGER.warning(
168  (
169  "Not adding controller '%s', only controllers owned by '%s'"
170  " may be added"
171  ),
172  controller[KEY_NAME],
173  self.usernameusername,
174  )
175  else:
176  _LOGGER.error(
177  "Failed to add rachio controller '%s' because of an error: %s",
178  controller[KEY_NAME],
179  webhooks.get("error", "Unknown Error"),
180  )
181  continue
182 
183  rachio_iro = RachioIro(hass, rachio, controller, webhooks)
184  rachio_iro.setup()
185  self._controllers.append(rachio_iro)
186 
187  base_count = len(base_stations)
188  self._base_stations.extend(
190  rachio,
191  base,
193  hass, rachio, self.config_entryconfig_entry, base, base_count
194  ),
195  RachioScheduleUpdateCoordinator(hass, rachio, self.config_entryconfig_entry, base),
196  )
197  for base in base_stations
198  )
199 
200  _LOGGER.debug('Using Rachio API as user "%s"', self.usernameusername)
201 
202  @property
203  def user_id(self) -> str | None:
204  """Get the user ID as defined by the Rachio API."""
205  return self._id_id
206 
207  @property
208  def controllers(self) -> list[RachioIro]:
209  """Get a list of controllers managed by this account."""
210  return self._controllers
211 
212  @property
213  def base_stations(self) -> list[RachioBaseStation]:
214  """List of smart hose timer base stations."""
215  return self._base_stations
216 
217  def start_multiple_zones(self, zones) -> None:
218  """Start multiple zones."""
219  self.rachiorachio.zone.start_multiple(zones)
220 
221 
222 class RachioIro:
223  """Represent a Rachio Iro."""
224 
225  def __init__(
226  self,
227  hass: HomeAssistant,
228  rachio: Rachio,
229  data: dict[str, Any],
230  webhooks: list[dict[str, Any]],
231  ) -> None:
232  """Initialize a Rachio device."""
233  self.hasshass = hass
234  self.rachiorachio = rachio
235  self._id_id = data[KEY_ID]
236  self.namename = data[KEY_NAME]
237  self.serial_numberserial_number = data[KEY_SERIAL_NUMBER]
238  self.mac_addressmac_address = data[KEY_MAC_ADDRESS]
239  self.modelmodel = data[KEY_MODEL]
240  self._zones_zones = data[KEY_ZONES]
241  self._schedules_schedules = data[KEY_SCHEDULES]
242  self._flex_schedules_flex_schedules = data[KEY_FLEX_SCHEDULES]
243  self._init_data_init_data = data
244  self._webhooks_webhooks: list[dict[str, Any]] = webhooks
245  _LOGGER.debug('%s has ID "%s"', self, self.controller_idcontroller_id)
246 
247  def setup(self) -> None:
248  """Rachio Iro setup for webhooks."""
249  # Listen for all updates
250  self._init_webhooks_init_webhooks()
251 
252  def _init_webhooks(self) -> None:
253  """Start getting updates from the Rachio API."""
254  current_webhook_id = None
255 
256  # First delete any old webhooks that may have stuck around
257  def _deinit_webhooks(_) -> None:
258  """Stop getting updates from the Rachio API."""
259  if not self._webhooks_webhooks:
260  # We fetched webhooks when we created the device, however if we call _init_webhooks
261  # again we need to fetch again
262  self._webhooks_webhooks = self.rachiorachio.notification.get_device_webhook(
263  self.controller_idcontroller_id
264  )[1]
265  for webhook in self._webhooks_webhooks:
266  if (
267  webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID)
268  or webhook[KEY_ID] == current_webhook_id
269  ):
270  self.rachiorachio.notification.delete(webhook[KEY_ID])
271  self._webhooks_webhooks = []
272 
273  _deinit_webhooks(None)
274 
275  # Choose which events to listen for and get their IDs
276  event_types = [
277  {"id": event_type[KEY_ID]}
278  for event_type in self.rachiorachio.notification.get_webhook_event_type()[1]
279  if event_type[KEY_NAME] in LISTEN_EVENT_TYPES
280  ]
281 
282  # Register to listen to these events from the device
283  url = self.rachiorachio.webhook_url
284  auth = WEBHOOK_CONST_ID + self.rachiorachio.webhook_auth
285  new_webhook = self.rachiorachio.notification.add(
286  self.controller_idcontroller_id, auth, url, event_types
287  )
288  # Save ID for deletion at shutdown
289  current_webhook_id = new_webhook[1][KEY_ID]
290  self.hasshass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks)
291 
292  def __str__(self) -> str:
293  """Display the controller as a string."""
294  return f'Rachio controller "{self.name}"'
295 
296  @property
297  def controller_id(self) -> str:
298  """Return the Rachio API controller ID."""
299  return self._id_id
300 
301  @property
302  def current_schedule(self) -> str:
303  """Return the schedule that the device is running right now."""
304  return self.rachiorachio.device.current_schedule(self.controller_idcontroller_id)[1]
305 
306  @property
307  def init_data(self) -> dict:
308  """Return the information used to set up the controller."""
309  return self._init_data_init_data
310 
311  def list_zones(self, include_disabled=False) -> list:
312  """Return a list of the zone dicts connected to the device."""
313  # All zones
314  if include_disabled:
315  return self._zones_zones
316 
317  # Only enabled zones
318  return [z for z in self._zones_zones if z[KEY_ENABLED]]
319 
320  def get_zone(self, zone_id) -> dict | None:
321  """Return the zone with the given ID."""
322  for zone in self.list_zoneslist_zones(include_disabled=True):
323  if zone[KEY_ID] == zone_id:
324  return zone
325 
326  return None
327 
328  def list_schedules(self) -> list:
329  """Return a list of fixed schedules."""
330  return self._schedules_schedules
331 
332  def list_flex_schedules(self) -> list:
333  """Return a list of flex schedules."""
334  return self._flex_schedules_flex_schedules
335 
336  def stop_watering(self) -> None:
337  """Stop watering all zones connected to this controller."""
338  self.rachiorachio.device.stop_water(self.controller_idcontroller_id)
339  _LOGGER.debug("Stopped watering of all zones on %s", self)
340 
341  def pause_watering(self, duration) -> None:
342  """Pause watering on this controller."""
343  self.rachiorachio.device.pause_zone_run(self.controller_idcontroller_id, duration * 60)
344  _LOGGER.debug("Paused watering on %s for %s minutes", self, duration)
345 
346  def resume_watering(self) -> None:
347  """Resume paused watering on this controller."""
348  self.rachiorachio.device.resume_zone_run(self.controller_idcontroller_id)
349  _LOGGER.debug("Resuming watering on %s", self)
350 
351 
353  """Represent a smart hose timer base station."""
354 
355  def __init__(
356  self,
357  rachio: Rachio,
358  data: dict[str, Any],
359  status_coordinator: RachioUpdateCoordinator,
360  schedule_coordinator: RachioScheduleUpdateCoordinator,
361  ) -> None:
362  """Initialize a smart hose timer base station."""
363  self.rachiorachio = rachio
364  self._id_id = data[KEY_ID]
365  self.status_coordinatorstatus_coordinator = status_coordinator
366  self.schedule_coordinatorschedule_coordinator = schedule_coordinator
367 
368  def start_watering(self, valve_id: str, duration: int) -> None:
369  """Start watering on this valve."""
370  self.rachiorachio.valve.start_watering(valve_id, duration)
371 
372  def stop_watering(self, valve_id: str) -> None:
373  """Stop watering on this valve."""
374  self.rachiorachio.valve.stop_watering(valve_id)
375 
376  def create_skip(self, program_id: str, timestamp: str) -> None:
377  """Create a skip for a scheduled event."""
378  self.rachiorachio.program.create_skip_overrides(program_id, timestamp)
379 
380 
381 def is_invalid_auth_code(http_status_code: int) -> bool:
382  """HTTP status codes that mean invalid auth."""
383  return http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)
None start_watering(self, str valve_id, int duration)
Definition: device.py:368
None __init__(self, Rachio rachio, dict[str, Any] data, RachioUpdateCoordinator status_coordinator, RachioScheduleUpdateCoordinator schedule_coordinator)
Definition: device.py:361
None create_skip(self, str program_id, str timestamp)
Definition: device.py:376
list list_zones(self, include_disabled=False)
Definition: device.py:311
None __init__(self, HomeAssistant hass, Rachio rachio, dict[str, Any] data, list[dict[str, Any]] webhooks)
Definition: device.py:231
list[RachioBaseStation] base_stations(self)
Definition: device.py:213
None _setup(self, HomeAssistant hass)
Definition: device.py:136
None __init__(self, Rachio rachio, ConfigEntry config_entry)
Definition: device.py:64
None async_setup(self, HomeAssistant hass)
Definition: device.py:74
bool is_invalid_auth_code(int http_status_code)
Definition: device.py:381