Add Config flow to august (#32133)

* Add battery sensors for August devices

* Additional tests and cleanup in prep for config flow
  and device registry

* pylint

* update name for new style guidelines - https://developers.home-assistant.io/docs/development_guidelines/#use-new-style-string-formatting

* Config Flow for august push

* Update homeassistant/components/august/__init__.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* Address review items

* Update tests

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2020-02-25 08:18:15 -10:00 committed by GitHub
parent 900714a3ee
commit 2925e0617c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1686 additions and 444 deletions

View File

@ -0,0 +1,32 @@
{
"config" : {
"error" : {
"unknown" : "Unexpected error",
"cannot_connect" : "Failed to connect, please try again",
"invalid_auth" : "Invalid authentication"
},
"abort" : {
"already_configured" : "Account is already configured"
},
"step" : {
"validation" : {
"title" : "Two factor authentication",
"data" : {
"code" : "Verification code"
},
"description" : "Please check your {login_method} ({username}) and enter the verification code below"
},
"user" : {
"description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.",
"data" : {
"timeout" : "Timeout (seconds)",
"password" : "Password",
"username" : "Username",
"login_method" : "Login Method"
},
"title" : "Setup an August account"
}
},
"title" : "August"
}
}

View File

@ -4,62 +4,45 @@ from datetime import timedelta
from functools import partial from functools import partial
import logging import logging
from august.api import Api, AugustApiHTTPError from august.api import AugustApiHTTPError
from august.authenticator import AuthenticationState, Authenticator, ValidationResult from august.authenticator import ValidationResult
from requests import RequestException, Session from august.doorbell import Doorbell
from august.lock import Lock
from requests import RequestException
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
CONF_PASSWORD, from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
CONF_TIMEOUT, from homeassistant.core import HomeAssistant
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.util import Throttle from homeassistant.util import Throttle
from .const import (
AUGUST_COMPONENTS,
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DATA_AUGUST,
DEFAULT_AUGUST_CONFIG_FILE,
DEFAULT_NAME,
DEFAULT_TIMEOUT,
DOMAIN,
LOGIN_METHODS,
MIN_TIME_BETWEEN_ACTIVITY_UPDATES,
MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES,
MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES,
VERIFICATION_CODE_KEY,
)
from .exceptions import InvalidAuth, RequireValidation
from .gateway import AugustGateway
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_CONFIGURING = {} TWO_FA_REVALIDATE = "verify_configurator"
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"
# Limit battery, online, and hardware updates to 1800 seconds
# in order to reduce the number of api requests and
# avoid hitting rate limits
MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800)
# Doorbells need to update more frequently than locks
# since we get an image from the doorbell api. Once
# py-august 0.18.0 is released doorbell status updates
# can be reduced in the same was as locks have been
MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20)
# Activity needs to be checked more frequently as the
# doorbell motion and rings are included here
MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10)
DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) DEFAULT_SCAN_INTERVAL = timedelta(seconds=10)
LOGIN_METHODS = ["phone", "email"]
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
@ -75,138 +58,159 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"]
async def async_request_validation(hass, config_entry, august_gateway):
"""Request a new verification code from the user."""
def request_configuration(hass, config, api, authenticator, token_refresh_lock): #
"""Request configuration steps from the user.""" # In the future this should start a new config flow
# instead of using the legacy configurator
#
_LOGGER.error("Access token is no longer valid.")
configurator = hass.components.configurator configurator = hass.components.configurator
entry_id = config_entry.entry_id
def august_configuration_callback(data): async def async_august_configuration_validation_callback(data):
"""Run when the configuration callback is called.""" code = data.get(VERIFICATION_CODE_KEY)
result = await hass.async_add_executor_job(
result = authenticator.validate_verification_code(data.get("verification_code")) august_gateway.authenticator.validate_verification_code, code
)
if result == ValidationResult.INVALID_VERIFICATION_CODE: if result == ValidationResult.INVALID_VERIFICATION_CODE:
configurator.notify_errors( configurator.async_notify_errors(
_CONFIGURING[DOMAIN], "Invalid verification code" hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE],
"Invalid verification code, please make sure you are using the latest code and try again.",
) )
elif result == ValidationResult.VALIDATED: elif result == ValidationResult.VALIDATED:
setup_august(hass, config, api, authenticator, token_refresh_lock) return await async_setup_august(hass, config_entry, august_gateway)
if DOMAIN not in _CONFIGURING: return False
authenticator.send_verification_code()
conf = config[DOMAIN] if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]:
username = conf.get(CONF_USERNAME) await hass.async_add_executor_job(
login_method = conf.get(CONF_LOGIN_METHOD) august_gateway.authenticator.send_verification_code
)
_CONFIGURING[DOMAIN] = configurator.request_config( entry_data = config_entry.data
NOTIFICATION_TITLE, login_method = entry_data.get(CONF_LOGIN_METHOD)
august_configuration_callback, username = entry_data.get(CONF_USERNAME)
description=f"Please check your {login_method} ({username}) and enter the verification code below",
hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE] = configurator.async_request_config(
f"{DEFAULT_NAME} ({username})",
async_august_configuration_validation_callback,
description="August must be re-verified. Please check your {} ({}) and enter the verification "
"code below".format(login_method, username),
submit_caption="Verify", submit_caption="Verify",
fields=[ fields=[
{"id": "verification_code", "name": "Verification code", "type": "string"} {"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"}
], ],
) )
return
def setup_august(hass, config, api, authenticator, token_refresh_lock): async def async_setup_august(hass, config_entry, august_gateway):
"""Set up the August component.""" """Set up the August component."""
authentication = None entry_id = config_entry.entry_id
hass.data[DOMAIN].setdefault(entry_id, {})
try: try:
authentication = authenticator.authenticate() august_gateway.authenticate()
except RequestException as ex: except RequireValidation:
_LOGGER.error("Unable to connect to August service: %s", str(ex)) await async_request_validation(hass, config_entry, august_gateway)
hass.components.persistent_notification.create(
"Error: {ex}<br />You will need to restart hass after fixing.",
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(
hass, api, authentication, authenticator, token_refresh_lock
)
for component in AUGUST_COMPONENTS:
discovery.load_platform(hass, component, DOMAIN, {}, config)
return True
if state == AuthenticationState.BAD_PASSWORD:
_LOGGER.error("Invalid password provided")
return False return False
if state == AuthenticationState.REQUIRES_VALIDATION: except InvalidAuth:
request_configuration(hass, config, api, authenticator, token_refresh_lock) _LOGGER.error("Password is no longer valid. Please set up August again")
return False
# We still use the configurator to get a new 2fa code
# when needed since config_flow doesn't have a way
# to re-request if it expires
if TWO_FA_REVALIDATE in hass.data[DOMAIN][entry_id]:
hass.components.configurator.async_request_done(
hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE)
)
hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job(
AugustData, hass, august_gateway
)
for component in AUGUST_COMPONENTS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
return True
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the August component from YAML."""
conf = config.get(DOMAIN)
hass.data.setdefault(DOMAIN, {})
if not conf:
return True return True
return False hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_LOGIN_METHOD: conf.get(CONF_LOGIN_METHOD),
CONF_USERNAME: conf.get(CONF_USERNAME),
CONF_PASSWORD: conf.get(CONF_PASSWORD),
CONF_INSTALL_ID: conf.get(CONF_INSTALL_ID),
CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE,
},
)
)
return True
async def async_setup(hass, config): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up the August component.""" """Set up August from a config entry."""
conf = config[DOMAIN] august_gateway = AugustGateway(hass)
api_http_session = None august_gateway.async_setup(entry.data)
try:
api_http_session = Session()
except RequestException as ex:
_LOGGER.warning("Creating HTTP session failed with: %s", str(ex))
api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) return await async_setup_august(hass, entry, august_gateway)
authenticator = Authenticator(
api, async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
conf.get(CONF_LOGIN_METHOD), """Unload a config entry."""
conf.get(CONF_USERNAME), unload_ok = all(
conf.get(CONF_PASSWORD), await asyncio.gather(
install_id=conf.get(CONF_INSTALL_ID), *[
access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE), hass.config_entries.async_forward_entry_unload(entry, component)
for component in AUGUST_COMPONENTS
]
)
) )
def close_http_session(event): if unload_ok:
"""Close API sessions used to connect to August.""" hass.data[DOMAIN].pop(entry.entry_id)
_LOGGER.debug("Closing August HTTP sessions")
if api_http_session:
try:
api_http_session.close()
except RequestException:
pass
_LOGGER.debug("August HTTP session closed.") return unload_ok
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session)
_LOGGER.debug("Registered for Home Assistant stop event")
token_refresh_lock = asyncio.Lock()
return await hass.async_add_executor_job(
setup_august, hass, config, api, authenticator, token_refresh_lock
)
class AugustData: class AugustData:
"""August data object.""" """August data object."""
def __init__(self, hass, api, authentication, authenticator, token_refresh_lock): DEFAULT_ACTIVITY_FETCH_LIMIT = 10
def __init__(self, hass, august_gateway):
"""Init August data object.""" """Init August data object."""
self._hass = hass self._hass = hass
self._api = api self._august_gateway = august_gateway
self._authenticator = authenticator self._api = august_gateway.api
self._access_token = authentication.access_token
self._access_token_expires = authentication.access_token_expires
self._token_refresh_lock = token_refresh_lock self._doorbells = (
self._doorbells = self._api.get_doorbells(self._access_token) or [] self._api.get_doorbells(self._august_gateway.access_token) or []
self._locks = self._api.get_operable_locks(self._access_token) or [] )
self._locks = (
self._api.get_operable_locks(self._august_gateway.access_token) or []
)
self._house_ids = set() self._house_ids = set()
for device in self._doorbells + self._locks: for device in self._doorbells + self._locks:
self._house_ids.add(device.house_id) self._house_ids.add(device.house_id)
@ -218,7 +222,7 @@ class AugustData:
# We check the locks right away so we can # We check the locks right away so we can
# remove inoperative ones # remove inoperative ones
self._update_locks_detail() self._update_locks_detail()
self._update_doorbells_detail()
self._filter_inoperative_locks() self._filter_inoperative_locks()
@property @property
@ -236,22 +240,6 @@ class AugustData:
"""Return a list of locks.""" """Return a list of locks."""
return self._locks return self._locks
async def _async_refresh_access_token_if_needed(self):
"""Refresh the august access token if needed."""
if self._authenticator.should_refresh():
async with self._token_refresh_lock:
await self._hass.async_add_executor_job(self._refresh_access_token)
def _refresh_access_token(self):
refreshed_authentication = self._authenticator.refresh_access_token(force=False)
_LOGGER.info(
"Refreshed august access token. The old token expired at %s, and the new token expires at %s",
self._access_token_expires,
refreshed_authentication.access_token_expires,
)
self._access_token = refreshed_authentication.access_token
self._access_token_expires = refreshed_authentication.access_token_expires
async def async_get_device_activities(self, device_id, *activity_types): async def async_get_device_activities(self, device_id, *activity_types):
"""Return a list of activities.""" """Return a list of activities."""
_LOGGER.debug("Getting device activities for %s", device_id) _LOGGER.debug("Getting device activities for %s", device_id)
@ -268,22 +256,23 @@ class AugustData:
return next(iter(activities or []), None) return next(iter(activities or []), None)
@Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES) @Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES)
async def _async_update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): async def _async_update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT):
"""Update data object with latest from August API.""" """Update data object with latest from August API."""
# This is the only place we refresh the api token # This is the only place we refresh the api token
await self._async_refresh_access_token_if_needed() await self._august_gateway.async_refresh_access_token_if_needed()
return await self._hass.async_add_executor_job( return await self._hass.async_add_executor_job(
partial(self._update_device_activities, limit=ACTIVITY_FETCH_LIMIT) partial(self._update_device_activities, limit=limit)
) )
def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): def _update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT):
_LOGGER.debug("Start retrieving device activities") _LOGGER.debug("Start retrieving device activities")
for house_id in self.house_ids: for house_id in self.house_ids:
_LOGGER.debug("Updating device activity for house id %s", house_id) _LOGGER.debug("Updating device activity for house id %s", house_id)
activities = self._api.get_house_activities( activities = self._api.get_house_activities(
self._access_token, house_id, limit=limit self._august_gateway.access_token, house_id, limit=limit
) )
device_ids = {a.device_id for a in activities} device_ids = {a.device_id for a in activities}
@ -294,6 +283,14 @@ class AugustData:
_LOGGER.debug("Completed retrieving device activities") _LOGGER.debug("Completed retrieving device activities")
async def async_get_device_detail(self, device):
"""Return the detail for a device."""
if isinstance(device, Lock):
return await self.async_get_lock_detail(device.device_id)
if isinstance(device, Doorbell):
return await self.async_get_doorbell_detail(device.device_id)
raise ValueError
async def async_get_doorbell_detail(self, device_id): async def async_get_doorbell_detail(self, device_id):
"""Return doorbell detail.""" """Return doorbell detail."""
await self._async_update_doorbells_detail() await self._async_update_doorbells_detail()
@ -342,8 +339,11 @@ class AugustData:
_LOGGER.debug("Start retrieving %s detail", device_type) _LOGGER.debug("Start retrieving %s detail", device_type)
for device in devices: for device in devices:
device_id = device.device_id device_id = device.device_id
detail_by_id[device_id] = None
try: try:
detail_by_id[device_id] = api_call(self._access_token, device_id) detail_by_id[device_id] = api_call(
self._august_gateway.access_token, device_id
)
except RequestException as ex: except RequestException as ex:
_LOGGER.error( _LOGGER.error(
"Request error trying to retrieve %s details for %s. %s", "Request error trying to retrieve %s details for %s. %s",
@ -351,10 +351,6 @@ class AugustData:
device.device_name, device.device_name,
ex, ex,
) )
detail_by_id[device_id] = None
except Exception:
detail_by_id[device_id] = None
raise
_LOGGER.debug("Completed retrieving %s detail", device_type) _LOGGER.debug("Completed retrieving %s detail", device_type)
return detail_by_id return detail_by_id
@ -365,7 +361,7 @@ class AugustData:
self.get_lock_name(device_id), self.get_lock_name(device_id),
"lock", "lock",
self._api.lock_return_activities, self._api.lock_return_activities,
self._access_token, self._august_gateway.access_token,
device_id, device_id,
) )
@ -375,7 +371,7 @@ class AugustData:
self.get_lock_name(device_id), self.get_lock_name(device_id),
"unlock", "unlock",
self._api.unlock_return_activities, self._api.unlock_return_activities,
self._access_token, self._august_gateway.access_token,
device_id, device_id,
) )

