From 289c88ff71b3711d3ad69f398bc9661519091790 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 8 Aug 2017 01:49:25 -0600 Subject: [PATCH] Add RainMachine switch platform (#8827) * Add RainMachine switch platform * Updated requirements_all.txt * Cleaning up CI and coverage results * Small update to deal with older pylint * Fixed small indentation-based error * Added some more defensive try/except logic around calls * I'm not a fan of importing a library multiple times :) * Making PR-requested changes * Fixed ref to positional parameter * Attempting to fix broken linting * Ignoring no-value-for-parameter pylint error --- .coveragerc | 1 + .gitignore | 1 + .../components/switch/rainmachine.py | 305 ++++++++++++++++++ homeassistant/const.py | 1 + requirements_all.txt | 3 + 5 files changed, 311 insertions(+) create mode 100644 homeassistant/components/switch/rainmachine.py diff --git a/.coveragerc b/.coveragerc index 1aa034ca577e89..7cf0a78dc3114e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -539,6 +539,7 @@ omit = homeassistant/components/switch/orvibo.py homeassistant/components/switch/pilight.py homeassistant/components/switch/pulseaudio_loopback.py + homeassistant/components/switch/rainmachine.py homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/tplink.py diff --git a/.gitignore b/.gitignore index d5c29180e094ed..26efcc25b85f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ pyvenv.cfg pip-selfcheck.json venv .venv +Pipfile* # vimmy stuff *.swp diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py new file mode 100644 index 00000000000000..648fad21a8bc9e --- /dev/null +++ b/homeassistant/components/switch/rainmachine.py @@ -0,0 +1,305 @@ +"""Implements a RainMachine sprinkler controller for Home Assistant.""" + +import asyncio +from datetime import timedelta +from logging import getLogger + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + CONF_EMAIL, CONF_IP_ADDRESS, CONF_PASSWORD, + CONF_PLATFORM, CONF_SCAN_INTERVAL) +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['regenmaschine==0.3.2'] + +ATTR_CYCLES = 'cycles' +ATTR_TOTAL_DURATION = 'total_duration' + +CONF_HIDE_DISABLED_ENTITIES = 'hide_disabled_entities' +CONF_ZONE_RUN_TIME = 'zone_run_time' + +DEFAULT_ZONE_RUN_SECONDS = 60 * 10 + +MIN_SCAN_TIME_LOCAL = timedelta(seconds=1) +MIN_SCAN_TIME_REMOTE = timedelta(seconds=5) +MIN_SCAN_TIME_FORCED = timedelta(milliseconds=100) + +PLATFORM_SCHEMA = vol.Schema( + vol.All( + cv.has_at_least_one_key(CONF_IP_ADDRESS, CONF_EMAIL), + { + vol.Required(CONF_PLATFORM): + cv.string, + vol.Optional(CONF_SCAN_INTERVAL): + cv.time_period, + vol.Exclusive(CONF_IP_ADDRESS, 'auth'): + cv.string, + vol.Exclusive(CONF_EMAIL, 'auth'): + vol.Email(), # pylint: disable=no-value-for-parameter + vol.Required(CONF_PASSWORD): + cv.string, + vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN_SECONDS): + cv.positive_int, + vol.Optional(CONF_HIDE_DISABLED_ENTITIES, default=True): + cv.boolean + }), + extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set this component up under its platform.""" + import regenmaschine as rm + + ip_address = config.get(CONF_IP_ADDRESS) + _LOGGER.debug('IP address: %s', ip_address) + + email_address = config.get(CONF_EMAIL) + _LOGGER.debug('Email address: %s', email_address) + + password = config.get(CONF_PASSWORD) + _LOGGER.debug('Password: %s', password) + + hide_disabled_entities = config.get(CONF_HIDE_DISABLED_ENTITIES) + _LOGGER.debug('Show disabled entities: %s', hide_disabled_entities) + + zone_run_time = config.get(CONF_ZONE_RUN_TIME) + _LOGGER.debug('Zone run time: %s', zone_run_time) + + try: + if ip_address: + _LOGGER.debug('Configuring local API...') + auth = rm.Authenticator.create_local(ip_address, password) + elif email_address: + _LOGGER.debug('Configuring remote API...') + auth = rm.Authenticator.create_remote(email_address, password) + + _LOGGER.debug('Instantiating RainMachine client...') + client = rm.Client(auth) + + rainmachine_device_name = client.provision.device_name().get('name') + + entities = [] + for program in client.programs.all().get('programs'): + if hide_disabled_entities and program.get('active') is False: + continue + + _LOGGER.debug('Adding program: %s', program) + entities.append( + RainMachineProgram( + client, program, device_name=rainmachine_device_name)) + + for zone in client.zones.all().get('zones'): + if hide_disabled_entities and zone.get('active') is False: + continue + + _LOGGER.debug('Adding zone: %s', zone) + entities.append( + RainMachineZone( + client, + zone, + zone_run_time, + device_name=rainmachine_device_name, )) + + async_add_devices(entities) + except rm.exceptions.HTTPError as exc_info: + _LOGGER.error('An HTTP error occurred while talking with RainMachine') + _LOGGER.debug(exc_info) + return False + except UnboundLocalError as exc_info: + _LOGGER.error('Could not authenticate against RainMachine') + _LOGGER.debug(exc_info) + return False + + +def aware_throttle(api_type): + """Create an API type-aware throttler.""" + _decorator = None + if api_type == 'local': + + @Throttle(MIN_SCAN_TIME_LOCAL, MIN_SCAN_TIME_FORCED) + def decorator(function): + """Create a local API throttler.""" + return function + + _decorator = decorator + else: + + @Throttle(MIN_SCAN_TIME_REMOTE, MIN_SCAN_TIME_FORCED) + def decorator(function): + """Create a remote API throttler.""" + return function + + _decorator = decorator + + return _decorator + + +class RainMachineEntity(SwitchDevice): + """A class to represent a generic RainMachine entity.""" + + def __init__(self, client, entity_json, **kwargs): + """Initialize a generic RainMachine entity.""" + self._api_type = 'remote' if client.auth.using_remote_api else 'local' + self._client = client + self._device_name = kwargs.get('device_name') + self._entity_json = entity_json + + self._attrs = { + ATTR_ATTRIBUTION: '© RainMachine', + ATTR_DEVICE_CLASS: self._device_name + } + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + if self._client: + return self._attrs + + @property + def is_enabled(self) -> bool: + """Return whether the entity is enabled.""" + return self._entity_json.get('active') + + @property + def rainmachine_id(self) -> int: + """Return the RainMachine ID for this entity.""" + return self._entity_json.get('uid') + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{}.{}.{}'.format(self.__class__, self._device_name, + self.rainmachine_id) + + @aware_throttle('local') + def _local_update(self) -> None: + """Call an update with scan times appropriate for the local API.""" + self._update() + + @aware_throttle('remote') + def _remote_update(self) -> None: + """Call an update with scan times appropriate for the remote API.""" + self._update() + + def _update(self) -> None: # pylint: disable=no-self-use + """Logic for update method, regardless of API type.""" + raise NotImplementedError() + + def update(self) -> None: + """Determine how the entity updates itself.""" + if self._api_type == 'remote': + self._remote_update() + else: + self._local_update() + + +class RainMachineProgram(RainMachineEntity): + """A RainMachine program.""" + + @property + def is_on(self) -> bool: + """Return whether the program is running.""" + return bool(self._entity_json.get('status')) + + @property + def name(self) -> str: + """Return the name of the program.""" + return 'Program: {}'.format(self._entity_json.get('name')) + + def turn_off(self, **kwargs) -> None: + """Turn the program off.""" + import regenmaschine.exceptions as exceptions + + try: + self._client.programs.stop(self.rainmachine_id) + except exceptions.BrokenAPICall: + _LOGGER.error('programs.stop currently broken in remote API') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to turn off program "%s"', + self.rainmachine_id) + _LOGGER.debug(exc_info) + + def turn_on(self, **kwargs) -> None: + """Turn the program on.""" + import regenmaschine.exceptions as exceptions + + try: + self._client.programs.start(self.rainmachine_id) + except exceptions.BrokenAPICall: + _LOGGER.error('programs.start currently broken in remote API') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to turn on program "%s"', + self.rainmachine_id) + _LOGGER.debug(exc_info) + + def _update(self) -> None: + """Update info for the program.""" + import regenmaschine.exceptions as exceptions + + try: + self._entity_json = self._client.programs.get(self.rainmachine_id) + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update info for program "%s"', + self.rainmachine_id) + _LOGGER.debug(exc_info) + + +class RainMachineZone(RainMachineEntity): + """A RainMachine zone.""" + + def __init__(self, client, zone_json, zone_run_time, **kwargs): + """Initialize a RainMachine zone.""" + super().__init__(client, zone_json, **kwargs) + self._run_time = zone_run_time + self._attrs.update({ + ATTR_CYCLES: + self._entity_json.get('noOfCycles'), + ATTR_TOTAL_DURATION: + self._entity_json.get('userDuration') + }) + + @property + def is_on(self) -> bool: + """Return whether the zone is running.""" + return bool(self._entity_json.get('state')) + + @property + def name(self) -> str: + """Return the name of the zone.""" + return 'Zone: {}'.format(self._entity_json.get('name')) + + def turn_off(self, **kwargs) -> None: + """Turn the zone off.""" + import regenmaschine.exceptions as exceptions + + try: + self._client.zones.stop(self.rainmachine_id) + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to turn off zone "%s"', self.rainmachine_id) + _LOGGER.debug(exc_info) + + def turn_on(self, **kwargs) -> None: + """Turn the zone on.""" + import regenmaschine.exceptions as exceptions + + try: + self._client.zones.start(self.rainmachine_id, self._run_time) + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to turn on zone "%s"', self.rainmachine_id) + _LOGGER.debug(exc_info) + + def _update(self) -> None: + """Update info for the zone.""" + import regenmaschine.exceptions as exceptions + + try: + self._entity_json = self._client.zones.get(self.rainmachine_id) + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update info for zone "%s"', + self.rainmachine_id) + _LOGGER.debug(exc_info) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1fdf5b7d51ae83..85846e32f591ff 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -109,6 +109,7 @@ CONF_ICON_TEMPLATE = 'icon_template' CONF_INCLUDE = 'include' CONF_ID = 'id' +CONF_IP_ADDRESS = 'ip_address' CONF_LATITUDE = 'latitude' CONF_LONGITUDE = 'longitude' CONF_MAC = 'mac' diff --git a/requirements_all.txt b/requirements_all.txt index 1dc2c334676b90..223bba518a7206 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -825,6 +825,9 @@ radiotherm==1.3 # homeassistant.components.raspihats # raspihats==2.2.1 +# homeassistant.components.switch.rainmachine +regenmaschine==0.3.2 + # homeassistant.components.python_script restrictedpython==4.0a3