From a8444b22e76fbb2581a5da367aff477df604085e Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Sun, 18 Feb 2018 00:24:51 -0800 Subject: [PATCH] Support for August doorbell (#11124) * Add support for August doorbell * Address PR comment for August platform * Address PR comment for August binary sensor * Address PR comment for August camera * Addressed PR comment for August lock * - Fixed houndci-bot error * - Updated configurator description * - Fixed stale docstring * Added august module to .coveragerc --- .coveragerc | 3 + homeassistant/components/august.py | 257 ++++++++++++++++++ .../components/binary_sensor/august.py | 97 +++++++ homeassistant/components/camera/august.py | 76 ++++++ homeassistant/components/lock/august.py | 82 ++++++ requirements_all.txt | 3 + 6 files changed, 518 insertions(+) create mode 100644 homeassistant/components/august.py create mode 100644 homeassistant/components/binary_sensor/august.py create mode 100644 homeassistant/components/camera/august.py create mode 100644 homeassistant/components/lock/august.py diff --git a/.coveragerc b/.coveragerc index ec41cf05b7c..ada79ca8f27 100644 --- a/.coveragerc +++ b/.coveragerc @@ -38,6 +38,9 @@ omit = homeassistant/components/asterisk_mbox.py homeassistant/components/*/asterisk_mbox.py + homeassistant/components/august.py + homeassistant/components/*/august.py + homeassistant/components/axis.py homeassistant/components/*/axis.py diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py new file mode 100644 index 00000000000..c12e18ef09c --- /dev/null +++ b/homeassistant/components/august.py @@ -0,0 +1,257 @@ +""" +Support for August devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/august/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import RequestException + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT) +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +_CONFIGURING = {} + +REQUIREMENTS = ['py-august==0.3.0'] + +DEFAULT_TIMEOUT = 10 +ACTIVITY_FETCH_LIMIT = 10 +ACTIVITY_INITIAL_FETCH_LIMIT = 20 + +CONF_LOGIN_METHOD = 'login_method' +CONF_INSTALL_ID = 'install_id' + +NOTIFICATION_ID = 'august_notification' +NOTIFICATION_TITLE = "August Setup" + +AUGUST_CONFIG_FILE = '.august.conf' + +DATA_AUGUST = 'august' +DOMAIN = 'august' +DEFAULT_ENTITY_NAMESPACE = 'august' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) +LOGIN_METHODS = ['phone', 'email'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_INSTALL_ID): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + +AUGUST_COMPONENTS = [ + 'camera', 'binary_sensor', 'lock' +] + + +def request_configuration(hass, config, api, authenticator): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + def august_configuration_callback(data): + """Run when the configuration callback is called.""" + from august.authenticator import ValidationResult + + result = authenticator.validate_verification_code( + data.get('verification_code')) + + if result == ValidationResult.INVALID_VERIFICATION_CODE: + configurator.notify_errors(_CONFIGURING[DOMAIN], + "Invalid verification code") + elif result == ValidationResult.VALIDATED: + setup_august(hass, config, api, authenticator) + + if DOMAIN not in _CONFIGURING: + authenticator.send_verification_code() + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + login_method = conf.get(CONF_LOGIN_METHOD) + + _CONFIGURING[DOMAIN] = configurator.request_config( + NOTIFICATION_TITLE, + august_configuration_callback, + description="Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), + submit_caption='Verify', + fields=[{ + 'id': 'verification_code', + 'name': "Verification code", + 'type': 'string'}] + ) + + +def setup_august(hass, config, api, authenticator): + """Set up the August component.""" + from august.authenticator import AuthenticationState + + authentication = None + try: + authentication = authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + + hass.components.persistent_notification.create( + "Error: {}
" + "You will need to restart hass after fixing." + "".format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + state = authentication.state + + if state == AuthenticationState.AUTHENTICATED: + if DOMAIN in _CONFIGURING: + hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + + hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token) + + for component in AUGUST_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + elif state == AuthenticationState.BAD_PASSWORD: + return False + elif state == AuthenticationState.REQUIRES_VALIDATION: + request_configuration(hass, config, api, authenticator) + return True + + return False + + +def setup(hass, config): + """Set up the August component.""" + from august.api import Api + from august.authenticator import Authenticator + + conf = config[DOMAIN] + api = Api(timeout=conf.get(CONF_TIMEOUT)) + + authenticator = Authenticator( + api, + conf.get(CONF_LOGIN_METHOD), + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + install_id=conf.get(CONF_INSTALL_ID), + access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + + return setup_august(hass, config, api, authenticator) + + +class AugustData: + """August data object.""" + + def __init__(self, api, access_token): + """Init August data object.""" + self._api = api + self._access_token = access_token + self._doorbells = self._api.get_doorbells(self._access_token) or [] + self._locks = self._api.get_locks(self._access_token) or [] + self._house_ids = [d.house_id for d in self._doorbells + self._locks] + + self._doorbell_detail_by_id = {} + self._lock_status_by_id = {} + self._lock_detail_by_id = {} + self._activities_by_id = {} + + @property + def house_ids(self): + """Return a list of house_ids.""" + return self._house_ids + + @property + def doorbells(self): + """Return a list of doorbells.""" + return self._doorbells + + @property + def locks(self): + """Return a list of locks.""" + return self._locks + + def get_device_activities(self, device_id, *activity_types): + """Return a list of activities.""" + self._update_device_activities() + + activities = self._activities_by_id.get(device_id, []) + if activity_types: + return [a for a in activities if a.activity_type in activity_types] + return activities + + def get_latest_device_activity(self, device_id, *activity_types): + """Return latest activity.""" + activities = self.get_device_activities(device_id, *activity_types) + return next(iter(activities or []), None) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + """Update data object with latest from August API.""" + for house_id in self.house_ids: + activities = self._api.get_house_activities(self._access_token, + house_id, + limit=limit) + + device_ids = {a.device_id for a in activities} + for device_id in device_ids: + self._activities_by_id[device_id] = [a for a in activities if + a.device_id == device_id] + + def get_doorbell_detail(self, doorbell_id): + """Return doorbell detail.""" + self._update_doorbells() + return self._doorbell_detail_by_id.get(doorbell_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doorbells(self): + detail_by_id = {} + + for doorbell in self._doorbells: + detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + + self._doorbell_detail_by_id = detail_by_id + + def get_lock_status(self, lock_id): + """Return lock status.""" + self._update_locks() + return self._lock_status_by_id.get(lock_id) + + def get_lock_detail(self, lock_id): + """Return lock detail.""" + self._update_locks() + return self._lock_detail_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_locks(self): + status_by_id = {} + detail_by_id = {} + + for lock in self._locks: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + + self._lock_status_by_id = status_by_id + self._lock_detail_by_id = detail_by_id + + def lock(self, device_id): + """Lock the device.""" + return self._api.lock(self._access_token, device_id) + + def unlock(self, device_id): + """Unlock the device.""" + return self._api.unlock(self._access_token, device_id) diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py new file mode 100644 index 00000000000..8df50a1bfb6 --- /dev/null +++ b/homeassistant/components/binary_sensor/august.py @@ -0,0 +1,97 @@ +""" +Support for August binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.august/ +""" +from datetime import timedelta, datetime + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.binary_sensor import (BinarySensorDevice) + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_online_state(data, doorbell): + """Get the latest state of the sensor.""" + detail = data.get_doorbell_detail(doorbell.device_id) + return detail.is_online + + +def _retrieve_motion_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_MOTION, + ActivityType.DOORBELL_DING]) + + +def _retrieve_ding_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_DING]) + + +def _activity_time_based_state(data, doorbell, activity_types): + """Get the latest state of the sensor.""" + latest = data.get_latest_device_activity(doorbell.device_id, + *activity_types) + + if latest is not None: + start = latest.activity_start_time + end = latest.activity_end_time + timedelta(seconds=30) + return start <= datetime.now() <= end + return None + + +# Sensor types: Name, device_class, state_provider +SENSOR_TYPES = { + 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], + 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], + 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the August binary sensors.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + for sensor_type in SENSOR_TYPES: + devices.append(AugustBinarySensor(data, sensor_type, doorbell)) + + add_devices(devices, True) + + +class AugustBinarySensor(BinarySensorDevice): + """Representation of an August binary sensor.""" + + def __init__(self, data, sensor_type, doorbell): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._doorbell = doorbell + self._state = None + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._doorbell.device_name, + SENSOR_TYPES[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES[self._sensor_type][2] + self._state = state_provider(self._data, self._doorbell) diff --git a/homeassistant/components/camera/august.py b/homeassistant/components/camera/august.py new file mode 100644 index 00000000000..d3bc080bfc6 --- /dev/null +++ b/homeassistant/components/camera/august.py @@ -0,0 +1,76 @@ +""" +Support for August camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.august/ +""" +from datetime import timedelta + +import requests + +from homeassistant.components.august import DATA_AUGUST, DEFAULT_TIMEOUT +from homeassistant.components.camera import Camera + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August cameras.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class AugustCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, doorbell, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._doorbell = doorbell + self._timeout = timeout + self._image_url = None + self._image_content = None + + @property + def name(self): + """Return the name of this device.""" + return self._doorbell.device_name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._doorbell.has_subscription + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return True + + @property + def brand(self): + """Return the camera brand.""" + return 'August' + + @property + def model(self): + """Return the camera model.""" + return 'Doorbell' + + def camera_image(self): + """Return bytes of camera image.""" + latest = self._data.get_doorbell_detail(self._doorbell.device_id) + + if self._image_url is not latest.image_url: + self._image_url = latest.image_url + self._image_content = requests.get(self._image_url, + timeout=self._timeout).content + + return self._image_content diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py new file mode 100644 index 00000000000..9ca63cb493b --- /dev/null +++ b/homeassistant/components/lock/august.py @@ -0,0 +1,82 @@ +""" +Support for August lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.august/ +""" +from datetime import timedelta + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August locks.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for lock in data.locks: + devices.append(AugustLock(data, lock)) + + add_devices(devices, True) + + +class AugustLock(LockDevice): + """Representation of an August lock.""" + + def __init__(self, data, lock): + """Initialize the lock.""" + self._data = data + self._lock = lock + self._lock_status = None + self._lock_detail = None + self._changed_by = None + + def lock(self, **kwargs): + """Lock the device.""" + self._data.lock(self._lock.device_id) + + def unlock(self, **kwargs): + """Unlock the device.""" + self._data.unlock(self._lock.device_id) + + def update(self): + """Get the latest state of the sensor.""" + self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + + from august.activity import ActivityType + activity = self._data.get_latest_device_activity( + self._lock.device_id, + ActivityType.LOCK_OPERATION) + + if activity is not None: + self._changed_by = activity.operated_by + + @property + def name(self): + """Return the name of this device.""" + return self._lock.device_name + + @property + def is_locked(self): + """Return true if device is on.""" + from august.lock import LockStatus + return self._lock_status is LockStatus.LOCKED + + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, + } diff --git a/requirements_all.txt b/requirements_all.txt index 86368c45b93..e2c1b321090 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -612,6 +612,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.august +py-august==0.3.0 + # homeassistant.components.canary py-canary==0.4.0