Skip to content

Commit

Permalink
Add RainMachine switch platform (home-assistant#8827)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bachya authored and MartinHjelmare committed Aug 8, 2017
1 parent 57f3bed commit 289c88f
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 0 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pyvenv.cfg
pip-selfcheck.json
venv
.venv
Pipfile*

# vimmy stuff
*.swp
Expand Down
305 changes: 305 additions & 0 deletions homeassistant/components/switch/rainmachine.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit 289c88f

Please sign in to comment.