View File

@ -6,43 +6,44 @@ from august.activity import ActivityType
from august.lock import LockDoorStatus from august.lock import LockDoorStatus
from august.util import update_lock_detail_from_activity from august.util import update_lock_detail_from_activity
from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OCCUPANCY,
BinarySensorDevice,
)
from . import DATA_AUGUST from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL = timedelta(seconds=5)
async def _async_retrieve_online_state(data, doorbell): async def _async_retrieve_online_state(data, detail):
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
detail = await data.async_get_doorbell_detail(doorbell.device_id) return detail.is_online or detail.status == "standby"
if detail is None:
return None
return detail.is_online
async def _async_retrieve_motion_state(data, doorbell): async def _async_retrieve_motion_state(data, detail):
return await _async_activity_time_based_state( return await _async_activity_time_based_state(
data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING] data,
detail.device_id,
[ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING],
) )
async def _async_retrieve_ding_state(data, doorbell): async def _async_retrieve_ding_state(data, detail):
return await _async_activity_time_based_state( return await _async_activity_time_based_state(
data, doorbell, [ActivityType.DOORBELL_DING] data, detail.device_id, [ActivityType.DOORBELL_DING]
) )
async def _async_activity_time_based_state(data, doorbell, activity_types): async def _async_activity_time_based_state(data, device_id, activity_types):
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
latest = await data.async_get_latest_device_activity( latest = await data.async_get_latest_device_activity(device_id, *activity_types)
doorbell.device_id, *activity_types
)
if latest is not None: if latest is not None:
start = latest.activity_start_time start = latest.activity_start_time
@ -57,15 +58,19 @@ SENSOR_STATE_PROVIDER = 2
# sensor_type: [name, device_class, async_state_provider] # sensor_type: [name, device_class, async_state_provider]
SENSOR_TYPES_DOORBELL = { SENSOR_TYPES_DOORBELL = {
"doorbell_ding": ["Ding", "occupancy", _async_retrieve_ding_state], "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _async_retrieve_ding_state],
"doorbell_motion": ["Motion", "motion", _async_retrieve_motion_state], "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _async_retrieve_motion_state],
"doorbell_online": ["Online", "connectivity", _async_retrieve_online_state], "doorbell_online": [
"Online",
DEVICE_CLASS_CONNECTIVITY,
_async_retrieve_online_state,
],
} }
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the August binary sensors.""" """Set up the August binary sensors."""
data = hass.data[DATA_AUGUST] data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = [] devices = []
for door in data.locks: for door in data.locks:
@ -98,6 +103,7 @@ class AugustDoorBinarySensor(BinarySensorDevice):
self._door = door self._door = door
self._state = None self._state = None
self._available = False self._available = False
self._firmware_version = None
@property @property
def available(self): def available(self):
@ -132,6 +138,7 @@ class AugustDoorBinarySensor(BinarySensorDevice):
lock_door_state = None lock_door_state = None
if detail is not None: if detail is not None:
lock_door_state = detail.door_state lock_door_state = detail.door_state
self._firmware_version = detail.firmware_version
self._available = lock_door_state != LockDoorStatus.UNKNOWN self._available = lock_door_state != LockDoorStatus.UNKNOWN
self._state = lock_door_state == LockDoorStatus.OPEN self._state = lock_door_state == LockDoorStatus.OPEN
@ -141,6 +148,16 @@ class AugustDoorBinarySensor(BinarySensorDevice):
"""Get the unique of the door open binary sensor.""" """Get the unique of the door open binary sensor."""
return f"{self._door.device_id}_open" return f"{self._door.device_id}_open"
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._door.device_id)},
"name": self._door.device_name,
"manufacturer": DEFAULT_NAME,
"sw_version": self._firmware_version,
}
class AugustDoorbellBinarySensor(BinarySensorDevice): class AugustDoorbellBinarySensor(BinarySensorDevice):
"""Representation of an August binary sensor.""" """Representation of an August binary sensor."""
@ -152,6 +169,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice):
self._doorbell = doorbell self._doorbell = doorbell
self._state = None self._state = None
self._available = False self._available = False
self._firmware_version = None
@property @property
def available(self): def available(self):
@ -178,11 +196,21 @@ class AugustDoorbellBinarySensor(BinarySensorDevice):
async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][
SENSOR_STATE_PROVIDER SENSOR_STATE_PROVIDER
] ]
self._state = await async_state_provider(self._data, self._doorbell) detail = await self._data.async_get_doorbell_detail(self._doorbell.device_id)
# The doorbell will go into standby mode when there is no motion # The doorbell will go into standby mode when there is no motion
# for a short while. It will wake by itself when needed so we need # for a short while. It will wake by itself when needed so we need
# to consider is available or we will not report motion or dings # to consider is available or we will not report motion or dings
self._available = self._doorbell.is_online or self._doorbell.status == "standby" if self.device_class == DEVICE_CLASS_CONNECTIVITY:
self._available = True
else:
self._available = detail is not None and (
detail.is_online or detail.status == "standby"
)
self._state = None
if detail is not None:
self._firmware_version = detail.firmware_version
self._state = await async_state_provider(self._data, detail)
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
@ -191,3 +219,13 @@ class AugustDoorbellBinarySensor(BinarySensorDevice):
f"{self._doorbell.device_id}_" f"{self._doorbell.device_id}_"
f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}"
) )
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._doorbell.device_id)},
"name": self._doorbell.device_name,
"manufacturer": "August",
"sw_version": self._firmware_version,
}

View File

@ -5,14 +5,14 @@ import requests
from homeassistant.components.camera import Camera from homeassistant.components.camera import Camera
from . import DATA_AUGUST, DEFAULT_TIMEOUT from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN
SCAN_INTERVAL = timedelta(seconds=10) SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up August cameras.""" """Set up August cameras."""
data = hass.data[DATA_AUGUST] data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = [] devices = []
for doorbell in data.doorbells: for doorbell in data.doorbells:
@ -29,9 +29,11 @@ class AugustCamera(Camera):
super().__init__() super().__init__()
self._data = data self._data = data
self._doorbell = doorbell self._doorbell = doorbell
self._doorbell_detail = None
self._timeout = timeout self._timeout = timeout
self._image_url = None self._image_url = None
self._image_content = None self._image_content = None
self._firmware_version = None
@property @property
def name(self): def name(self):
@ -51,7 +53,7 @@ class AugustCamera(Camera):
@property @property
def brand(self): def brand(self):
"""Return the camera brand.""" """Return the camera brand."""
return "August" return DEFAULT_NAME
@property @property
def model(self): def model(self):
@ -60,16 +62,30 @@ class AugustCamera(Camera):
async def async_camera_image(self): async def async_camera_image(self):
"""Return bytes of camera image.""" """Return bytes of camera image."""
latest = await self._data.async_get_doorbell_detail(self._doorbell.device_id) self._doorbell_detail = await self._data.async_get_doorbell_detail(
self._doorbell.device_id
)
if self._doorbell_detail is None:
return None
if self._image_url is not latest.image_url: if self._image_url is not self._doorbell_detail.image_url:
self._image_url = latest.image_url self._image_url = self._doorbell_detail.image_url
self._image_content = await self.hass.async_add_executor_job( self._image_content = await self.hass.async_add_executor_job(
self._camera_image self._camera_image
) )
return self._image_content return self._image_content
async def async_update(self):
"""Update camera data."""
self._doorbell_detail = await self._data.async_get_doorbell_detail(
self._doorbell.device_id
)
if self._doorbell_detail is None:
return None
self._firmware_version = self._doorbell_detail.firmware_version
def _camera_image(self): def _camera_image(self):
"""Return bytes of camera image via http get.""" """Return bytes of camera image via http get."""
# Move this to py-august: see issue#32048 # Move this to py-august: see issue#32048
@ -79,3 +95,13 @@ class AugustCamera(Camera):
def unique_id(self) -> str: def unique_id(self) -> str:
"""Get the unique id of the camera.""" """Get the unique id of the camera."""
return f"{self._doorbell.device_id:s}_camera" return f"{self._doorbell.device_id:s}_camera"
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._doorbell.device_id)},
"name": self._doorbell.device_name + " Camera",
"manufacturer": DEFAULT_NAME,
"sw_version": self._firmware_version,
}

