1 """Support for RainMachine devices."""
3 from __future__
import annotations
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
11 from regenmaschine
import Client
12 from regenmaschine.controller
import Controller
13 from regenmaschine.errors
import RainMachineError, UnknownAPICallError
14 import voluptuous
as vol
23 CONF_UNIT_OF_MEASUREMENT,
30 config_validation
as cv,
31 device_registry
as dr,
32 entity_registry
as er,
38 from .config_flow
import get_client_controller
40 CONF_ALLOW_INACTIVE_ZONES_TO_RUN,
41 CONF_DEFAULT_ZONE_RUN_TIME,
43 CONF_USE_APP_RUN_TIMES,
45 DATA_MACHINE_FIRMWARE_UPDATE_STATUS,
47 DATA_PROVISION_SETTINGS,
48 DATA_RESTRICTIONS_CURRENT,
49 DATA_RESTRICTIONS_UNIVERSAL,
55 from .coordinator
import RainMachineDataUpdateCoordinator
61 Platform.BINARY_SENSOR,
69 CONF_CONDITION =
"condition"
70 CONF_DEWPOINT =
"dewpoint"
73 CONF_MAXTEMP =
"maxtemp"
75 CONF_MINTEMP =
"mintemp"
76 CONF_PRESSURE =
"pressure"
79 CONF_SECONDS =
"seconds"
80 CONF_SOLARRAD =
"solarrad"
81 CONF_TEMPERATURE =
"temperature"
82 CONF_TIMESTAMP =
"timestamp"
85 CONF_WEATHER =
"weather"
89 CV_FLOW_METER_VALID_UNITS = {
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))
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"
112 SERVICE_SCHEMA = vol.Schema(
114 vol.Required(CONF_DEVICE_ID): cv.string,
118 SERVICE_PAUSE_WATERING_SCHEMA = SERVICE_SCHEMA.extend(
120 vol.Required(CONF_SECONDS): cv.positive_int,
124 SERVICE_PUSH_FLOW_METER_DATA_SCHEMA = SERVICE_SCHEMA.extend(
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)
133 SERVICE_PUSH_WEATHER_DATA_SCHEMA = SERVICE_SCHEMA.extend(
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,
152 SERVICE_RESTRICT_WATERING_SCHEMA = SERVICE_SCHEMA.extend(
154 vol.Required(CONF_DURATION): cv.time_period,
158 COORDINATOR_UPDATE_INTERVAL_MAP = {
160 DATA_MACHINE_FIRMWARE_UPDATE_STATUS:
timedelta(seconds=15),
162 DATA_PROVISION_SETTINGS:
timedelta(minutes=1),
163 DATA_RESTRICTIONS_CURRENT:
timedelta(minutes=1),
164 DATA_RESTRICTIONS_UNIVERSAL:
timedelta(minutes=1),
169 type RainMachineConfigEntry = ConfigEntry[RainMachineData]
174 """Define an object to be stored in `entry.runtime_data`."""
176 controller: Controller
177 coordinators: dict[str, RainMachineDataUpdateCoordinator]
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)
188 if (device_entry := device_registry.async_get(device_id))
is None:
189 raise ValueError(f
"Invalid RainMachine device ID: {device_id}")
191 for entry_id
in device_entry.config_entries:
192 if (entry := hass.config_entries.async_get_entry(entry_id))
is None:
194 if entry.domain == DOMAIN:
195 return cast(RainMachineConfigEntry, entry)
197 raise ValueError(f
"No controller for device ID: {device_id}")
201 hass: HomeAssistant, entry: RainMachineConfigEntry
203 """Update program and zone DataUpdateCoordinators.
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.
208 data = entry.runtime_data
211 await data.coordinators[DATA_PROGRAMS].async_refresh()
212 await data.coordinators[DATA_ZONES].async_refresh()
216 hass: HomeAssistant, entry: RainMachineConfigEntry
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]
224 await client.load_local(
226 entry.data[CONF_PASSWORD],
227 port=entry.data[CONF_PORT],
228 use_ssl=entry.data.get(CONF_SSL, DEFAULT_SSL),
230 except RainMachineError
as err:
231 raise ConfigEntryNotReady
from err
237 entry_updates: dict[str, Any] = {}
240 entry_updates[
"unique_id"] = controller.mac
242 if CONF_DEFAULT_ZONE_RUN_TIME
in entry.data:
245 data = {**entry.data}
246 entry_updates[
"data"] = data
247 entry_updates[
"options"] = {
249 CONF_DEFAULT_ZONE_RUN_TIME: data.pop(CONF_DEFAULT_ZONE_RUN_TIME),
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
259 hass.config_entries.async_update_entry(entry, **entry_updates)
261 if entry.unique_id
and controller.mac != entry.unique_id:
268 f
"Unexpected device found at {ip_address}; expected {entry.unique_id}, "
269 f
"found {controller.mac}"
273 """Update the appropriate API data based on a category."""
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()
290 data = await controller.zones.all(details=
True, include_inactive=
True)
291 except UnknownAPICallError:
293 "Skipping unsupported API call for controller %s: %s",
297 except RainMachineError
as err:
303 for api_category, update_interval
in COORDINATOR_UPDATE_INTERVAL_MAP.items():
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),
312 coordinator.async_initialize()
316 await coordinator.async_config_entry_first_refresh()
319 controller=controller, coordinators=coordinators
322 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
324 entry.async_on_unload(entry.add_update_listener(async_reload_entry))
326 def call_with_controller(
327 update_programs_and_zones: bool =
True,
329 [Callable[[ServiceCall, Controller], Coroutine[Any, Any,
None]]],
330 Callable[[ServiceCall], Coroutine[Any, Any,
None]],
332 """Hydrate a service call with the appropriate controller."""
335 func: Callable[[ServiceCall, Controller], Coroutine[Any, Any,
None]],
336 ) -> Callable[[ServiceCall], Coroutine[Any, Any,
None]]:
337 """Define the decorator."""
340 async
def wrapper(call: ServiceCall) ->
None:
341 """Wrap the service function."""
343 data = entry.runtime_data
346 await func(call, data.controller)
347 except RainMachineError
as err:
349 f
"Error while executing {func.__name__}: {err}"
352 if update_programs_and_zones:
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])
364 @call_with_controller(update_programs_and_zones=False)
365 async
def async_push_flow_meter_data(
366 call: ServiceCall, controller: Controller
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)
373 await controller.watering.post_flowmeter(value=value)
375 @call_with_controller(update_programs_and_zones=False)
376 async
def async_push_weather_data(
377 call: ServiceCall, controller: Controller
379 """Push weather data to the device."""
380 await controller.parsers.post_data(
385 for key, value
in call.data.items()
386 if key != CONF_DEVICE_ID
392 @call_with_controller()
393 async
def async_restrict_watering(
394 call: ServiceCall, controller: Controller
396 """Restrict watering for a time period."""
397 duration = call.data[CONF_DURATION]
398 await controller.restrictions.set_universal(
401 "rainDelayDuration": duration.total_seconds(),
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()
410 @call_with_controller()
411 async
def async_unpause_watering(call: ServiceCall, controller: Controller) ->
None:
412 """Unpause watering."""
413 await controller.watering.unpause_all()
415 @call_with_controller()
416 async
def async_unrestrict_watering(
417 call: ServiceCall, controller: Controller
419 """Unrestrict watering."""
420 await controller.restrictions.set_universal(
423 "rainDelayDuration": 0,
427 for service_name, schema, method
in (
429 SERVICE_NAME_PAUSE_WATERING,
430 SERVICE_PAUSE_WATERING_SCHEMA,
431 async_pause_watering,
434 SERVICE_NAME_PUSH_FLOW_METER_DATA,
435 SERVICE_PUSH_FLOW_METER_DATA_SCHEMA,
436 async_push_flow_meter_data,
439 SERVICE_NAME_PUSH_WEATHER_DATA,
440 SERVICE_PUSH_WEATHER_DATA_SCHEMA,
441 async_push_weather_data,
444 SERVICE_NAME_RESTRICT_WATERING,
445 SERVICE_RESTRICT_WATERING_SCHEMA,
446 async_restrict_watering,
448 (SERVICE_NAME_STOP_ALL, SERVICE_SCHEMA, async_stop_all),
449 (SERVICE_NAME_UNPAUSE_WATERING, SERVICE_SCHEMA, async_unpause_watering),
451 SERVICE_NAME_UNRESTRICT_WATERING,
453 async_unrestrict_watering,
456 if hass.services.has_service(DOMAIN, service_name):
458 hass.services.async_register(DOMAIN, service_name, method, schema=schema)
464 hass: HomeAssistant, entry: RainMachineConfigEntry
466 """Unload an RainMachine config entry."""
467 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
470 for entry
in hass.config_entries.async_entries(DOMAIN)
471 if entry.state
is ConfigEntryState.LOADED
473 if len(loaded_entries) == 1:
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,
485 hass.services.async_remove(DOMAIN, service_name)
491 hass: HomeAssistant, entry: RainMachineConfigEntry
493 """Migrate an old config entry."""
494 version = entry.version
496 LOGGER.debug(
"Migrating from version %s", version)
502 hass.config_entries.async_update_entry(entry, version=version)
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
512 if entity_entry.entity_id.startswith(
"switch"):
513 unique_id_pieces[1] = unique_id_pieces[1][11:].lower()
515 return {
"new_unique_id":
"_".join(unique_id_pieces)}
517 await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
519 LOGGER.debug(
"Migration to version %s successful", version)
525 hass: HomeAssistant, entry: RainMachineConfigEntry
527 """Handle an options update."""
528 await hass.config_entries.async_reload(entry.entry_id)
Controller get_client_controller(Client client)
None async_reload_entry(HomeAssistant hass, RainMachineConfigEntry entry)
RainMachineConfigEntry async_get_entry_for_service_call(HomeAssistant hass, ServiceCall call)
bool async_setup_entry(HomeAssistant hass, RainMachineConfigEntry entry)
bool async_migrate_entry(HomeAssistant hass, RainMachineConfigEntry entry)
None async_update_programs_and_zones(HomeAssistant hass, RainMachineConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, RainMachineConfigEntry entry)
float as_timestamp(dt.datetime|str dt_value)
bool is_ip_address(str address)