"""Support for RainMachine devices.""" import asyncio import logging from datetime import timedelta import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL, CONF_MONITORED_CONDITIONS, CONF_SWITCHES, ) 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 .config_flow import configured_instances from .const import ( DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN, PROVISION_SETTINGS, RESTRICTIONS_CURRENT, RESTRICTIONS_UNIVERSAL, ) _LOGGER = logging.getLogger(__name__) DATA_LISTENER = "listener" PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update" SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update" ZONE_UPDATE_TOPIC = f"{DOMAIN}_zone_update" CONF_CONTROLLERS = "controllers" CONF_PROGRAM_ID = "program_id" CONF_SECONDS = "seconds" CONF_ZONE_ID = "zone_id" CONF_ZONE_RUN_TIME = "zone_run_time" DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC" DEFAULT_ICON = "mdi:water" DEFAULT_ZONE_RUN = 60 * 10 TYPE_FLOW_SENSOR = "flow_sensor" TYPE_FLOW_SENSOR_CLICK_M3 = "flow_sensor_clicks_cubic_meter" TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" TYPE_FLOW_SENSOR_START_INDEX = "flow_sensor_start_index" TYPE_FLOW_SENSOR_WATERING_CLICKS = "flow_sensor_watering_clicks" TYPE_FREEZE = "freeze" TYPE_FREEZE_PROTECTION = "freeze_protection" TYPE_FREEZE_TEMP = "freeze_protect_temp" TYPE_HOT_DAYS = "extra_water_on_hot_days" TYPE_HOURLY = "hourly" TYPE_MONTH = "month" TYPE_RAINDELAY = "raindelay" TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" BINARY_SENSORS = { TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump"), TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel"), TYPE_FREEZE_PROTECTION: ("Freeze Protection", "mdi:weather-snowy"), TYPE_HOT_DAYS: ("Extra Water on Hot Days", "mdi:thermometer-lines"), TYPE_HOURLY: ("Hourly Restrictions", "mdi:cancel"), TYPE_MONTH: ("Month Restrictions", "mdi:cancel"), TYPE_RAINDELAY: ("Rain Delay Restrictions", "mdi:cancel"), TYPE_RAINSENSOR: ("Rain Sensor Restrictions", "mdi:cancel"), TYPE_WEEKDAY: ("Weekday Restrictions", "mdi:cancel"), } SENSORS = { TYPE_FLOW_SENSOR_CLICK_M3: ( "Flow Sensor Clicks", "mdi:water-pump", "clicks/m^3", None, ), TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( "Flow Sensor Consumed Liters", "mdi:water-pump", "liter", None, ), TYPE_FLOW_SENSOR_START_INDEX: ( "Flow Sensor Start Index", "mdi:water-pump", "index", None, ), TYPE_FLOW_SENSOR_WATERING_CLICKS: ( "Flow Sensor Clicks", "mdi:water-pump", "clicks", None, ), TYPE_FREEZE_TEMP: ( "Freeze Protect Temperature", "mdi:thermometer", "°C", "temperature", ), } BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): vol.All( cv.ensure_list, [vol.In(BINARY_SENSORS)] ) } ) SENSOR_SCHEMA = vol.Schema( { vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( cv.ensure_list, [vol.In(SENSORS)] ) } ) 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}) SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_ZONE_RUN_TIME): 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): cv.time_period, vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, } ) 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] = {} hass.data[DOMAIN][DATA_LISTENER] = {} if DOMAIN not in config: return True conf = config[DOMAIN] for controller in conf[CONF_CONTROLLERS]: if controller[CONF_IP_ADDRESS] in configured_instances(hass): continue 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.""" from regenmaschine import login from regenmaschine.errors import RainMachineError _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) try: client = await login( config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD], websession, port=config_entry.data[CONF_PORT], ssl=config_entry.data[CONF_SSL], ) rainmachine = RainMachine( client, config_entry.data.get(CONF_BINARY_SENSORS, {}).get( CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS) ), config_entry.data.get(CONF_SENSORS, {}).get( CONF_MONITORED_CONDITIONS, list(SENSORS) ), config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN), ) await rainmachine.async_update() except RainMachineError as err: _LOGGER.error("An error occurred: %s", err) raise ConfigEntryNotReady 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) ) async def refresh(event_time): """Refresh RainMachine sensor data.""" _LOGGER.debug("Updating RainMachine sensor data") await rainmachine.async_update() async_dispatcher_send(hass, SENSOR_UPDATE_TOPIC) hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( hass, refresh, timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) ) @_verify_domain_control async def disable_program(call): """Disable a program.""" await rainmachine.client.programs.disable(call.data[CONF_PROGRAM_ID]) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) @_verify_domain_control async def disable_zone(call): """Disable a zone.""" await rainmachine.client.zones.disable(call.data[CONF_ZONE_ID]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) @_verify_domain_control async def enable_program(call): """Enable a program.""" await rainmachine.client.programs.enable(call.data[CONF_PROGRAM_ID]) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) @_verify_domain_control async def enable_zone(call): """Enable a zone.""" await rainmachine.client.zones.enable(call.data[CONF_ZONE_ID]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) @_verify_domain_control async def pause_watering(call): """Pause watering for a set number of seconds.""" await rainmachine.client.watering.pause_all(call.data[CONF_SECONDS]) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) @_verify_domain_control async def start_program(call): """Start a particular program.""" await rainmachine.client.programs.start(call.data[CONF_PROGRAM_ID]) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) @_verify_domain_control async def start_zone(call): """Start a particular zone for a certain amount of time.""" await rainmachine.client.zones.start( call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME] ) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) @_verify_domain_control async def stop_all(call): """Stop all watering.""" await rainmachine.client.watering.stop_all() async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) @_verify_domain_control async def stop_program(call): """Stop a program.""" await rainmachine.client.programs.stop(call.data[CONF_PROGRAM_ID]) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) @_verify_domain_control async def stop_zone(call): """Stop a zone.""" await rainmachine.client.zones.stop(call.data[CONF_ZONE_ID]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) @_verify_domain_control async def unpause_watering(call): """Unpause watering.""" await rainmachine.client.watering.unpause_all() async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) 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) remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) remove_listener() for component in ("binary_sensor", "sensor", "switch"): await hass.config_entries.async_forward_entry_unload(config_entry, component) return True class RainMachine: """Define a generic RainMachine object.""" def __init__( self, client, binary_sensor_conditions, sensor_conditions, default_zone_runtime ): """Initialize.""" self.binary_sensor_conditions = binary_sensor_conditions self.client = client self.data = {} self.default_zone_runtime = default_zone_runtime self.device_mac = self.client.mac self.sensor_conditions = sensor_conditions async def async_update(self): """Update sensor/binary sensor data.""" from regenmaschine.errors import RainMachineError tasks = {} if TYPE_FLOW_SENSOR in self.binary_sensor_conditions or any( c in self.sensor_conditions for c in ( TYPE_FLOW_SENSOR_CLICK_M3, TYPE_FLOW_SENSOR_CONSUMED_LITERS, TYPE_FLOW_SENSOR_START_INDEX, TYPE_FLOW_SENSOR_WATERING_CLICKS, ) ): tasks[PROVISION_SETTINGS] = self.client.provisioning.settings() if any( c in self.binary_sensor_conditions for c in ( TYPE_FREEZE, TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, ) ): tasks[RESTRICTIONS_CURRENT] = self.client.restrictions.current() if ( any( c in self.binary_sensor_conditions for c in (TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS) ) or TYPE_FREEZE_TEMP in self.sensor_conditions ): tasks[RESTRICTIONS_UNIVERSAL] = self.client.restrictions.universal() results = await asyncio.gather(*tasks.values(), return_exceptions=True) for operation, result in zip(tasks, results): if isinstance(result, RainMachineError): _LOGGER.error( "There was an error while updating %s: %s", operation, result ) continue self.data[operation] = result class RainMachineEntity(Entity): """Define a generic RainMachine entity.""" def __init__(self, rainmachine): """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._device_class = None self._dispatcher_handlers = [] 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.client.mac)}, "name": self.rainmachine.client.name, "manufacturer": "RainMachine", "model": "Version {0} (API: {1})".format( self.rainmachine.client.hardware_version, self.rainmachine.client.api_version, ), "sw_version": self.rainmachine.client.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 async def async_will_remove_from_hass(self): """Disconnect dispatcher listener when removed.""" for handler in self._dispatcher_handlers: handler()