View File

@ -0,0 +1,135 @@
"""Config flow for August integration."""
import logging
from august.authenticator import ValidationResult
import voluptuous as vol
from homeassistant import config_entries, core
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from .const import (
CONF_LOGIN_METHOD,
DEFAULT_TIMEOUT,
LOGIN_METHODS,
VERIFICATION_CODE_KEY,
)
from .const import DOMAIN # pylint:disable=unused-import
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
from .gateway import AugustGateway
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOGIN_METHOD, default="phone"): vol.In(LOGIN_METHODS),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
}
)
async def async_validate_input(
hass: core.HomeAssistant, data, august_gateway,
):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
Request configuration steps from the user.
"""
code = data.get(VERIFICATION_CODE_KEY)
if code is not None:
result = await hass.async_add_executor_job(
august_gateway.authenticator.validate_verification_code, code
)
_LOGGER.debug("Verification code validation: %s", result)
if result != ValidationResult.VALIDATED:
raise RequireValidation
try:
august_gateway.authenticate()
except RequireValidation:
_LOGGER.debug(
"Requesting new verification code for %s via %s",
data.get(CONF_USERNAME),
data.get(CONF_LOGIN_METHOD),
)
if code is None:
await hass.async_add_executor_job(
august_gateway.authenticator.send_verification_code
)
raise
return {
"title": data.get(CONF_USERNAME),
"data": august_gateway.config_entry(),
}
class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for August."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Store an AugustGateway()."""
self._august_gateway = None
self.user_auth_details = {}
super().__init__()
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if self._august_gateway is None:
self._august_gateway = AugustGateway(self.hass)
errors = {}
if user_input is not None:
self._august_gateway.async_setup(user_input)
try:
info = await async_validate_input(
self.hass, user_input, self._august_gateway,
)
await self.async_set_unique_id(user_input[CONF_USERNAME])
return self.async_create_entry(title=info["title"], data=info["data"])
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except RequireValidation:
self.user_auth_details = user_input
return await self.async_step_validation()
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_validation(self, user_input=None):
"""Handle validation (2fa) step."""
if user_input:
return await self.async_step_user({**self.user_auth_details, **user_input})
return self.async_show_form(
step_id="validation",
data_schema=vol.Schema(
{vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)}
),
description_placeholders={
CONF_USERNAME: self.user_auth_details.get(CONF_USERNAME),
CONF_LOGIN_METHOD: self.user_auth_details.get(CONF_LOGIN_METHOD),
},
)
async def async_step_import(self, user_input):
"""Handle import."""
await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured()
return await self.async_step_user(user_input)

View File

@ -0,0 +1,42 @@
"""Constants for August devices."""
from datetime import timedelta
DEFAULT_TIMEOUT = 10
CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file"
CONF_LOGIN_METHOD = "login_method"
CONF_INSTALL_ID = "install_id"
VERIFICATION_CODE_KEY = "verification_code"
NOTIFICATION_ID = "august_notification"
NOTIFICATION_TITLE = "August"
DEFAULT_AUGUST_CONFIG_FILE = ".august.conf"
DATA_AUGUST = "data_august"
DEFAULT_NAME = "August"
DOMAIN = "august"
# Limit battery, online, and hardware updates to 1800 seconds
# in order to reduce the number of api requests and
# avoid hitting rate limits
MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800)
# Doorbells need to update more frequently than locks
# since we get an image from the doorbell api. Once
# py-august 0.18.0 is released doorbell status updates
# can be reduced in the same was as locks have been
MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20)
# Activity needs to be checked more frequently as the
# doorbell motion and rings are included here
MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10)
DEFAULT_SCAN_INTERVAL = timedelta(seconds=10)
LOGIN_METHODS = ["phone", "email"]
AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock", "sensor"]

View File

