From fc1bb58247b2a4320fdbea7b4f819c980d358bd1 Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Mon, 29 May 2017 04:15:27 -0500 Subject: [PATCH] Rachio (Sprinklers) (#7600) * Rachio platform started * Rachio tests * detect bad api token * Documentation, Code cleanup * Docstrings end with a period, log uses % * Fix arguments, default run time is now 10 minutes * Fix typo, remove todo (GH issue exists) * Revert polymer submodule commit * Use a RachioPy version with SSL cert validation * Update requirements --- .coveragerc | 3 + homeassistant/components/switch/rachio.py | 232 ++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 238 insertions(+) create mode 100644 homeassistant/components/switch/rachio.py diff --git a/.coveragerc b/.coveragerc index 4ba57e0f750..b02b4d12579 100644 --- a/.coveragerc +++ b/.coveragerc @@ -89,6 +89,9 @@ omit = homeassistant/components/qwikswitch.py homeassistant/components/*/qwikswitch.py + homeassistant/components/rachio.py + homeassistant/components/*/rachio.py + homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py new file mode 100644 index 00000000000..73183da1128 --- /dev/null +++ b/homeassistant/components/switch/rachio.py @@ -0,0 +1,232 @@ +"""Integration with the Rachio Iro sprinkler system controller.""" +import logging +from datetime import timedelta +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util as util +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import CONF_ACCESS_TOKEN + +REQUIREMENTS = ['https://github.com/Klikini/rachiopy' + '/archive/2c8996fcfa97a9f361a789e0c998797ed2805281.zip' + '#rachiopy==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RACHIO = 'rachio' + +CONF_MANUAL_RUN_MINS = 'manual_run_mins' +DEFAULT_MANUAL_RUN_MINS = 10 + +MIN_UPDATE_INTERVAL = timedelta(minutes=5) +MIN_FORCED_UPDATE_INTERVAL = timedelta(seconds=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_MANUAL_RUN_MINS, default=DEFAULT_MANUAL_RUN_MINS): + cv.positive_int +}) + + +# noinspection PyUnusedLocal +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the component.""" + # Get options + manual_run_mins = config.get(CONF_MANUAL_RUN_MINS) + _LOGGER.debug("Rachio run time is %d min", manual_run_mins) + + # Get access token + _LOGGER.debug("Getting Rachio access token...") + access_token = config.get(CONF_ACCESS_TOKEN) + + # Configure API + _LOGGER.debug("Configuring Rachio API...") + from rachiopy import Rachio + rachio = Rachio(access_token) + person = None + try: + person = _get_person(rachio) + except KeyError: + _LOGGER.error("Could not reach the Rachio API. " + "Is your access token valid?") + return False + + # Get and persist devices + devices = _list_devices(rachio, manual_run_mins) + if len(devices) == 0: + _LOGGER.error("No Rachio devices found in account " + + person['username']) + return False + else: + hass.data[DATA_RACHIO] = devices[0] + + if len(devices) > 1: + _LOGGER.warning("Multiple Rachio devices found in account, " + "using " + hass.data[DATA_RACHIO].device_id) + else: + _LOGGER.info("Found Rachio device") + + hass.data[DATA_RACHIO].update() + add_devices(hass.data[DATA_RACHIO].list_zones()) + return True + + +def _get_person(rachio): + """Pull the account info of the person whose access token was provided.""" + person_id = rachio.person.getInfo()[1]['id'] + return rachio.person.get(person_id)[1] + + +def _list_devices(rachio, manual_run_mins): + """Pull a list of devices on the account.""" + return [RachioIro(rachio, d['id'], manual_run_mins) + for d in _get_person(rachio)['devices']] + + +class RachioIro(object): + """Represents one Rachio Iro.""" + + def __init__(self, rachio, device_id, manual_run_mins): + """Initialize a new device.""" + self.rachio = rachio + self._device_id = device_id + self.manual_run_mins = manual_run_mins + self._device = None + self._running = None + self._zones = None + + def __str__(self): + """Display the device as a string.""" + return "Rachio Iro " + self.serial_number + + @property + def device_id(self): + """How the Rachio API refers to the device.""" + return self._device['id'] + + @property + def status(self): + """The current status of the device.""" + return self._device['status'] + + @property + def serial_number(self): + """The serial number of the device.""" + return self._device['serialNumber'] + + @property + def is_paused(self): + """Whether the device is temporarily disabled.""" + return self._device['paused'] + + @property + def is_on(self): + """Whether the device is powered on and connected.""" + return self._device['on'] + + @property + def current_schedule(self): + """The schedule that the device is running right now.""" + return self._running + + def list_zones(self, include_disabled=False): + """A list of the zones connected to the device and their data.""" + if not self._zones: + self._zones = [RachioZone(self.rachio, self, zone['id'], + self.manual_run_mins) + for zone in self._device['zones']] + + if include_disabled: + return self._zones + else: + self.update(no_throttle=True) + return [z for z in self._zones if z.is_enabled] + + @util.Throttle(MIN_UPDATE_INTERVAL, MIN_FORCED_UPDATE_INTERVAL) + def update(self, **kwargs): + """Pull updated device info from the Rachio API.""" + self._device = self.rachio.device.get(self._device_id)[1] + self._running = self.rachio.device\ + .getCurrentSchedule(self._device_id)[1] + + # Possibly update all zones + for zone in self.list_zones(include_disabled=True): + zone.update() + + _LOGGER.debug("Updated %s", str(self)) + + +class RachioZone(SwitchDevice): + """Represents one zone of sprinklers connected to the Rachio Iro.""" + + def __init__(self, rachio, device, zone_id, manual_run_mins): + """Initialize a new Rachio Zone.""" + self.rachio = rachio + self._device = device + self._zone_id = zone_id + self._zone = None + self._manual_run_secs = manual_run_mins * 60 + + def __str__(self): + """Display the zone as a string.""" + return "Rachio Zone " + self.name + + @property + def zone_id(self): + """How the Rachio API refers to the zone.""" + return self._zone['id'] + + @property + def unique_id(self): + """Generate a unique string ID for the zone.""" + return '{iro}-{zone}'.format( + iro=self._device.device_id, + zone=self.zone_id) + + @property + def number(self): + """The physical connection of the zone pump.""" + return self._zone['zoneNumber'] + + @property + def name(self): + """The friendly name of the zone.""" + return self._zone['name'] + + @property + def is_enabled(self): + """Whether the zone is allowed to run.""" + return self._zone['enabled'] + + @property + def is_on(self): + """Whether the zone is currently running.""" + self._device.update() + schedule = self._device.current_schedule + return self.zone_id == schedule.get('zoneId') + + def update(self): + """Pull updated zone info from the Rachio API.""" + self._zone = self.rachio.zone.get(self._zone_id)[1] + + # Possibly update device + self._device.update() + + _LOGGER.debug("Updated %s", str(self)) + + def turn_on(self): + """Start the zone.""" + # Convert minutes to seconds + seconds = self._manual_run_secs * 60 + + # Stop other zones first + self.turn_off() + + _LOGGER.info("Watering %s for %d sec", self.name, seconds) + self.rachio.zone.start(self.zone_id, seconds) + + def turn_off(self): + """Stop all zones.""" + _LOGGER.info("Stopping watering of all zones") + self.rachio.device.stopWater(self._device.device_id) diff --git a/requirements_all.txt b/requirements_all.txt index 18c40b7455f..47a3a4c7fb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,6 +261,9 @@ hikvision==0.4 # homeassistant.components.binary_sensor.workday holidays==0.8.1 +# homeassistant.components.switch.rachio +https://github.com/Klikini/rachiopy/archive/2c8996fcfa97a9f361a789e0c998797ed2805281.zip#rachiopy==0.1.1 + # homeassistant.components.switch.dlink https://github.com/LinuxChristian/pyW215/archive/v0.4.zip#pyW215==0.4