Home Assistant Unofficial Reference 2024.12.1
switch.py
Go to the documentation of this file.
1 """Component providing support for RainMachine programs and zones."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Awaitable, Callable, Coroutine
7 from dataclasses import dataclass
8 from datetime import datetime
9 from typing import Any, Concatenate
10 
11 from regenmaschine.errors import RainMachineError
12 import voluptuous as vol
13 
14 from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
15 from homeassistant.config_entries import ConfigEntry
16 from homeassistant.const import ATTR_ID, EntityCategory
17 from homeassistant.core import HomeAssistant, callback
18 from homeassistant.exceptions import HomeAssistantError
19 from homeassistant.helpers import config_validation as cv, entity_platform
20 from homeassistant.helpers.entity_platform import AddEntitiesCallback
21 from homeassistant.helpers.typing import VolDictType
22 
23 from . import RainMachineConfigEntry, RainMachineData, async_update_programs_and_zones
24 from .const import (
25  CONF_ALLOW_INACTIVE_ZONES_TO_RUN,
26  CONF_DEFAULT_ZONE_RUN_TIME,
27  CONF_DURATION,
28  CONF_USE_APP_RUN_TIMES,
29  DATA_PROGRAMS,
30  DATA_PROVISION_SETTINGS,
31  DATA_RESTRICTIONS_UNIVERSAL,
32  DATA_ZONES,
33  DEFAULT_ZONE_RUN,
34 )
35 from .entity import RainMachineEntity, RainMachineEntityDescription
36 from .util import RUN_STATE_MAP, key_exists
37 
38 ATTR_ACTIVITY_TYPE = "activity_type"
39 ATTR_AREA = "area"
40 ATTR_CS_ON = "cs_on"
41 ATTR_CURRENT_CYCLE = "current_cycle"
42 ATTR_CYCLES = "cycles"
43 ATTR_DELAY = "delay"
44 ATTR_DELAY_ON = "delay_on"
45 ATTR_FIELD_CAPACITY = "field_capacity"
46 ATTR_NEXT_RUN = "next_run"
47 ATTR_NO_CYCLES = "number_of_cycles"
48 ATTR_PRECIP_RATE = "sprinkler_head_precipitation_rate"
49 ATTR_RESTRICTIONS = "restrictions"
50 ATTR_SLOPE = "slope"
51 ATTR_SOAK = "soak"
52 ATTR_SOIL_TYPE = "soil_type"
53 ATTR_SPRINKLER_TYPE = "sprinkler_head_type"
54 ATTR_STATUS = "status"
55 ATTR_SUN_EXPOSURE = "sun_exposure"
56 ATTR_VEGETATION_TYPE = "vegetation_type"
57 ATTR_ZONES = "zones"
58 ATTR_ZONE_RUN_TIME = "zone_run_time_from_app"
59 
60 DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
61 
62 SOIL_TYPE_MAP = {
63  0: "Not Set",
64  1: "Clay Loam",
65  2: "Silty Clay",
66  3: "Clay",
67  4: "Loam",
68  5: "Sandy Loam",
69  6: "Loamy Sand",
70  7: "Sand",
71  8: "Sandy Clay",
72  9: "Silt Loam",
73  10: "Silt",
74  99: "Other",
75 }
76 
77 SLOPE_TYPE_MAP = {
78  0: "Not Set",
79  1: "Flat",
80  2: "Moderate",
81  3: "High",
82  4: "Very High",
83  99: "Other",
84 }
85 
86 SPRINKLER_TYPE_MAP = {
87  0: "Not Set",
88  1: "Popup Spray",
89  2: "Rotors Low Rate",
90  3: "Surface Drip",
91  4: "Bubblers Drip",
92  5: "Rotors High Rate",
93  99: "Other",
94 }
95 
96 SUN_EXPOSURE_MAP = {0: "Not Set", 1: "Full Sun", 2: "Partial Shade", 3: "Full Shade"}
97 
98 VEGETATION_MAP = {
99  0: "Not Set",
100  1: "Not Set",
101  2: "Cool Season Grass",
102  3: "Fruit Trees",
103  4: "Flowers",
104  5: "Vegetables",
105  6: "Citrus",
106  7: "Bushes",
107  9: "Drought Tolerant Plants",
108  10: "Warm Season Grass",
109  11: "Trees",
110  99: "Other",
111 }
112 
113 
114 def raise_on_request_error[_T: RainMachineBaseSwitch, **_P](
115  func: Callable[Concatenate[_T, _P], Awaitable[None]],
116 ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
117  """Define a decorator to raise on a request error."""
118 
119  async def decorator(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
120  """Decorate."""
121  try:
122  await func(self, *args, **kwargs)
123  except RainMachineError as err:
124  raise HomeAssistantError(
125  f"Error while executing {func.__name__}: {err}",
126  ) from err
127 
128  return decorator
129 
130 
131 @dataclass(frozen=True, kw_only=True)
133  SwitchEntityDescription, RainMachineEntityDescription
134 ):
135  """Describe a RainMachine switch."""
136 
137 
138 @dataclass(frozen=True, kw_only=True)
140  """Describe a RainMachine activity (program/zone) switch."""
141 
142  kind: str
143  uid: int
144 
145 
146 @dataclass(frozen=True, kw_only=True)
148  """Describe a RainMachine restriction switch."""
149 
150  data_key: str
151 
152 
153 TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED = "freeze_protect_enabled"
154 TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING = "hot_days_extra_watering"
155 
156 RESTRICTIONS_SWITCH_DESCRIPTIONS = (
158  key=TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED,
159  translation_key=TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED,
160  icon="mdi:snowflake-alert",
161  api_category=DATA_RESTRICTIONS_UNIVERSAL,
162  data_key="freezeProtectEnabled",
163  ),
165  key=TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING,
166  translation_key=TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING,
167  icon="mdi:heat-wave",
168  api_category=DATA_RESTRICTIONS_UNIVERSAL,
169  data_key="hotDaysExtraWatering",
170  ),
171 )
172 
173 
175  hass: HomeAssistant,
176  entry: RainMachineConfigEntry,
177  async_add_entities: AddEntitiesCallback,
178 ) -> None:
179  """Set up RainMachine switches based on a config entry."""
180  platform = entity_platform.async_get_current_platform()
181 
182  services: tuple[tuple[str, VolDictType | None, str], ...] = (
183  ("start_program", None, "async_start_program"),
184  (
185  "start_zone",
186  {
187  vol.Optional(
188  CONF_DEFAULT_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN
189  ): cv.positive_int
190  },
191  "async_start_zone",
192  ),
193  ("stop_program", None, "async_stop_program"),
194  ("stop_zone", None, "async_stop_zone"),
195  )
196  for service_name, schema, method in services:
197  platform.async_register_entity_service(service_name, schema, method)
198 
199  data = entry.runtime_data
200  entities: list[RainMachineBaseSwitch] = []
201 
202  for kind, api_category, switch_class, switch_enabled_class in (
203  ("program", DATA_PROGRAMS, RainMachineProgram, RainMachineProgramEnabled),
204  ("zone", DATA_ZONES, RainMachineZone, RainMachineZoneEnabled),
205  ):
206  coordinator = data.coordinators[api_category]
207  for uid, activity in coordinator.data.items():
208  name = activity["name"].capitalize()
209 
210  # Add a switch to start/stop the program or zone:
211  entities.append(
212  switch_class(
213  entry,
214  data,
216  key=f"{kind}_{uid}",
217  name=name,
218  api_category=api_category,
219  kind=kind,
220  uid=uid,
221  ),
222  )
223  )
224 
225  # Add a switch to enabled/disable the program or zone:
226  entities.append(
227  switch_enabled_class(
228  entry,
229  data,
231  key=f"{kind}_{uid}_enabled",
232  name=f"{name} enabled",
233  api_category=api_category,
234  kind=kind,
235  uid=uid,
236  ),
237  )
238  )
239 
240  # Add switches to control restrictions:
241  for description in RESTRICTIONS_SWITCH_DESCRIPTIONS:
242  coordinator = data.coordinators[description.api_category]
243  if not key_exists(coordinator.data, description.data_key):
244  continue
245  entities.append(RainMachineRestrictionSwitch(entry, data, description))
246 
247  async_add_entities(entities)
248 
249 
251  """Define a base RainMachine switch."""
252 
253  entity_description: RainMachineSwitchDescription
254 
255  def __init__(
256  self,
257  entry: ConfigEntry,
258  data: RainMachineData,
259  description: RainMachineSwitchDescription,
260  ) -> None:
261  """Initialize."""
262  super().__init__(entry, data, description)
263 
264  self._attr_is_on_attr_is_on = False
265  self._entry_entry_entry = entry
266 
267  @callback
268  def _update_activities(self) -> None:
269  """Update all activity data."""
270  self.hasshasshass.async_create_task(
272  )
273 
274  async def async_start_program(self) -> None:
275  """Execute the start_program entity service."""
276  raise NotImplementedError("Service not implemented for this entity")
277 
278  async def async_start_zone(self, *, zone_run_time: int) -> None:
279  """Execute the start_zone entity service."""
280  raise NotImplementedError("Service not implemented for this entity")
281 
282  async def async_stop_program(self) -> None:
283  """Execute the stop_program entity service."""
284  raise NotImplementedError("Service not implemented for this entity")
285 
286  async def async_stop_zone(self) -> None:
287  """Execute the stop_zone entity service."""
288  raise NotImplementedError("Service not implemented for this entity")
289 
290 
292  """Define a RainMachine switch to start/stop an activity (program or zone)."""
293 
294  _attr_icon = "mdi:water"
295  entity_description: RainMachineActivitySwitchDescription
296 
297  def __init__(
298  self,
299  entry: ConfigEntry,
300  data: RainMachineData,
301  description: RainMachineSwitchDescription,
302  ) -> None:
303  """Initialize."""
304  super().__init__(entry, data, description)
305 
306  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_ACTIVITY_TYPE] = (
307  self.entity_descriptionentity_description.kind
308  )
309 
310  async def async_turn_off(self, **kwargs: Any) -> None:
311  """Turn the switch off.
312 
313  The only way this could occur is if someone rapidly turns a disabled activity
314  off right after turning it on.
315  """
316  if (
317  not self._entry_entry_entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN]
318  and not self.coordinator.data[self.entity_descriptionentity_description.uid]["active"]
319  ):
320  raise HomeAssistantError(
321  f"Cannot turn off an inactive program/zone: {self.name}"
322  )
323 
324  await self.async_turn_off_when_activeasync_turn_off_when_active(**kwargs)
325 
326  @raise_on_request_error
327  async def async_turn_off_when_active(self, **kwargs: Any) -> None:
328  """Turn the switch off when its associated activity is active."""
329  raise NotImplementedError
330 
331  async def async_turn_on(self, **kwargs: Any) -> None:
332  """Turn the switch on."""
333  if (
334  not self._entry_entry_entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN]
335  and not self.coordinator.data[self.entity_descriptionentity_description.uid]["active"]
336  ):
337  self._attr_is_on_attr_is_on_attr_is_on = False
338  self.async_write_ha_stateasync_write_ha_state()
339  raise HomeAssistantError(
340  f"Cannot turn on an inactive program/zone: {self.name}"
341  )
342 
343  await self.async_turn_on_when_activeasync_turn_on_when_active(**kwargs)
344 
345  @raise_on_request_error
346  async def async_turn_on_when_active(self, **kwargs: Any) -> None:
347  """Turn the switch on when its associated activity is active."""
348  raise NotImplementedError
349 
350 
352  """Define a RainMachine switch to enable/disable an activity (program or zone)."""
353 
354  _attr_entity_category = EntityCategory.CONFIG
355  _attr_icon = "mdi:cog"
356  entity_description: RainMachineActivitySwitchDescription
357 
358  def __init__(
359  self,
360  entry: ConfigEntry,
361  data: RainMachineData,
362  description: RainMachineSwitchDescription,
363  ) -> None:
364  """Initialize."""
365  super().__init__(entry, data, description)
366 
367  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_ACTIVITY_TYPE] = (
368  self.entity_descriptionentity_description.kind
369  )
370 
371  @callback
372  def update_from_latest_data(self) -> None:
373  """Update the entity when new data is received."""
374  self._attr_is_on_attr_is_on_attr_is_on = self.coordinator.data[self.entity_descriptionentity_description.uid]["active"]
375 
376 
378  """Define a RainMachine program."""
379 
380  async def async_start_program(self) -> None:
381  """Start the program."""
382  await self.async_turn_onasync_turn_onasync_turn_on()
383 
384  async def async_stop_program(self) -> None:
385  """Stop the program."""
386  await self.async_turn_offasync_turn_offasync_turn_off()
387 
388  @raise_on_request_error
389  async def async_turn_off_when_active(self, **kwargs: Any) -> None:
390  """Turn the switch off when its associated activity is active."""
391  await self._data_data.controller.programs.stop(self.entity_descriptionentity_description.uid)
392  self._update_activities_update_activities()
393 
394  @raise_on_request_error
395  async def async_turn_on_when_active(self, **kwargs: Any) -> None:
396  """Turn the switch on when its associated activity is active."""
397  await self._data_data.controller.programs.start(self.entity_descriptionentity_description.uid)
398  self._update_activities_update_activities()
399 
400  @callback
401  def update_from_latest_data(self) -> None:
402  """Update the entity when new data is received."""
403  data = self.coordinator.data[self.entity_descriptionentity_description.uid]
404 
405  self._attr_is_on_attr_is_on_attr_is_on_attr_is_on = bool(data["status"])
406 
407  next_run: str | None
408  if data.get("nextRun") is None:
409  next_run = None
410  else:
411  next_run = datetime.strptime(
412  f"{data['nextRun']} {data['startTime']}",
413  "%Y-%m-%d %H:%M",
414  ).isoformat()
415 
416  self._attr_extra_state_attributes_attr_extra_state_attributes.update(
417  {
418  ATTR_ID: self.entity_descriptionentity_description.uid,
419  ATTR_NEXT_RUN: next_run,
420  ATTR_SOAK: data.get("soak"),
421  ATTR_STATUS: RUN_STATE_MAP[data["status"]],
422  ATTR_ZONES: [z for z in data["wateringTimes"] if z["active"]],
423  }
424  )
425 
426 
428  """Define a switch to enable/disable a RainMachine program."""
429 
430  @raise_on_request_error
431  async def async_turn_off(self, **kwargs: Any) -> None:
432  """Disable the program."""
433  tasks = [
434  self._data_data.controller.programs.stop(self.entity_descriptionentity_description.uid),
435  self._data_data.controller.programs.disable(self.entity_descriptionentity_description.uid),
436  ]
437  await asyncio.gather(*tasks)
438  self._update_activities_update_activities()
439 
440  @raise_on_request_error
441  async def async_turn_on(self, **kwargs: Any) -> None:
442  """Enable the program."""
443  await self._data_data.controller.programs.enable(self.entity_descriptionentity_description.uid)
444  self._update_activities_update_activities()
445 
446 
448  """Define a RainMachine restriction setting."""
449 
450  _attr_entity_category = EntityCategory.CONFIG
451  entity_description: RainMachineRestrictionSwitchDescription
452 
453  @raise_on_request_error
454  async def async_turn_off(self, **kwargs: Any) -> None:
455  """Disable the restriction."""
456  await self._data_data.controller.restrictions.set_universal(
457  {self.entity_descriptionentity_description.data_key: False}
458  )
459  self._attr_is_on_attr_is_on_attr_is_on = False
460  self.async_write_ha_stateasync_write_ha_state()
461 
462  @raise_on_request_error
463  async def async_turn_on(self, **kwargs: Any) -> None:
464  """Enable the restriction."""
465  await self._data_data.controller.restrictions.set_universal(
466  {self.entity_descriptionentity_description.data_key: True}
467  )
468  self._attr_is_on_attr_is_on_attr_is_on = True
469  self.async_write_ha_stateasync_write_ha_state()
470 
471  @callback
472  def update_from_latest_data(self) -> None:
473  """Update the entity when new data is received."""
474  self._attr_is_on_attr_is_on_attr_is_on = self.coordinator.data[self.entity_descriptionentity_description.data_key]
475 
476 
478  """Define a RainMachine zone."""
479 
480  async def async_start_zone(self, *, zone_run_time: int) -> None:
481  """Start a particular zone for a certain amount of time."""
482  await self.async_turn_onasync_turn_onasync_turn_on(duration=zone_run_time)
483 
484  async def async_stop_zone(self) -> None:
485  """Stop a zone."""
486  await self.async_turn_offasync_turn_offasync_turn_off()
487 
488  @raise_on_request_error
489  async def async_turn_off_when_active(self, **kwargs: Any) -> None:
490  """Turn the switch off when its associated activity is active."""
491  await self._data_data.controller.zones.stop(self.entity_descriptionentity_description.uid)
492  self._update_activities_update_activities()
493 
494  @raise_on_request_error
495  async def async_turn_on_when_active(self, **kwargs: Any) -> None:
496  """Turn the switch on when its associated activity is active."""
497  # 1. Use duration parameter if provided from service call
498  duration = kwargs.get(CONF_DURATION)
499  if not duration:
500  if (
501  self._entry_entry_entry.options[CONF_USE_APP_RUN_TIMES]
502  and ATTR_ZONE_RUN_TIME in self._attr_extra_state_attributes_attr_extra_state_attributes
503  ):
504  # 2. Use app's zone-specific default, if enabled and available
505  duration = self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_ZONE_RUN_TIME]
506  else:
507  # 3. Fall back to global zone default duration
508  duration = self._entry_entry_entry.options[CONF_DEFAULT_ZONE_RUN_TIME]
509  await self._data_data.controller.zones.start(
510  self.entity_descriptionentity_description.uid,
511  duration,
512  )
513  self._update_activities_update_activities()
514 
515  @callback
516  def update_from_latest_data(self) -> None:
517  """Update the entity when new data is received."""
518  data = self.coordinator.data[self.entity_descriptionentity_description.uid]
519 
520  self._attr_is_on_attr_is_on_attr_is_on_attr_is_on = bool(data["state"])
521 
522  attrs = {
523  ATTR_CURRENT_CYCLE: data["cycle"],
524  ATTR_ID: data["uid"],
525  ATTR_NO_CYCLES: data["noOfCycles"],
526  ATTR_RESTRICTIONS: data["restriction"],
527  ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99),
528  ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99),
529  ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99),
530  ATTR_STATUS: RUN_STATE_MAP[data["state"]],
531  ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")),
532  ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99),
533  }
534 
535  if "waterSense" in data:
536  if "area" in data["waterSense"]:
537  attrs[ATTR_AREA] = round(data["waterSense"]["area"], 2)
538  if "fieldCapacity" in data["waterSense"]:
539  attrs[ATTR_FIELD_CAPACITY] = round(
540  data["waterSense"]["fieldCapacity"], 2
541  )
542  if "precipitationRate" in data["waterSense"]:
543  attrs[ATTR_PRECIP_RATE] = round(
544  data["waterSense"]["precipitationRate"], 2
545  )
546 
547  if self._entry_entry_entry.options[CONF_USE_APP_RUN_TIMES]:
548  provision_data = self._data_data.coordinators[DATA_PROVISION_SETTINGS].data
549  if zone_durations := provision_data.get("system", {}).get("zoneDuration"):
550  attrs[ATTR_ZONE_RUN_TIME] = zone_durations[
551  list(self.coordinator.data).index(self.entity_descriptionentity_description.uid)
552  ]
553 
554  self._attr_extra_state_attributes_attr_extra_state_attributes.update(attrs)
555 
556 
558  """Define a switch to enable/disable a RainMachine zone."""
559 
560  @raise_on_request_error
561  async def async_turn_off(self, **kwargs: Any) -> None:
562  """Disable the zone."""
563  tasks = [
564  self._data_data.controller.zones.stop(self.entity_descriptionentity_description.uid),
565  self._data_data.controller.zones.disable(self.entity_descriptionentity_description.uid),
566  ]
567  await asyncio.gather(*tasks)
568  self._update_activities_update_activities()
569 
570  @raise_on_request_error
571  async def async_turn_on(self, **kwargs: Any) -> None:
572  """Enable the zone."""
573  await self._data_data.controller.zones.enable(self.entity_descriptionentity_description.uid)
574  self._update_activities_update_activities()
None __init__(self, ConfigEntry entry, RainMachineData data, RainMachineSwitchDescription description)
Definition: switch.py:302
None __init__(self, ConfigEntry entry, RainMachineData data, RainMachineSwitchDescription description)
Definition: switch.py:260
None __init__(self, ConfigEntry entry, RainMachineData data, RainMachineSwitchDescription description)
Definition: switch.py:363
None async_turn_off(self, **Any kwargs)
Definition: entity.py:1709
None async_turn_on(self, **Any kwargs)
Definition: entity.py:1701
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None async_setup_entry(HomeAssistant hass, RainMachineConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: switch.py:178
bool key_exists(dict[str, Any] data, str search_key)
Definition: util.py:70
None async_update_programs_and_zones(HomeAssistant hass, RainMachineConfigEntry entry)
Definition: __init__.py:202