@ -0,0 +1,15 @@
"""Shared excecption for the august integration."""
from homeassistant import exceptions
class RequireValidation(exceptions.HomeAssistantError):
"""Error to indicate we require validation (2fa)."""
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -0,0 +1,143 @@
"""Handle August connection setup and authentication."""
import asyncio
import logging
from august.api import Api
from august.authenticator import AuthenticationState, Authenticator
from requests import RequestException, Session
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import callback
from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DEFAULT_AUGUST_CONFIG_FILE,
VERIFICATION_CODE_KEY,
)
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
_LOGGER = logging.getLogger(__name__)
class AugustGateway:
"""Handle the connection to August."""
def __init__(self, hass):
"""Init the connection."""
self._api_http_session = Session()
self._token_refresh_lock = asyncio.Lock()
self._hass = hass
self._config = None
self._api = None
self._authenticator = None
self._authentication = None
@property
def authenticator(self):
"""August authentication object from py-august."""
return self._authenticator
@property
def authentication(self):
"""August authentication object from py-august."""
return self._authentication
@property
def access_token(self):
"""Access token for the api."""
return self._authentication.access_token
@property
def api(self):
"""August api object from py-august."""
return self._api
def config_entry(self):
"""Config entry."""
return {
CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD],
CONF_USERNAME: self._config[CONF_USERNAME],
CONF_PASSWORD: self._config[CONF_PASSWORD],
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
CONF_TIMEOUT: self._config.get(CONF_TIMEOUT),
CONF_ACCESS_TOKEN_CACHE_FILE: self._config[CONF_ACCESS_TOKEN_CACHE_FILE],
}
@callback
def async_setup(self, conf):
"""Create the api and authenticator objects."""
if conf.get(VERIFICATION_CODE_KEY):
return
if conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) is None:
conf[
CONF_ACCESS_TOKEN_CACHE_FILE
] = f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}"
self._config = conf
self._api = Api(
timeout=self._config.get(CONF_TIMEOUT), http_session=self._api_http_session,
)
self._authenticator = Authenticator(
self._api,
self._config[CONF_LOGIN_METHOD],
self._config[CONF_USERNAME],
self._config[CONF_PASSWORD],
install_id=self._config.get(CONF_INSTALL_ID),
access_token_cache_file=self._hass.config.path(
self._config[CONF_ACCESS_TOKEN_CACHE_FILE]
),
)
def authenticate(self):
"""Authenticate with the details provided to setup."""
self._authentication = None
try:
self._authentication = self.authenticator.authenticate()
except RequestException as ex:
_LOGGER.error("Unable to connect to August service: %s", str(ex))
raise CannotConnect
if self._authentication.state == AuthenticationState.BAD_PASSWORD:
raise InvalidAuth
if self._authentication.state == AuthenticationState.REQUIRES_VALIDATION:
raise RequireValidation
if self._authentication.state != AuthenticationState.AUTHENTICATED:
_LOGGER.error(
"Unknown authentication state: %s", self._authentication.state
)
raise InvalidAuth
return self._authentication
async def async_refresh_access_token_if_needed(self):
"""Refresh the august access token if needed."""
if self.authenticator.should_refresh():
async with self._token_refresh_lock:
await self._hass.async_add_executor_job(self._refresh_access_token)
def _refresh_access_token(self):
refreshed_authentication = self.authenticator.refresh_access_token(force=False)
_LOGGER.info(
"Refreshed august access token. The old token expired at %s, and the new token expires at %s",
self.authentication.access_token_expires,
refreshed_authentication.access_token_expires,
)
self._authentication = refreshed_authentication
def _close_http_session(self):
"""Close API sessions used to connect to August."""
if self._api_http_session:
try:
self._api_http_session.close()
except RequestException:
pass
def __del__(self):
"""Close out the http session on destroy."""
self._close_http_session()

View File

@ -9,16 +9,16 @@ from august.util import update_lock_detail_from_activity
from homeassistant.components.lock import LockDevice from homeassistant.components.lock import LockDevice
from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.const import ATTR_BATTERY_LEVEL
from . import DATA_AUGUST from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up August locks.""" """Set up August locks."""
data = hass.data[DATA_AUGUST] data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = [] devices = []
for lock in data.locks: for lock in data.locks:
@ -39,6 +39,7 @@ class AugustLock(LockDevice):
self._lock_detail = None self._lock_detail = None
self._changed_by = None self._changed_by = None
self._available = False self._available = False
self._firmware_version = None
async def async_lock(self, **kwargs): async def async_lock(self, **kwargs):
"""Lock the device.""" """Lock the device."""
@ -59,12 +60,18 @@ class AugustLock(LockDevice):
self.schedule_update_ha_state() self.schedule_update_ha_state()
def _update_lock_status_from_detail(self): def _update_lock_status_from_detail(self):
lock_status = self._lock_detail.lock_status detail = self._lock_detail
if self._lock_status != lock_status: lock_status = None
self._lock_status = lock_status self._available = False
if detail is not None:
lock_status = detail.lock_status
self._available = ( self._available = (
lock_status is not None and lock_status != LockStatus.UNKNOWN lock_status is not None and lock_status != LockStatus.UNKNOWN
) )
if self._lock_status != lock_status:
self._lock_status = lock_status
return True return True
return False return False
@ -77,7 +84,11 @@ class AugustLock(LockDevice):
if lock_activity is not None: if lock_activity is not None:
self._changed_by = lock_activity.operated_by self._changed_by = lock_activity.operated_by
update_lock_detail_from_activity(self._lock_detail, lock_activity) if self._lock_detail is not None:
update_lock_detail_from_activity(self._lock_detail, lock_activity)
if self._lock_detail is not None:
self._firmware_version = self._lock_detail.firmware_version
self._update_lock_status_from_detail() self._update_lock_status_from_detail()
@ -94,7 +105,8 @@ class AugustLock(LockDevice):
@property @property
def is_locked(self): def is_locked(self):
"""Return true if device is on.""" """Return true if device is on."""
if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN:
return None
return self._lock_status is LockStatus.LOCKED return self._lock_status is LockStatus.LOCKED
@property @property
@ -115,6 +127,16 @@ class AugustLock(LockDevice):
return attributes return attributes
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._lock.device_id)},
"name": self._lock.device_name,
"manufacturer": DEFAULT_NAME,
"sw_version": self._firmware_version,
}
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
"""Get the unique id of the lock.""" """Get the unique id of the lock."""

View File

@ -2,7 +2,14 @@
"domain": "august", "domain": "august",
"name": "August", "name": "August",
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"requirements": ["py-august==0.17.0"], "requirements": [
"dependencies": ["configurator"], "py-august==0.17.0"
"codeowners": ["@bdraco"] ],
} "dependencies": [
"configurator"
],
"codeowners": [
"@bdraco"
],
"config_flow": true
}

View File

@ -0,0 +1,158 @@
"""Support for August sensors."""
from datetime import timedelta
import logging
from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
from homeassistant.helpers.entity import Entity
from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5)
async def _async_retrieve_device_battery_state(detail):
"""Get the latest state of the sensor."""
if detail is None:
return None
return detail.battery_level
async def _async_retrieve_linked_keypad_battery_state(detail):
"""Get the latest state of the sensor."""
if detail is None:
return None
if detail.keypad is None:
return None
battery_level = detail.keypad.battery_level
_LOGGER.debug("keypad battery level: %s %s", battery_level, battery_level.lower())
if battery_level.lower() == "full":
return 100
if battery_level.lower() == "medium":
return 60
if battery_level.lower() == "low":
return 10
return 0
SENSOR_TYPES_BATTERY = {
"device_battery": {
"name": "Battery",
"async_state_provider": _async_retrieve_device_battery_state,
},
"linked_keypad_battery": {
"name": "Keypad Battery",
"async_state_provider": _async_retrieve_linked_keypad_battery_state,
},
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the August sensors."""
data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = []
batteries = {
"device_battery": [],
"linked_keypad_battery": [],
}
for device in data.doorbells:
batteries["device_battery"].append(device)
for device in data.locks:
batteries["device_battery"].append(device)
batteries["linked_keypad_battery"].append(device)
for sensor_type in SENSOR_TYPES_BATTERY:
for device in batteries[sensor_type]:
async_state_provider = SENSOR_TYPES_BATTERY[sensor_type][
"async_state_provider"
]
detail = await data.async_get_device_detail(device)
state = await async_state_provider(detail)
sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"]
if state is None:
_LOGGER.debug(
"Not adding battery sensor %s for %s because it is not present",
sensor_name,
device.device_name,
)
else:
_LOGGER.debug(
"Adding battery sensor %s for %s", sensor_name, device.device_name,
)
devices.append(AugustBatterySensor(data, sensor_type, device))
async_add_entities(devices, True)
class AugustBatterySensor(Entity):
"""Representation of an August sensor."""
def __init__(self, data, sensor_type, device):
"""Initialize the sensor."""
self._data = data
self._sensor_type = sensor_type
self._device = device
self._state = None
self._available = False
self._firmware_version = None
@property
def available(self):
"""Return the availability of this sensor."""
return self._available
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return "%" # UNIT_PERCENTAGE will be available after PR#32094
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return DEVICE_CLASS_BATTERY
@property
def name(self):
"""Return the name of the sensor."""
device_name = self._device.device_name
sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"]
return f"{device_name} {sensor_name}"
async def async_update(self):
"""Get the latest state of the sensor."""
async_state_provider = SENSOR_TYPES_BATTERY[self._sensor_type][
"async_state_provider"
]
detail = await self._data.async_get_device_detail(self._device)
self._state = await async_state_provider(detail)
self._available = self._state is not None
if detail is not None:
self._firmware_version = detail.firmware_version
@property
def unique_id(self) -> str:
"""Get the unique id of the device sensor."""
return f"{self._device.device_id}_{self._sensor_type}"
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._device.device_id)},
"name": self._device.device_name,
"manufacturer": DEFAULT_NAME,
"sw_version": self._firmware_version,
}

View File

@ -0,0 +1,32 @@
{
"config" : {
"error" : {
"unknown" : "Unexpected error",
"cannot_connect" : "Failed to connect, please try again",
"invalid_auth" : "Invalid authentication"
},
"abort" : {
"already_configured" : "Account is already configured"
},
"step" : {
"validation" : {
"title" : "Two factor authentication",
"data" : {
"code" : "Verification code"
},
"description" : "Please check your {login_method} ({username}) and enter the verification code below"
},
"user" : {
"description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.",
"data" : {
"timeout" : "Timeout (seconds)",
"password" : "Password",
"username" : "Username",
"login_method" : "Login Method"
},
"title" : "Setup an August account"
}
},
"title" : "August"
}
}

View File

