"""Support for RainMachine devices.""" import asyncio from datetime import timedelta import logging from regenmaschine import Client from regenmaschine.errors import RainMachineError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import verify_domain_control from .const import ( CONF_ZONE_RUN_TIME, DATA_CLIENT, DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, DATA_ZONES_DETAILS, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_ZONE_RUN, DOMAIN, PROGRAM_UPDATE_TOPIC, SENSOR_UPDATE_TOPIC, ZONE_UPDATE_TOPIC, ) _LOGGER = logging.getLogger(__name__) CONF_CONTROLLERS = "controllers" CONF_PROGRAM_ID = "program_id" CONF_SECONDS = "seconds" CONF_ZONE_ID = "zone_id" DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC" DEFAULT_ICON = "mdi:water" DEFAULT_SSL = True SERVICE_ALTER_PROGRAM = vol.Schema({vol.Required(CONF_PROGRAM_ID): cv.positive_int}) SERVICE_ALTER_ZONE = vol.Schema({vol.Required(CONF_ZONE_ID): cv.positive_int}) SERVICE_PAUSE_WATERING = vol.Schema({vol.Required(CONF_SECONDS): cv.positive_int}) SERVICE_START_PROGRAM_SCHEMA = vol.Schema( {vol.Required(CONF_PROGRAM_ID): cv.positive_int} ) SERVICE_START_ZONE_SCHEMA = vol.Schema( { vol.Required(CONF_ZONE_ID): cv.positive_int, vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int, } ) SERVICE_STOP_PROGRAM_SCHEMA = vol.Schema( {vol.Required(CONF_PROGRAM_ID): cv.positive_int} ) SERVICE_STOP_ZONE_SCHEMA = vol.Schema({vol.Required(CONF_ZONE_ID): cv.positive_int}) CONTROLLER_SCHEMA = vol.Schema( { vol.Required(CONF_IP_ADDRESS): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, lambda value: value.total_seconds() ), vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int, } ) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_CONTROLLERS): vol.All( cv.ensure_list, [CONTROLLER_SCHEMA] ) } ) }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass, config): """Set up the RainMachine component.""" hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_CLIENT] = {} if DOMAIN not in config: return True conf = config[DOMAIN] for controller in conf[CONF_CONTROLLERS]: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=controller ) ) return True async def async_setup_entry(hass, config_entry): """Set up RainMachine as config entry.""" if not config_entry.unique_id: hass.config_entries.async_update_entry( config_entry, unique_id=config_entry.data[CONF_IP_ADDRESS] ) _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) client = Client(session=websession) try: await client.load_local( config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD], port=config_entry.data[CONF_PORT], ssl=config_entry.data.get(CONF_SSL, DEFAULT_SSL), ) except RainMachineError as err: _LOGGER.error("An error occurred: %s", err) raise ConfigEntryNotReady else: # regenmaschine can load multiple controllers at once, but we only grab the one # we loaded above: controller = next(iter(client.controllers.values())) rainmachine = RainMachine( hass, controller, config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN), config_entry.data.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.total_seconds() ), ) # Update the data object, which at this point (prior to any sensors registering # "interest" in the API), will focus on grabbing the latest program and zone data: await rainmachine.async_update() hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine for component in ("binary_sensor", "sensor", "switch"): hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, component) ) @_verify_domain_control async def disable_program(call): """Disable a program.""" await rainmachine.controller.programs.disable(call.data[CONF_PROGRAM_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def disable_zone(call): """Disable a zone.""" await rainmachine.controller.zones.disable(call.data[CONF_ZONE_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def enable_program(call): """Enable a program.""" await rainmachine.controller.programs.enable(call.data[CONF_PROGRAM_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def enable_zone(call): """Enable a zone.""" await rainmachine.controller.zones.enable(call.data[CONF_ZONE_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def pause_watering(call): """Pause watering for a set number of seconds.""" await rainmachine.controller.watering.pause_all(call.data[CONF_SECONDS]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def start_program(call): """Start a particular program.""" await rainmachine.controller.programs.start(call.data[CONF_PROGRAM_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def start_zone(call): """Start a particular zone for a certain amount of time.""" await rainmachine.controller.zones.start( call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME] ) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_all(call): """Stop all watering.""" await rainmachine.controller.watering.stop_all() await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_program(call): """Stop a program.""" await rainmachine.controller.programs.stop(call.data[CONF_PROGRAM_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_zone(call): """Stop a zone.""" await rainmachine.controller.zones.stop(call.data[CONF_ZONE_ID]) await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def unpause_watering(call): """Unpause watering.""" await rainmachine.controller.watering.unpause_all() await rainmachine.async_update_programs_and_zones() for service, method, schema in [ ("disable_program", disable_program, SERVICE_ALTER_PROGRAM), ("disable_zone", disable_zone, SERVICE_ALTER_ZONE), ("enable_program", enable_program, SERVICE_ALTER_PROGRAM), ("enable_zone", enable_zone, SERVICE_ALTER_ZONE), ("pause_watering", pause_watering, SERVICE_PAUSE_WATERING), ("start_program", start_program, SERVICE_START_PROGRAM_SCHEMA), ("start_zone", start_zone, SERVICE_START_ZONE_SCHEMA), ("stop_all", stop_all, {}), ("stop_program", stop_program, SERVICE_STOP_PROGRAM_SCHEMA), ("stop_zone", stop_zone, SERVICE_STOP_ZONE_SCHEMA), ("unpause_watering", unpause_watering, {}), ]: hass.services.async_register(DOMAIN, service, method, schema=schema) return True async def async_unload_entry(hass, config_entry): """Unload an OpenUV config entry.""" hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) tasks = [ hass.config_entries.async_forward_entry_unload(config_entry, component) for component in ("binary_sensor", "sensor", "switch") ] await asyncio.gather(*tasks) return True class RainMachine: """Define a generic RainMachine object.""" def __init__(self, hass, controller, default_zone_runtime, scan_interval): """Initialize.""" self._async_cancel_time_interval_listener = None self._scan_interval_seconds = scan_interval self.controller = controller self.data = {} self.default_zone_runtime = default_zone_runtime self.device_mac = controller.mac self.hass = hass self._api_category_count = { DATA_PROVISION_SETTINGS: 0, DATA_RESTRICTIONS_CURRENT: 0, DATA_RESTRICTIONS_UNIVERSAL: 0, } self._api_category_locks = { DATA_PROVISION_SETTINGS: asyncio.Lock(), DATA_RESTRICTIONS_CURRENT: asyncio.Lock(), DATA_RESTRICTIONS_UNIVERSAL: asyncio.Lock(), } async def _async_update_listener_action(self, now): """Define an async_track_time_interval action to update data.""" await self.async_update() @callback def async_deregister_sensor_api_interest(self, api_category): """Decrement the number of entities with data needs from an API category.""" # If this deregistration should leave us with no registration at all, remove the # time interval: if sum(self._api_category_count.values()) == 0: if self._async_cancel_time_interval_listener: self._async_cancel_time_interval_listener() self._async_cancel_time_interval_listener = None return self._api_category_count[api_category] -= 1 async def async_fetch_from_api(self, api_category): """Execute the appropriate coroutine to fetch particular data from the API.""" if api_category == DATA_PROGRAMS: data = await self.controller.programs.all(include_inactive=True) elif api_category == DATA_PROVISION_SETTINGS: data = await self.controller.provisioning.settings() elif api_category == DATA_RESTRICTIONS_CURRENT: data = await self.controller.restrictions.current() elif api_category == DATA_RESTRICTIONS_UNIVERSAL: data = await self.controller.restrictions.universal() elif api_category == DATA_ZONES: data = await self.controller.zones.all(include_inactive=True) elif api_category == DATA_ZONES_DETAILS: # This API call needs to be separate from the DATA_ZONES one above because, # maddeningly, the DATA_ZONES_DETAILS API call doesn't include the current # state of the zone: data = await self.controller.zones.all(details=True, include_inactive=True) self.data[api_category] = data async def async_register_sensor_api_interest(self, api_category): """Increment the number of entities with data needs from an API category.""" # If this is the first registration we have, start a time interval: if not self._async_cancel_time_interval_listener: self._async_cancel_time_interval_listener = async_track_time_interval( self.hass, self._async_update_listener_action, timedelta(seconds=self._scan_interval_seconds), ) self._api_category_count[api_category] += 1 # If a sensor registers interest in a particular API call and the data doesn't # exist for it yet, make the API call and grab the data: async with self._api_category_locks[api_category]: if api_category not in self.data: await self.async_fetch_from_api(api_category) async def async_update(self): """Update all RainMachine data.""" tasks = [self.async_update_programs_and_zones(), self.async_update_sensors()] await asyncio.gather(*tasks) async def async_update_sensors(self): """Update sensor/binary sensor data.""" _LOGGER.debug("Updating sensor data for RainMachine") # Fetch an API category if there is at least one interested entity: tasks = {} for category, count in self._api_category_count.items(): if count == 0: continue tasks[category] = self.async_fetch_from_api(category) results = await asyncio.gather(*tasks.values(), return_exceptions=True) for api_category, result in zip(tasks, results): if isinstance(result, RainMachineError): _LOGGER.error( "There was an error while updating %s: %s", api_category, result ) continue async_dispatcher_send(self.hass, SENSOR_UPDATE_TOPIC) async def async_update_programs_and_zones(self): """Update program and zone data. Program and zone updates always go together because of how linked they are: programs affect zones and certain combinations of zones affect programs. Note that this call does not take into account interested entities when making the API calls; we make the reasonable assumption that switches will always be enabled. """ _LOGGER.debug("Updating program and zone data for RainMachine") tasks = { DATA_PROGRAMS: self.async_fetch_from_api(DATA_PROGRAMS), DATA_ZONES: self.async_fetch_from_api(DATA_ZONES), DATA_ZONES_DETAILS: self.async_fetch_from_api(DATA_ZONES_DETAILS), } results = await asyncio.gather(*tasks.values(), return_exceptions=True) for api_category, result in zip(tasks, results): if isinstance(result, RainMachineError): _LOGGER.error( "There was an error while updating %s: %s", api_category, result ) async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(self.hass, ZONE_UPDATE_TOPIC) class RainMachineEntity(Entity): """Define a generic RainMachine entity.""" def __init__(self, rainmachine): """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._device_class = None self._name = None self.rainmachine = rainmachine @property def device_class(self): """Return the device class.""" return self._device_class @property def device_info(self): """Return device registry information for this entity.""" return { "identifiers": {(DOMAIN, self.rainmachine.controller.mac)}, "name": self.rainmachine.controller.name, "manufacturer": "RainMachine", "model": ( f"Version {self.rainmachine.controller.hardware_version} " f"(API: {self.rainmachine.controller.api_version})" ), "sw_version": self.rainmachine.controller.software_version, } @property def device_state_attributes(self) -> dict: """Return the state attributes.""" return self._attrs @property def name(self) -> str: """Return the name of the entity.""" return self._name @property def should_poll(self): """Disable polling.""" return False @callback def _update_state(self): """Update the state.""" self.update_from_latest_data() self.async_write_ha_state() @callback def update_from_latest_data(self): """Update the entity.""" raise NotImplementedError