Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for RainMachine devices."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Coroutine
6 from dataclasses import dataclass
7 from datetime import timedelta
8 from functools import partial, wraps
9 from typing import Any, cast
10 
11 from regenmaschine import Client
12 from regenmaschine.controller import Controller
13 from regenmaschine.errors import RainMachineError, UnknownAPICallError
14 import voluptuous as vol
15 
16 from homeassistant.config_entries import ConfigEntry, ConfigEntryState
17 from homeassistant.const import (
18  CONF_DEVICE_ID,
19  CONF_IP_ADDRESS,
20  CONF_PASSWORD,
21  CONF_PORT,
22  CONF_SSL,
23  CONF_UNIT_OF_MEASUREMENT,
24  Platform,
25 )
26 from homeassistant.core import HomeAssistant, ServiceCall, callback
27 from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
28 from homeassistant.helpers import (
29  aiohttp_client,
30  config_validation as cv,
31  device_registry as dr,
32  entity_registry as er,
33 )
34 from homeassistant.helpers.update_coordinator import UpdateFailed
35 from homeassistant.util.dt import as_timestamp, utcnow
36 from homeassistant.util.network import is_ip_address
37 
38 from .config_flow import get_client_controller
39 from .const import (
40  CONF_ALLOW_INACTIVE_ZONES_TO_RUN,
41  CONF_DEFAULT_ZONE_RUN_TIME,
42  CONF_DURATION,
43  CONF_USE_APP_RUN_TIMES,
44  DATA_API_VERSIONS,
45  DATA_MACHINE_FIRMWARE_UPDATE_STATUS,
46  DATA_PROGRAMS,
47  DATA_PROVISION_SETTINGS,
48  DATA_RESTRICTIONS_CURRENT,
49  DATA_RESTRICTIONS_UNIVERSAL,
50  DATA_ZONES,
51  DEFAULT_ZONE_RUN,
52  DOMAIN,
53  LOGGER,
54 )
55 from .coordinator import RainMachineDataUpdateCoordinator
56 
57 DEFAULT_SSL = True
58 
59 
60 PLATFORMS = [
61  Platform.BINARY_SENSOR,
62  Platform.BUTTON,
63  Platform.SELECT,
64  Platform.SENSOR,
65  Platform.SWITCH,
66  Platform.UPDATE,
67 ]
68 
69 CONF_CONDITION = "condition"
70 CONF_DEWPOINT = "dewpoint"
71 CONF_ET = "et"
72 CONF_MAXRH = "maxrh"
73 CONF_MAXTEMP = "maxtemp"
74 CONF_MINRH = "minrh"
75 CONF_MINTEMP = "mintemp"
76 CONF_PRESSURE = "pressure"
77 CONF_QPF = "qpf"
78 CONF_RAIN = "rain"
79 CONF_SECONDS = "seconds"
80 CONF_SOLARRAD = "solarrad"
81 CONF_TEMPERATURE = "temperature"
82 CONF_TIMESTAMP = "timestamp"
83 CONF_UNITS = "units"
84 CONF_VALUE = "value"
85 CONF_WEATHER = "weather"
86 CONF_WIND = "wind"
87 
88 # Config Validator for Flow Meter Data
89 CV_FLOW_METER_VALID_UNITS = {
90  "clicks",
91  "gal",
92  "litre",
93  "m3",
94 }
95 
96 # Config Validators for Weather Service Data
97 CV_WX_DATA_VALID_PERCENTAGE = vol.All(vol.Coerce(int), vol.Range(min=0, max=100))
98 CV_WX_DATA_VALID_TEMP_RANGE = vol.All(vol.Coerce(float), vol.Range(min=-40.0, max=40.0))
99 CV_WX_DATA_VALID_RAIN_RANGE = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1000.0))
100 CV_WX_DATA_VALID_WIND_SPEED = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=65.0))
101 CV_WX_DATA_VALID_PRESSURE = vol.All(vol.Coerce(float), vol.Range(min=60.0, max=110.0))
102 CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0))
103 
104 SERVICE_NAME_PAUSE_WATERING = "pause_watering"
105 SERVICE_NAME_PUSH_FLOW_METER_DATA = "push_flow_meter_data"
106 SERVICE_NAME_PUSH_WEATHER_DATA = "push_weather_data"
107 SERVICE_NAME_RESTRICT_WATERING = "restrict_watering"
108 SERVICE_NAME_STOP_ALL = "stop_all"
109 SERVICE_NAME_UNPAUSE_WATERING = "unpause_watering"
110 SERVICE_NAME_UNRESTRICT_WATERING = "unrestrict_watering"
111 
112 SERVICE_SCHEMA = vol.Schema(
113  {
114  vol.Required(CONF_DEVICE_ID): cv.string,
115  }
116 )
117 
118 SERVICE_PAUSE_WATERING_SCHEMA = SERVICE_SCHEMA.extend(
119  {
120  vol.Required(CONF_SECONDS): cv.positive_int,
121  }
122 )
123 
124 SERVICE_PUSH_FLOW_METER_DATA_SCHEMA = SERVICE_SCHEMA.extend(
125  {
126  vol.Required(CONF_VALUE): cv.positive_float,
127  vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(
128  cv.string, vol.In(CV_FLOW_METER_VALID_UNITS)
129  ),
130  }
131 )
132 
133 SERVICE_PUSH_WEATHER_DATA_SCHEMA = SERVICE_SCHEMA.extend(
134  {
135  vol.Optional(CONF_TIMESTAMP): cv.positive_float,
136  vol.Optional(CONF_MINTEMP): CV_WX_DATA_VALID_TEMP_RANGE,
137  vol.Optional(CONF_MAXTEMP): CV_WX_DATA_VALID_TEMP_RANGE,
138  vol.Optional(CONF_TEMPERATURE): CV_WX_DATA_VALID_TEMP_RANGE,
139  vol.Optional(CONF_WIND): CV_WX_DATA_VALID_WIND_SPEED,
140  vol.Optional(CONF_SOLARRAD): CV_WX_DATA_VALID_SOLARRAD,
141  vol.Optional(CONF_QPF): CV_WX_DATA_VALID_RAIN_RANGE,
142  vol.Optional(CONF_RAIN): CV_WX_DATA_VALID_RAIN_RANGE,
143  vol.Optional(CONF_ET): CV_WX_DATA_VALID_RAIN_RANGE,
144  vol.Optional(CONF_MINRH): CV_WX_DATA_VALID_PERCENTAGE,
145  vol.Optional(CONF_MAXRH): CV_WX_DATA_VALID_PERCENTAGE,
146  vol.Optional(CONF_CONDITION): cv.string,
147  vol.Optional(CONF_PRESSURE): CV_WX_DATA_VALID_PRESSURE,
148  vol.Optional(CONF_DEWPOINT): CV_WX_DATA_VALID_TEMP_RANGE,
149  }
150 )
151 
152 SERVICE_RESTRICT_WATERING_SCHEMA = SERVICE_SCHEMA.extend(
153  {
154  vol.Required(CONF_DURATION): cv.time_period,
155  }
156 )
157 
158 COORDINATOR_UPDATE_INTERVAL_MAP = {
159  DATA_API_VERSIONS: timedelta(minutes=1),
160  DATA_MACHINE_FIRMWARE_UPDATE_STATUS: timedelta(seconds=15),
161  DATA_PROGRAMS: timedelta(seconds=30),
162  DATA_PROVISION_SETTINGS: timedelta(minutes=1),
163  DATA_RESTRICTIONS_CURRENT: timedelta(minutes=1),
164  DATA_RESTRICTIONS_UNIVERSAL: timedelta(minutes=1),
165  DATA_ZONES: timedelta(seconds=15),
166 }
167 
168 
169 type RainMachineConfigEntry = ConfigEntry[RainMachineData]
170 
171 
172 @dataclass
174  """Define an object to be stored in `entry.runtime_data`."""
175 
176  controller: Controller
177  coordinators: dict[str, RainMachineDataUpdateCoordinator]
178 
179 
180 @callback
182  hass: HomeAssistant, call: ServiceCall
183 ) -> RainMachineConfigEntry:
184  """Get the controller related to a service call (by device ID)."""
185  device_id = call.data[CONF_DEVICE_ID]
186  device_registry = dr.async_get(hass)
187 
188  if (device_entry := device_registry.async_get(device_id)) is None:
189  raise ValueError(f"Invalid RainMachine device ID: {device_id}")
190 
191  for entry_id in device_entry.config_entries:
192  if (entry := hass.config_entries.async_get_entry(entry_id)) is None:
193  continue
194  if entry.domain == DOMAIN:
195  return cast(RainMachineConfigEntry, entry)
196 
197  raise ValueError(f"No controller for device ID: {device_id}")
198 
199 
201  hass: HomeAssistant, entry: RainMachineConfigEntry
202 ) -> None:
203  """Update program and zone DataUpdateCoordinators.
204 
205  Program and zone updates always go together because of how linked they are:
206  programs affect zones and certain combinations of zones affect programs.
207  """
208  data = entry.runtime_data
209  # No gather here to allow http keep-alive to reuse
210  # the connection for each coordinator.
211  await data.coordinators[DATA_PROGRAMS].async_refresh()
212  await data.coordinators[DATA_ZONES].async_refresh()
213 
214 
215 async def async_setup_entry( # noqa: C901
216  hass: HomeAssistant, entry: RainMachineConfigEntry
217 ) -> bool:
218  """Set up RainMachine as config entry."""
219  websession = aiohttp_client.async_get_clientsession(hass)
220  client = Client(session=websession)
221  ip_address = entry.data[CONF_IP_ADDRESS]
222 
223  try:
224  await client.load_local(
225  ip_address,
226  entry.data[CONF_PASSWORD],
227  port=entry.data[CONF_PORT],
228  use_ssl=entry.data.get(CONF_SSL, DEFAULT_SSL),
229  )
230  except RainMachineError as err:
231  raise ConfigEntryNotReady from err
232 
233  # regenmaschine can load multiple controllers at once, but we only grab the one
234  # we loaded above:
235  controller = get_client_controller(client)
236 
237  entry_updates: dict[str, Any] = {}
238  if not entry.unique_id or is_ip_address(entry.unique_id):
239  # If the config entry doesn't already have a unique ID, set one:
240  entry_updates["unique_id"] = controller.mac
241 
242  if CONF_DEFAULT_ZONE_RUN_TIME in entry.data:
243  # If a zone run time exists in the config entry's data, pop it and move it to
244  # options:
245  data = {**entry.data}
246  entry_updates["data"] = data
247  entry_updates["options"] = {
248  **entry.options,
249  CONF_DEFAULT_ZONE_RUN_TIME: data.pop(CONF_DEFAULT_ZONE_RUN_TIME),
250  }
251  entry_updates["options"] = {**entry.options}
252  if CONF_USE_APP_RUN_TIMES not in entry.options:
253  entry_updates["options"][CONF_USE_APP_RUN_TIMES] = False
254  if CONF_DEFAULT_ZONE_RUN_TIME not in entry.options:
255  entry_updates["options"][CONF_DEFAULT_ZONE_RUN_TIME] = DEFAULT_ZONE_RUN
256  if CONF_ALLOW_INACTIVE_ZONES_TO_RUN not in entry.options:
257  entry_updates["options"][CONF_ALLOW_INACTIVE_ZONES_TO_RUN] = False
258  if entry_updates:
259  hass.config_entries.async_update_entry(entry, **entry_updates)
260 
261  if entry.unique_id and controller.mac != entry.unique_id:
262  # If the mac address of the device does not match the unique_id
263  # of the config entry, it likely means the DHCP lease has expired
264  # and the device has been assigned a new IP address. We need to
265  # wait for the next discovery to find the device at its new address
266  # and update the config entry so we do not mix up devices.
267  raise ConfigEntryNotReady(
268  f"Unexpected device found at {ip_address}; expected {entry.unique_id}, "
269  f"found {controller.mac}"
270  )
271 
272  async def async_update(api_category: str) -> dict:
273  """Update the appropriate API data based on a category."""
274  data: dict = {}
275 
276  try:
277  if api_category == DATA_API_VERSIONS:
278  data = await controller.api.versions()
279  elif api_category == DATA_MACHINE_FIRMWARE_UPDATE_STATUS:
280  data = await controller.machine.get_firmware_update_status()
281  elif api_category == DATA_PROGRAMS:
282  data = await controller.programs.all(include_inactive=True)
283  elif api_category == DATA_PROVISION_SETTINGS:
284  data = await controller.provisioning.settings()
285  elif api_category == DATA_RESTRICTIONS_CURRENT:
286  data = await controller.restrictions.current()
287  elif api_category == DATA_RESTRICTIONS_UNIVERSAL:
288  data = await controller.restrictions.universal()
289  else:
290  data = await controller.zones.all(details=True, include_inactive=True)
291  except UnknownAPICallError:
292  LOGGER.warning(
293  "Skipping unsupported API call for controller %s: %s",
294  controller.name,
295  api_category,
296  )
297  except RainMachineError as err:
298  raise UpdateFailed(err) from err
299 
300  return data
301 
302  coordinators = {}
303  for api_category, update_interval in COORDINATOR_UPDATE_INTERVAL_MAP.items():
304  coordinator = coordinators[api_category] = RainMachineDataUpdateCoordinator(
305  hass,
306  entry=entry,
307  name=f'{controller.name} ("{api_category}")',
308  api_category=api_category,
309  update_interval=update_interval,
310  update_method=partial(async_update, api_category),
311  )
312  coordinator.async_initialize()
313  # Its generally faster not to gather here so we can
314  # reuse the connection instead of creating a new
315  # connection for each coordinator.
316  await coordinator.async_config_entry_first_refresh()
317 
318  entry.runtime_data = RainMachineData(
319  controller=controller, coordinators=coordinators
320  )
321 
322  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
323 
324  entry.async_on_unload(entry.add_update_listener(async_reload_entry))
325 
326  def call_with_controller(
327  update_programs_and_zones: bool = True,
328  ) -> Callable[
329  [Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]]],
330  Callable[[ServiceCall], Coroutine[Any, Any, None]],
331  ]:
332  """Hydrate a service call with the appropriate controller."""
333 
334  def decorator(
335  func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]],
336  ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]:
337  """Define the decorator."""
338 
339  @wraps(func)
340  async def wrapper(call: ServiceCall) -> None:
341  """Wrap the service function."""
342  entry = async_get_entry_for_service_call(hass, call)
343  data = entry.runtime_data
344 
345  try:
346  await func(call, data.controller)
347  except RainMachineError as err:
348  raise HomeAssistantError(
349  f"Error while executing {func.__name__}: {err}"
350  ) from err
351 
352  if update_programs_and_zones:
353  await async_update_programs_and_zones(hass, entry)
354 
355  return wrapper
356 
357  return decorator
358 
359  @call_with_controller()
360  async def async_pause_watering(call: ServiceCall, controller: Controller) -> None:
361  """Pause watering for a set number of seconds."""
362  await controller.watering.pause_all(call.data[CONF_SECONDS])
363 
364  @call_with_controller(update_programs_and_zones=False)
365  async def async_push_flow_meter_data(
366  call: ServiceCall, controller: Controller
367  ) -> None:
368  """Push flow meter data to the device."""
369  value = call.data[CONF_VALUE]
370  if units := call.data.get(CONF_UNIT_OF_MEASUREMENT):
371  await controller.watering.post_flowmeter(value=value, units=units)
372  else:
373  await controller.watering.post_flowmeter(value=value)
374 
375  @call_with_controller(update_programs_and_zones=False)
376  async def async_push_weather_data(
377  call: ServiceCall, controller: Controller
378  ) -> None:
379  """Push weather data to the device."""
380  await controller.parsers.post_data(
381  {
382  CONF_WEATHER: [
383  {
384  key: value
385  for key, value in call.data.items()
386  if key != CONF_DEVICE_ID
387  }
388  ]
389  }
390  )
391 
392  @call_with_controller()
393  async def async_restrict_watering(
394  call: ServiceCall, controller: Controller
395  ) -> None:
396  """Restrict watering for a time period."""
397  duration = call.data[CONF_DURATION]
398  await controller.restrictions.set_universal(
399  {
400  "rainDelayStartTime": round(as_timestamp(utcnow())),
401  "rainDelayDuration": duration.total_seconds(),
402  },
403  )
404 
405  @call_with_controller()
406  async def async_stop_all(call: ServiceCall, controller: Controller) -> None:
407  """Stop all watering."""
408  await controller.watering.stop_all()
409 
410  @call_with_controller()
411  async def async_unpause_watering(call: ServiceCall, controller: Controller) -> None:
412  """Unpause watering."""
413  await controller.watering.unpause_all()
414 
415  @call_with_controller()
416  async def async_unrestrict_watering(
417  call: ServiceCall, controller: Controller
418  ) -> None:
419  """Unrestrict watering."""
420  await controller.restrictions.set_universal(
421  {
422  "rainDelayStartTime": round(as_timestamp(utcnow())),
423  "rainDelayDuration": 0,
424  },
425  )
426 
427  for service_name, schema, method in (
428  (
429  SERVICE_NAME_PAUSE_WATERING,
430  SERVICE_PAUSE_WATERING_SCHEMA,
431  async_pause_watering,
432  ),
433  (
434  SERVICE_NAME_PUSH_FLOW_METER_DATA,
435  SERVICE_PUSH_FLOW_METER_DATA_SCHEMA,
436  async_push_flow_meter_data,
437  ),
438  (
439  SERVICE_NAME_PUSH_WEATHER_DATA,
440  SERVICE_PUSH_WEATHER_DATA_SCHEMA,
441  async_push_weather_data,
442  ),
443  (
444  SERVICE_NAME_RESTRICT_WATERING,
445  SERVICE_RESTRICT_WATERING_SCHEMA,
446  async_restrict_watering,
447  ),
448  (SERVICE_NAME_STOP_ALL, SERVICE_SCHEMA, async_stop_all),
449  (SERVICE_NAME_UNPAUSE_WATERING, SERVICE_SCHEMA, async_unpause_watering),
450  (
451  SERVICE_NAME_UNRESTRICT_WATERING,
452  SERVICE_SCHEMA,
453  async_unrestrict_watering,
454  ),
455  ):
456  if hass.services.has_service(DOMAIN, service_name):
457  continue
458  hass.services.async_register(DOMAIN, service_name, method, schema=schema)
459 
460  return True
461 
462 
464  hass: HomeAssistant, entry: RainMachineConfigEntry
465 ) -> bool:
466  """Unload an RainMachine config entry."""
467  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
468  loaded_entries = [
469  entry
470  for entry in hass.config_entries.async_entries(DOMAIN)
471  if entry.state is ConfigEntryState.LOADED
472  ]
473  if len(loaded_entries) == 1:
474  # If this is the last loaded instance of RainMachine, deregister any services
475  # defined during integration setup:
476  for service_name in (
477  SERVICE_NAME_PAUSE_WATERING,
478  SERVICE_NAME_PUSH_FLOW_METER_DATA,
479  SERVICE_NAME_PUSH_WEATHER_DATA,
480  SERVICE_NAME_RESTRICT_WATERING,
481  SERVICE_NAME_STOP_ALL,
482  SERVICE_NAME_UNPAUSE_WATERING,
483  SERVICE_NAME_UNRESTRICT_WATERING,
484  ):
485  hass.services.async_remove(DOMAIN, service_name)
486 
487  return unload_ok
488 
489 
491  hass: HomeAssistant, entry: RainMachineConfigEntry
492 ) -> bool:
493  """Migrate an old config entry."""
494  version = entry.version
495 
496  LOGGER.debug("Migrating from version %s", version)
497 
498  # 1 -> 2: Update unique IDs to be consistent across platform (including removing
499  # the silly removal of colons in the MAC address that was added originally):
500  if version == 1:
501  version = 2
502  hass.config_entries.async_update_entry(entry, version=version)
503 
504  @callback
505  def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]:
506  """Migrate the unique ID to a new format."""
507  unique_id_pieces = entity_entry.unique_id.split("_")
508  old_mac = unique_id_pieces[0]
509  new_mac = ":".join(old_mac[i : i + 2] for i in range(0, len(old_mac), 2))
510  unique_id_pieces[0] = new_mac
511 
512  if entity_entry.entity_id.startswith("switch"):
513  unique_id_pieces[1] = unique_id_pieces[1][11:].lower()
514 
515  return {"new_unique_id": "_".join(unique_id_pieces)}
516 
517  await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
518 
519  LOGGER.debug("Migration to version %s successful", version)
520 
521  return True
522 
523 
525  hass: HomeAssistant, entry: RainMachineConfigEntry
526 ) -> None:
527  """Handle an options update."""
528  await hass.config_entries.async_reload(entry.entry_id)
Controller get_client_controller(Client client)
Definition: config_flow.py:34
None async_reload_entry(HomeAssistant hass, RainMachineConfigEntry entry)
Definition: __init__.py:526
RainMachineConfigEntry async_get_entry_for_service_call(HomeAssistant hass, ServiceCall call)
Definition: __init__.py:183
bool async_setup_entry(HomeAssistant hass, RainMachineConfigEntry entry)
Definition: __init__.py:217
bool async_migrate_entry(HomeAssistant hass, RainMachineConfigEntry entry)
Definition: __init__.py:492
None async_update_programs_and_zones(HomeAssistant hass, RainMachineConfigEntry entry)
Definition: __init__.py:202
bool async_unload_entry(HomeAssistant hass, RainMachineConfigEntry entry)
Definition: __init__.py:465
float as_timestamp(dt.datetime|str dt_value)
Definition: dt.py:145
bool is_ip_address(str address)
Definition: network.py:63