@ -12,6 +12,7 @@ FLOWS = [
"almond", "almond",
"ambiclimate", "ambiclimate",
"ambient_station", "ambient_station",
"august",
"axis", "axis",
"brother", "brother",
"cast", "cast",

View File

@ -1,16 +1,13 @@
"""Mocks for the august component.""" """Mocks for the august component."""
import datetime
import json import json
import os import os
import time import time
from unittest.mock import MagicMock, PropertyMock from unittest.mock import MagicMock, PropertyMock
from asynctest import mock from asynctest import mock
from august.activity import Activity, DoorOperationActivity, LockOperationActivity from august.activity import DoorOperationActivity, LockOperationActivity
from august.api import Api
from august.authenticator import AuthenticationState from august.authenticator import AuthenticationState
from august.doorbell import Doorbell, DoorbellDetail from august.doorbell import Doorbell, DoorbellDetail
from august.exceptions import AugustApiHTTPError
from august.lock import Lock, LockDetail from august.lock import Lock, LockDetail
from homeassistant.components.august import ( from homeassistant.components.august import (
@ -18,10 +15,8 @@ from homeassistant.components.august import (
CONF_PASSWORD, CONF_PASSWORD,
CONF_USERNAME, CONF_USERNAME,
DOMAIN, DOMAIN,
AugustData,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt
from tests.common import load_fixture from tests.common import load_fixture
@ -37,8 +32,8 @@ def _mock_get_config():
} }
@mock.patch("homeassistant.components.august.Api") @mock.patch("homeassistant.components.august.gateway.Api")
@mock.patch("homeassistant.components.august.Authenticator.authenticate") @mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate")
async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock): async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock):
"""Set up august integration.""" """Set up august integration."""
authenticate_mock.side_effect = MagicMock( authenticate_mock.side_effect = MagicMock(
@ -84,6 +79,9 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None)
def get_lock_detail_side_effect(access_token, device_id): def get_lock_detail_side_effect(access_token, device_id):
return _get_device_detail("locks", device_id) return _get_device_detail("locks", device_id)
def get_doorbell_detail_side_effect(access_token, device_id):
return _get_device_detail("doorbells", device_id)
def get_operable_locks_side_effect(access_token): def get_operable_locks_side_effect(access_token):
return _get_base_devices("locks") return _get_base_devices("locks")
@ -109,6 +107,8 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None)
if "get_lock_detail" not in api_call_side_effects: if "get_lock_detail" not in api_call_side_effects:
api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect
if "get_doorbell_detail" not in api_call_side_effects:
api_call_side_effects["get_doorbell_detail"] = get_doorbell_detail_side_effect
if "get_operable_locks" not in api_call_side_effects: if "get_operable_locks" not in api_call_side_effects:
api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect
if "get_doorbells" not in api_call_side_effects: if "get_doorbells" not in api_call_side_effects:
@ -143,6 +143,11 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects):
if api_call_side_effects["get_doorbells"]: if api_call_side_effects["get_doorbells"]:
api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"] api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"]
if api_call_side_effects["get_doorbell_detail"]:
api_instance.get_doorbell_detail.side_effect = api_call_side_effects[
"get_doorbell_detail"
]
if api_call_side_effects["get_house_activities"]: if api_call_side_effects["get_house_activities"]:
api_instance.get_house_activities.side_effect = api_call_side_effects[ api_instance.get_house_activities.side_effect = api_call_side_effects[
"get_house_activities" "get_house_activities"
@ -160,106 +165,6 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects):
return await _mock_setup_august(hass, api_instance) return await _mock_setup_august(hass, api_instance)
class MockAugustApiFailing(Api):
"""A mock for py-august Api class that always has an AugustApiHTTPError."""
def _call_api(self, *args, **kwargs):
"""Mock the time activity started."""
raise AugustApiHTTPError("This should bubble up as its user consumable")
class MockActivity(Activity):
"""A mock for py-august Activity class."""
def __init__(
self, action=None, activity_start_timestamp=None, activity_end_timestamp=None
):
"""Init the py-august Activity class mock."""
self._action = action
self._activity_start_timestamp = activity_start_timestamp
self._activity_end_timestamp = activity_end_timestamp
@property
def activity_start_time(self):
"""Mock the time activity started."""
return datetime.datetime.fromtimestamp(self._activity_start_timestamp)
@property
def activity_end_time(self):
"""Mock the time activity ended."""
return datetime.datetime.fromtimestamp(self._activity_end_timestamp)
@property
def action(self):
"""Mock the action."""
return self._action
class MockAugustComponentData(AugustData):
"""A wrapper to mock AugustData."""
# AugustData support multiple locks, however for the purposes of
# mocking we currently only mock one lockid
def __init__(
self,
last_lock_status_update_timestamp=1,
last_door_state_update_timestamp=1,
api=MockAugustApiFailing(),
access_token="mocked_access_token",
locks=[],
doorbells=[],
):
"""Mock AugustData."""
self._last_lock_status_update_time_utc = dt.as_utc(
datetime.datetime.fromtimestamp(last_lock_status_update_timestamp)
)
self._last_door_state_update_time_utc = dt.as_utc(
datetime.datetime.fromtimestamp(last_lock_status_update_timestamp)
)
self._api = api
self._access_token = access_token
self._locks = locks
self._doorbells = doorbells
self._lock_status_by_id = {}
self._lock_last_status_update_time_utc_by_id = {}
def set_mocked_locks(self, locks):
"""Set lock mocks."""
self._locks = locks
def set_mocked_doorbells(self, doorbells):
"""Set doorbell mocks."""
self._doorbells = doorbells
def get_last_lock_status_update_time_utc(self, device_id):
"""Mock to get last lock status update time."""
return self._last_lock_status_update_time_utc
def set_last_lock_status_update_time_utc(self, device_id, update_time):
"""Mock to set last lock status update time."""
self._last_lock_status_update_time_utc = update_time
def get_last_door_state_update_time_utc(self, device_id):
"""Mock to get last door state update time."""
return self._last_door_state_update_time_utc
def set_last_door_state_update_time_utc(self, device_id, update_time):
"""Mock to set last door state update time."""
self._last_door_state_update_time_utc = update_time
def _mock_august_authenticator():
authenticator = MagicMock(name="august.authenticator")
authenticator.should_refresh = MagicMock(
name="august.authenticator.should_refresh", return_value=0
)
authenticator.refresh_access_token = MagicMock(
name="august.authenticator.refresh_access_token"
)
return authenticator
def _mock_august_authentication(token_text, token_timestamp): def _mock_august_authentication(token_text, token_timestamp):
authentication = MagicMock(name="august.authentication") authentication = MagicMock(name="august.authentication")
type(authentication).state = PropertyMock( type(authentication).state = PropertyMock(
@ -321,20 +226,12 @@ def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"):
} }
def _mock_operative_august_lock_detail(lockid): async def _mock_operative_august_lock_detail(hass):
operative_lock_detail_data = _mock_august_lock_data(lockid=lockid) return await _mock_lock_from_fixture(hass, "get_lock.online.json")
return LockDetail(operative_lock_detail_data)
def _mock_inoperative_august_lock_detail(lockid): async def _mock_inoperative_august_lock_detail(hass):
inoperative_lock_detail_data = _mock_august_lock_data(lockid=lockid) return await _mock_lock_from_fixture(hass, "get_lock.offline.json")
del inoperative_lock_detail_data["Bridge"]
return LockDetail(inoperative_lock_detail_data)
def _mock_doorsense_enabled_august_lock_detail(lockid):
doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid)
return LockDetail(doorsense_lock_detail_data)
async def _mock_lock_from_fixture(hass, path): async def _mock_lock_from_fixture(hass, path):
@ -354,10 +251,12 @@ async def _load_json_fixture(hass, path):
return json.loads(fixture) return json.loads(fixture)
def _mock_doorsense_missing_august_lock_detail(lockid): async def _mock_doorsense_enabled_august_lock_detail(hass):
doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) return await _mock_lock_from_fixture(hass, "get_lock.online_with_doorsense.json")
del doorsense_lock_detail_data["LockStatus"]["doorState"]
return LockDetail(doorsense_lock_detail_data)
async def _mock_doorsense_missing_august_lock_detail(hass):
return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json")
def _mock_lock_operation_activity(lock, action): def _mock_lock_operation_activity(lock, action):

View File

@ -9,6 +9,7 @@ from homeassistant.const import (
SERVICE_UNLOCK, SERVICE_UNLOCK,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNAVAILABLE,
) )
from tests.components.august.mocks import ( from tests.components.august.mocks import (
@ -69,3 +70,21 @@ async def test_create_doorbell(hass):
"binary_sensor.k98gidt45gul_name_ding" "binary_sensor.k98gidt45gul_name_ding"
) )
assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
async def test_create_doorbell_offline(hass):
"""Test creation of a doorbell that is offline."""
doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
doorbell_details = [doorbell_one]
await _create_august_with_devices(hass, doorbell_details)
binary_sensor_tmt100_name_motion = hass.states.get(
"binary_sensor.tmt100_name_motion"
)
assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE
binary_sensor_tmt100_name_online = hass.states.get(
"binary_sensor.tmt100_name_online"
)
assert binary_sensor_tmt100_name_online.state == STATE_OFF
binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding")
assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE

View File

@ -0,0 +1,195 @@
"""Test the August config flow."""
from asynctest import patch
from august.authenticator import ValidationResult
from homeassistant import config_entries, setup
from homeassistant.components.august.const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DOMAIN,
VERIFICATION_CODE_KEY,
)
from homeassistant.components.august.exceptions import (
CannotConnect,
InvalidAuth,
RequireValidation,
)
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.august.config_flow.AugustGateway.authenticate",
return_value=True,
), patch(
"homeassistant.components.august.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.august.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "my@email.tld"
assert result2["data"] == {
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
CONF_INSTALL_ID: None,
CONF_TIMEOUT: 10,
CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.august.config_flow.AugustGateway.authenticate",
side_effect=InvalidAuth,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.august.config_flow.AugustGateway.authenticate",
side_effect=CannotConnect,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_needs_validate(hass):
"""Test we present validation when we need to validate."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.august.config_flow.AugustGateway.authenticate",
side_effect=RequireValidation,
), patch(
"homeassistant.components.august.gateway.Authenticator.send_verification_code",
return_value=True,
) as mock_send_verification_code:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
},
)
assert len(mock_send_verification_code.mock_calls) == 1
assert result2["type"] == "form"
assert result2["errors"] is None
assert result2["step_id"] == "validation"
# Try with the WRONG verification code give us the form back again
with patch(
"homeassistant.components.august.config_flow.AugustGateway.authenticate",
side_effect=RequireValidation,
), patch(
"homeassistant.components.august.gateway.Authenticator.validate_verification_code",
return_value=ValidationResult.INVALID_VERIFICATION_CODE,
) as mock_validate_verification_code, patch(
"homeassistant.components.august.gateway.Authenticator.send_verification_code",
return_value=True,
) as mock_send_verification_code, patch(
"homeassistant.components.august.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.august.async_setup_entry", return_value=True
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], {VERIFICATION_CODE_KEY: "incorrect"},
)
# Make sure we do not resend the code again
# so they have a chance to retry
assert len(mock_send_verification_code.mock_calls) == 0
assert len(mock_validate_verification_code.mock_calls) == 1
assert result3["type"] == "form"
assert result3["errors"] is None
assert result3["step_id"] == "validation"
# Try with the CORRECT verification code and we setup
with patch(
"homeassistant.components.august.config_flow.AugustGateway.authenticate",
return_value=True,
), patch(
"homeassistant.components.august.gateway.Authenticator.validate_verification_code",
return_value=ValidationResult.VALIDATED,
) as mock_validate_verification_code, patch(
"homeassistant.components.august.gateway.Authenticator.send_verification_code",
return_value=True,
) as mock_send_verification_code, patch(
"homeassistant.components.august.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.august.async_setup_entry", return_value=True
) as mock_setup_entry:
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"], {VERIFICATION_CODE_KEY: "correct"},
)
assert len(mock_send_verification_code.mock_calls) == 0
assert len(mock_validate_verification_code.mock_calls) == 1
assert result4["type"] == "create_entry"
assert result4["title"] == "my@email.tld"
assert result4["data"] == {
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
CONF_INSTALL_ID: None,
CONF_TIMEOUT: 10,
CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1

View File

@ -0,0 +1,49 @@
"""The gateway tests for the august platform."""
from unittest.mock import MagicMock
from asynctest import mock
from homeassistant.components.august.const import DOMAIN
from homeassistant.components.august.gateway import AugustGateway
from tests.components.august.mocks import _mock_august_authentication, _mock_get_config
async def test_refresh_access_token(hass):
"""Test token refreshes."""
await _patched_refresh_access_token(hass, "new_token", 5678)
@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate")
@mock.patch("homeassistant.components.august.gateway.Authenticator.should_refresh")
@mock.patch(
"homeassistant.components.august.gateway.Authenticator.refresh_access_token"
)
async def _patched_refresh_access_token(
hass,
new_token,
new_token_expire_time,
refresh_access_token_mock,
should_refresh_mock,
authenticate_mock,
):
authenticate_mock.side_effect = MagicMock(
return_value=_mock_august_authentication("original_token", 1234)
)
august_gateway = AugustGateway(hass)
mocked_config = _mock_get_config()
august_gateway.async_setup(mocked_config[DOMAIN])
august_gateway.authenticate()
should_refresh_mock.return_value = False
await august_gateway.async_refresh_access_token_if_needed()
refresh_access_token_mock.assert_not_called()
should_refresh_mock.return_value = True
refresh_access_token_mock.return_value = _mock_august_authentication(
new_token, new_token_expire_time
)
await august_gateway.async_refresh_access_token_if_needed()
refresh_access_token_mock.assert_called()
assert august_gateway.access_token == new_token
assert august_gateway.authentication.access_token_expires == new_token_expire_time

View File

@ -1,136 +1,146 @@
"""The tests for the august platform.""" """The tests for the august platform."""
import asyncio from asynctest import patch
from unittest.mock import MagicMock from august.exceptions import AugustApiHTTPError
from august.lock import LockDetail from homeassistant import setup
from requests import RequestException from homeassistant.components.august.const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
from homeassistant.components import august CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DEFAULT_AUGUST_CONFIG_FILE,
)
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_PASSWORD,
CONF_TIMEOUT,
CONF_USERNAME,
SERVICE_LOCK,
SERVICE_UNLOCK,
STATE_LOCKED,
STATE_ON,
)
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from tests.components.august.mocks import ( from tests.components.august.mocks import (
MockAugustApiFailing, _create_august_with_devices,
MockAugustComponentData,
_mock_august_authentication,
_mock_august_authenticator,
_mock_august_lock,
_mock_doorsense_enabled_august_lock_detail, _mock_doorsense_enabled_august_lock_detail,
_mock_doorsense_missing_august_lock_detail, _mock_doorsense_missing_august_lock_detail,
_mock_get_config,
_mock_inoperative_august_lock_detail, _mock_inoperative_august_lock_detail,
_mock_operative_august_lock_detail, _mock_operative_august_lock_detail,
) )
def test_get_lock_name(): async def test_unlock_throws_august_api_http_error(hass):
"""Get the lock name from August data.""" """Test unlock throws correct error on http error."""
data = MockAugustComponentData(last_lock_status_update_timestamp=1) mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
lock = _mock_august_lock()
data.set_mocked_locks([lock])
assert data.get_lock_name("mocklockid1") == "mocklockid1 Name"
def _unlock_return_activities_side_effect(access_token, device_id):
raise AugustApiHTTPError("This should bubble up as its user consumable")
def test_unlock_throws_august_api_http_error(): await _create_august_with_devices(
"""Test unlock.""" hass,
data = MockAugustComponentData(api=MockAugustApiFailing()) [mocked_lock_detail],
lock = _mock_august_lock() api_call_side_effects={
data.set_mocked_locks([lock]) "unlock_return_activities": _unlock_return_activities_side_effect
},
)
last_err = None last_err = None
data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
try: try:
data.unlock("mocklockid1") await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True)
except HomeAssistantError as err: except HomeAssistantError as err:
last_err = err last_err = err
assert ( assert (
str(last_err) str(last_err)
== "mocklockid1 Name: This should bubble up as its user consumable" == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable"
) )
def test_lock_throws_august_api_http_error(): async def test_lock_throws_august_api_http_error(hass):
"""Test lock.""" """Test lock throws correct error on http error."""
data = MockAugustComponentData(api=MockAugustApiFailing()) mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
lock = _mock_august_lock()
data.set_mocked_locks([lock]) def _lock_return_activities_side_effect(access_token, device_id):
raise AugustApiHTTPError("This should bubble up as its user consumable")
await _create_august_with_devices(
hass,
[mocked_lock_detail],
api_call_side_effects={
"lock_return_activities": _lock_return_activities_side_effect
},
)
last_err = None last_err = None
data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
try: try:
data.unlock("mocklockid1") await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True)
except HomeAssistantError as err: except HomeAssistantError as err:
last_err = err last_err = err
assert ( assert (
str(last_err) str(last_err)
== "mocklockid1 Name: This should bubble up as its user consumable" == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable"
) )
def test_inoperative_locks_are_filtered_out(): async def test_inoperative_locks_are_filtered_out(hass):
"""Ensure inoperative locks do not get setup.""" """Ensure inoperative locks do not get setup."""
august_operative_lock = _mock_operative_august_lock_detail("oplockid1") august_operative_lock = await _mock_operative_august_lock_detail(hass)
data = _create_august_data_with_lock_details( august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass)
[august_operative_lock, _mock_inoperative_august_lock_detail("inoplockid1")] await _create_august_with_devices(
hass, [august_operative_lock, august_inoperative_lock]
) )
assert len(data.locks) == 1 lock_abc_name = hass.states.get("lock.abc_name")
assert data.locks[0].device_id == "oplockid1" assert lock_abc_name is None
lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get(
"lock.a6697750d607098bae8d6baa11ef8063_name"
)
assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED
def test_lock_has_doorsense(): async def test_lock_has_doorsense(hass):
"""Check to see if a lock has doorsense.""" """Check to see if a lock has doorsense."""
data = _create_august_data_with_lock_details( doorsenselock = await _mock_doorsense_enabled_august_lock_detail(hass)
[ nodoorsenselock = await _mock_doorsense_missing_august_lock_detail(hass)
_mock_doorsense_enabled_august_lock_detail("doorsenselock1"), await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock])
_mock_doorsense_missing_august_lock_detail("nodoorsenselock1"),
RequestException("mocked request error"), binary_sensor_online_with_doorsense_name_open = hass.states.get(
RequestException("mocked request error"), "binary_sensor.online_with_doorsense_name_open"
]
) )
assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON
assert data.lock_has_doorsense("doorsenselock1") is True binary_sensor_missing_doorsense_id_name_open = hass.states.get(
assert data.lock_has_doorsense("nodoorsenselock1") is False "binary_sensor.missing_doorsense_id_name_open"
# The api calls are mocked to fail on the second
# run of async_get_lock_detail
#
# This will be switched to await data.async_get_lock_detail("doorsenselock1")
# once we mock the full home assistant setup
data._update_locks_detail()
# doorsenselock1 should be false if we cannot tell due
# to an api error
assert data.lock_has_doorsense("doorsenselock1") is False
async def test__refresh_access_token(hass):
"""Test refresh of the access token."""
authentication = _mock_august_authentication("original_token", 1234)
authenticator = _mock_august_authenticator()
token_refresh_lock = asyncio.Lock()
data = august.AugustData(
hass, MagicMock(name="api"), authentication, authenticator, token_refresh_lock
) )
await data._async_refresh_access_token_if_needed() assert binary_sensor_missing_doorsense_id_name_open is None
authenticator.refresh_access_token.assert_not_called()
authenticator.should_refresh.return_value = 1
authenticator.refresh_access_token.return_value = _mock_august_authentication(
"new_token", 5678
)
await data._async_refresh_access_token_if_needed()
authenticator.refresh_access_token.assert_called()
assert data._access_token == "new_token"
assert data._access_token_expires == 5678
def _create_august_data_with_lock_details(lock_details): async def test_set_up_from_yaml(hass):
locks = [] """Test to make sure config is imported from yaml."""
for lock in lock_details:
if isinstance(lock, LockDetail): await setup.async_setup_component(hass, "persistent_notification", {})
locks.append(_mock_august_lock(lock.device_id)) with patch(
authentication = _mock_august_authentication("original_token", 1234) "homeassistant.components.august.async_setup_august", return_value=True,
authenticator = _mock_august_authenticator() ) as mock_setup_august, patch(
token_refresh_lock = MagicMock() "homeassistant.components.august.config_flow.AugustGateway.authenticate",
api = MagicMock() return_value=True,
api.get_lock_detail = MagicMock(side_effect=lock_details) ):
api.get_operable_locks = MagicMock(return_value=locks) mocked_config = _mock_get_config()
api.get_doorbells = MagicMock(return_value=[]) assert await async_setup_component(hass, "august", mocked_config)
return august.AugustData( await hass.async_block_till_done()
MagicMock(), api, authentication, authenticator, token_refresh_lock assert len(mock_setup_august.mock_calls) == 1
) call = mock_setup_august.call_args
args, kwargs = call
imported_config_entry = args[1]
# The import must use DEFAULT_AUGUST_CONFIG_FILE so they
# do not loose their token when config is migrated
assert imported_config_entry.data == {
CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE,
CONF_INSTALL_ID: None,
CONF_LOGIN_METHOD: "email",
CONF_PASSWORD: "mocked_password",
CONF_TIMEOUT: None,
CONF_USERNAME: "mocked_username",
}

View File

@ -6,45 +6,65 @@ from homeassistant.const import (
SERVICE_LOCK, SERVICE_LOCK,
SERVICE_UNLOCK, SERVICE_UNLOCK,
STATE_LOCKED, STATE_LOCKED,
STATE_UNAVAILABLE,
STATE_UNLOCKED, STATE_UNLOCKED,
) )
from tests.components.august.mocks import ( from tests.components.august.mocks import (
_create_august_with_devices, _create_august_with_devices,
_mock_doorsense_enabled_august_lock_detail,
_mock_lock_from_fixture, _mock_lock_from_fixture,
) )
async def test_one_lock_operation(hass): async def test_one_lock_operation(hass):
"""Test creation of a lock with doorsense and bridge.""" """Test creation of a lock with doorsense and bridge."""
lock_one = await _mock_lock_from_fixture( lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
hass, "get_lock.online_with_doorsense.json"
)
lock_details = [lock_one] lock_details = [lock_one]
await _create_august_with_devices(hass, lock_details) await _create_august_with_devices(hass, lock_details)
lock_abc_name = hass.states.get("lock.abc_name") lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_abc_name.state == STATE_LOCKED assert lock_online_with_doorsense_name.state == STATE_LOCKED
assert lock_abc_name.attributes.get("battery_level") == 92 assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92
assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" assert (
lock_online_with_doorsense_name.attributes.get("friendly_name")
== "online_with_doorsense Name"
)
data = {} data = {}
data[ATTR_ENTITY_ID] = "lock.abc_name" data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name"
assert await hass.services.async_call( assert await hass.services.async_call(
LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True
) )
lock_abc_name = hass.states.get("lock.abc_name") lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_abc_name.state == STATE_UNLOCKED assert lock_online_with_doorsense_name.state == STATE_UNLOCKED
assert lock_abc_name.attributes.get("battery_level") == 92 assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92
assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" assert (
lock_online_with_doorsense_name.attributes.get("friendly_name")
== "online_with_doorsense Name"
)
assert await hass.services.async_call( assert await hass.services.async_call(
LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True
) )
lock_abc_name = hass.states.get("lock.abc_name") lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_abc_name.state == STATE_LOCKED assert lock_online_with_doorsense_name.state == STATE_LOCKED
async def test_one_lock_unknown_state(hass):
"""Test creation of a lock with doorsense and bridge."""
lock_one = await _mock_lock_from_fixture(
hass, "get_lock.online.unknown_state.json",
)
lock_details = [lock_one]
await _create_august_with_devices(hass, lock_details)
lock_brokenid_name = hass.states.get("lock.brokenid_name")
# Once we have bridge_is_online support in py-august
# this can change to STATE_UNKNOWN
assert lock_brokenid_name.state == STATE_UNAVAILABLE

View File

@ -0,0 +1,84 @@
"""The sensor tests for the august platform."""
from tests.components.august.mocks import (
_create_august_with_devices,
_mock_doorbell_from_fixture,
_mock_lock_from_fixture,
)
async def test_create_doorbell(hass):
"""Test creation of a doorbell."""
doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
await _create_august_with_devices(hass, [doorbell_one])
sensor_k98gidt45gul_name_battery = hass.states.get(
"sensor.k98gidt45gul_name_battery"
)
assert sensor_k98gidt45gul_name_battery.state == "96"
assert sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == "%"
async def test_create_doorbell_offline(hass):
"""Test creation of a doorbell that is offline."""
doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
await _create_august_with_devices(hass, [doorbell_one])
entity_registry = await hass.helpers.entity_registry.async_get_registry()
sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
assert sensor_tmt100_name_battery.state == "81"
assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == "%"
entry = entity_registry.async_get("sensor.tmt100_name_battery")
assert entry
assert entry.unique_id == "tmt100_device_battery"
async def test_create_doorbell_hardwired(hass):
"""Test creation of a doorbell that is hardwired without a battery."""
doorbell_one = await _mock_doorbell_from_fixture(
hass, "get_doorbell.nobattery.json"
)
await _create_august_with_devices(hass, [doorbell_one])
sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
assert sensor_tmt100_name_battery is None
async def test_create_lock_with_linked_keypad(hass):
"""Test creation of a lock with a linked keypad that both have a battery."""
lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json")
await _create_august_with_devices(hass, [lock_one])
entity_registry = await hass.helpers.entity_registry.async_get_registry()
sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get(
"sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
)
assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88"
assert (
sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[
"unit_of_measurement"
]
== "%"
)
entry = entity_registry.async_get(
"sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
)
assert entry
assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery"
sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get(
"sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
)
assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "60"
assert (
sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[
"unit_of_measurement"
]
== "%"
)
entry = entity_registry.async_get(
"sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
)
assert entry
assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery"

View File

@ -0,0 +1,80 @@
{
"status_timestamp" : 1512811834532,
"appID" : "august-iphone",
"LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA",
"recentImage" : {
"original_filename" : "file",
"placeholder" : false,
"bytes" : 24476,
"height" : 640,
"format" : "jpg",
"width" : 480,
"version" : 1512892814,
"resource_type" : "image",
"etag" : "54966926be2e93f77d498a55f247661f",
"tags" : [],
"public_id" : "qqqqt4ctmxwsysylaaaa",
"url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg",
"created_at" : "2017-12-10T08:01:35Z",
"signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da",
"secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg",
"type" : "upload"
},
"settings" : {
"keepEncoderRunning" : true,
"videoResolution" : "640x480",
"minACNoScaling" : 40,
"irConfiguration" : 8448272,
"directLink" : true,
"overlayEnabled" : true,
"notify_when_offline" : true,
"micVolume" : 100,
"bitrateCeiling" : 512000,
"initialBitrate" : 384000,
"IVAEnabled" : false,
"turnOffCamera" : false,
"ringSoundEnabled" : true,
"JPGQuality" : 70,
"motion_notifications" : true,
"speakerVolume" : 92,
"buttonpush_notifications" : true,
"ABREnabled" : true,
"debug" : false,
"batteryLowThreshold" : 3.1,
"batteryRun" : false,
"IREnabled" : true,
"batteryUseThreshold" : 3.4
},
"doorbellServerURL" : "https://doorbells.august.com",
"name" : "Front Door",
"createdAt" : "2016-11-26T22:27:11.176Z",
"installDate" : "2016-11-26T22:27:11.176Z",
"serialNumber" : "tBXZR0Z35E",
"dvrSubscriptionSetupDone" : true,
"caps" : [
"reconnect"
],
"doorbellID" : "K98GiDT45GUL",
"HouseID" : "3dd2accaea08",
"telemetry" : {
"signal_level" : -56,
"date" : "2017-12-10 08:05:12",
"steady_ac_in" : 22.196405,
"BSSID" : "88:ee:00:dd:aa:11",
"SSID" : "foo_ssid",
"updated_at" : "2017-12-10T08:05:13.650Z",
"temperature" : 28.25,
"wifi_freq" : 5745,
"load_average" : "0.50 0.47 0.35 1/154 9345",
"link_quality" : 54,
"uptime" : "16168.75 13830.49",
"ip_addr" : "10.0.1.11",
"doorbell_low_battery" : false,
"ac_in" : 23.856874
},
"installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777",
"status" : "doorbell_call_status_online",
"firmwareVersion" : "2.3.0-RC153+201711151527",
"pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc",
"updatedAt" : "2017-12-10T08:05:13.650Z"
}

View File

@ -0,0 +1,130 @@
{
"recentImage" : {
"tags" : [],
"height" : 576,
"public_id" : "fdsfds",
"bytes" : 50013,
"resource_type" : "image",
"original_filename" : "file",
"version" : 1582242766,
"format" : "jpg",
"signature" : "fdsfdsf",
"created_at" : "2020-02-20T23:52:46Z",
"type" : "upload",
"placeholder" : false,
"url" : "http://res.cloudinary.com/august-com/image/upload/ccc/ccccc.jpg",
"secure_url" : "https://res.cloudinary.com/august-com/image/upload/cc/cccc.jpg",
"etag" : "zds",
"width" : 720
},
"firmwareVersion" : "3.1.0-HYDRC75+201909251139",
"doorbellServerURL" : "https://doorbells.august.com",
"installUserID" : "mock",
"caps" : [
"reconnect",
"webrtc",
"tcp_wakeup"
],
"messagingProtocol" : "pubnub",
"createdAt" : "2020-02-12T03:52:28.719Z",
"invitations" : [],
"appID" : "august-iphone-v5",
"HouseID" : "houseid1",
"doorbellID" : "tmt100",
"name" : "Front Door",
"settings" : {
"batteryUseThreshold" : 3.4,
"brightness" : 50,
"batteryChargeCurrent" : 60,
"overCurrentThreshold" : -250,
"irLedBrightness" : 40,
"videoResolution" : "720x576",
"pirPulseCounter" : 1,
"contrast" : 50,
"micVolume" : 50,
"directLink" : true,
"auto_contrast_mode" : 0,
"saturation" : 50,
"motion_notifications" : true,
"pirSensitivity" : 20,
"pirBlindTime" : 7,
"notify_when_offline" : false,
"nightModeAlsThreshold" : 10,
"minACNoScaling" : 40,
"DVRRecordingTimeout" : 15,
"turnOffCamera" : false,
"debug" : false,
"keepEncoderRunning" : true,
"pirWindowTime" : 0,
"bitrateCeiling" : 2000000,
"backlight_comp" : false,
"buttonpush_notifications" : true,
"buttonpush_notifications_partners" : false,
"minimumSnapshotInterval" : 30,
"pirConfiguration" : 272,
"batteryLowThreshold" : 3.1,
"sharpness" : 50,
"ABREnabled" : true,
"hue" : 50,
"initialBitrate" : 1000000,
"ringSoundEnabled" : true,
"IVAEnabled" : false,
"overlayEnabled" : true,
"speakerVolume" : 92,
"ringRepetitions" : 3,
"powerProfilePreset" : -1,
"irConfiguration" : 16836880,
"JPGQuality" : 70,
"IREnabled" : true
},
"updatedAt" : "2020-02-20T23:58:21.580Z",
"serialNumber" : "abc",
"installDate" : "2019-02-12T03:52:28.719Z",
"dvrSubscriptionSetupDone" : true,
"pubsubChannel" : "mock",
"chimes" : [
{
"updatedAt" : "2020-02-12T03:55:38.805Z",
"_id" : "cccc",
"type" : 1,
"serialNumber" : "ccccc",
"doorbellID" : "tmt100",
"name" : "Living Room",
"chimeID" : "cccc",
"createdAt" : "2020-02-12T03:55:38.805Z",
"firmware" : "3.1.16"
}
],
"telemetry" : {
"battery" : 3.985,
"battery_soc" : 81,
"load_average" : "0.45 0.18 0.07 4/98 831",
"ip_addr" : "192.168.100.174",
"BSSID" : "snp",
"uptime" : "96.55 70.59",
"SSID" : "bob",
"updated_at" : "2020-02-20T23:53:09.586Z",
"dtim_period" : 0,
"wifi_freq" : 2462,
"date" : "2020-02-20 11:47:36",
"BSSIDManufacturer" : "Ubiquiti - Ubiquiti Networks Inc.",
"battery_temp" : 22,
"battery_avg_cur" : -291,
"beacon_interval" : 0,
"signal_level" : -49,
"battery_soh" : 95,
"doorbell_low_battery" : false
},
"secChipCertSerial" : "",
"tcpKeepAlive" : {
"keepAliveUUID" : "mock",
"wakeUp" : {
"token" : "wakemeup",
"lastUpdated" : 1582242723931
}
},
"statusUpdatedAtMs" : 1582243101579,
"status" : "doorbell_offline",
"type" : "hydra1",
"HouseName" : "housename"
}

View File

@ -0,0 +1,59 @@
{
"LockName": "Side Door",
"Type": 1001,
"Created": "2019-10-07T01:49:06.831Z",
"Updated": "2019-10-07T01:49:06.831Z",
"LockID": "BROKENID",
"HouseID": "abc",
"HouseName": "dog",
"Calibrated": false,
"timeZone": "America/Chicago",
"battery": 0.9524716174964851,
"hostLockInfo": {
"serialNumber": "YR",
"manufacturer": "yale",
"productID": 1536,
"productTypeID": 32770
},
"supportsEntryCodes": true,
"skuNumber": "AUG-MD01",
"macAddress": "MAC",
"SerialNumber": "M1FXZ00EZ9",
"LockStatus": {
"status": "unknown_error_during_connect",
"dateTime": "2020-02-22T02:48:11.741Z",
"isLockStatusChanged": true,
"valid": true,
"doorState": "closed"
},
"currentFirmwareVersion": "undefined-4.3.0-1.8.14",
"homeKitEnabled": true,
"zWaveEnabled": false,
"isGalileo": false,
"Bridge": {
"_id": "id",
"mfgBridgeID": "id",
"deviceModel": "august-connect",
"firmwareVersion": "2.2.1",
"operative": true,
"status": {
"current": "online",
"updated": "2020-02-21T15:06:47.001Z",
"lastOnline": "2020-02-21T15:06:47.001Z",
"lastOffline": "2020-02-06T17:33:21.265Z"
},
"hyperBridge": true
},
"parametersToSet": {},
"ruleHash": {},
"cameras": [],
"geofenceLimits": {
"ios": {
"debounceInterval": 90,
"gpsAccuracyMultiplier": 2.5,
"maximumGeofence": 5000,
"minimumGeofence": 100,
"minGPSAccuracyRequired": 80
}
}
}

View File

@ -0,0 +1,50 @@
{
"Bridge" : {
"_id" : "bridgeid",
"deviceModel" : "august-connect",
"firmwareVersion" : "2.2.1",
"hyperBridge" : true,
"mfgBridgeID" : "C5WY200WSH",
"operative" : true,
"status" : {
"current" : "online",
"lastOffline" : "2000-00-00T00:00:00.447Z",
"lastOnline" : "2000-00-00T00:00:00.447Z",
"updated" : "2000-00-00T00:00:00.447Z"
}
},
"Calibrated" : false,
"Created" : "2000-00-00T00:00:00.447Z",
"HouseID" : "123",
"HouseName" : "Test",
"LockID" : "missing_doorsense_id",
"LockName" : "Online door missing doorsense",
"LockStatus" : {
"dateTime" : "2017-12-10T04:48:30.272Z",
"isLockStatusChanged" : false,
"status" : "locked",
"valid" : true
},
"SerialNumber" : "XY",
"Type" : 1001,
"Updated" : "2000-00-00T00:00:00.447Z",
"battery" : 0.922,
"currentFirmwareVersion" : "undefined-4.3.0-1.8.14",
"homeKitEnabled" : true,
"hostLockInfo" : {
"manufacturer" : "yale",
"productID" : 1536,
"productTypeID" : 32770,
"serialNumber" : "ABC"
},
"isGalileo" : false,
"macAddress" : "12:22",
"pins" : {
"created" : [],
"loaded" : []
},
"skuNumber" : "AUG-MD01",
"supportsEntryCodes" : true,
"timeZone" : "Pacific/Hawaii",
"zWaveEnabled" : false
}

View File

@ -17,7 +17,7 @@
"Created" : "2000-00-00T00:00:00.447Z", "Created" : "2000-00-00T00:00:00.447Z",
"HouseID" : "123", "HouseID" : "123",
"HouseName" : "Test", "HouseName" : "Test",
"LockID" : "ABC", "LockID" : "online_with_doorsense",
"LockName" : "Online door with doorsense", "LockName" : "Online door with doorsense",
"LockStatus" : { "LockStatus" : {
"dateTime" : "2017-12-10T04:48:30.272Z", "dateTime" : "2017-12-10T04:48:30.272Z",