Convert august to async so a token refresh lock can be used (#31848)

* Convert august to async so a token refresh lock can be used

* Update comment since we now have a lock

* Do not mock the lock

* Address review items
This commit is contained in:
J. Nick Koston 2020-02-15 23:08:52 -06:00 committed by GitHub
parent fb8cbc2e93
commit f6d9e6b6c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 119 additions and 93 deletions

View file

@ -1,5 +1,7 @@
"""Support for August devices."""
import asyncio
from datetime import timedelta
from functools import partial
import logging
from august.api import Api
@ -78,7 +80,7 @@ CONFIG_SCHEMA = vol.Schema(
AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"]
def request_configuration(hass, config, api, authenticator):
def request_configuration(hass, config, api, authenticator, token_refresh_lock):
"""Request configuration steps from the user."""
configurator = hass.components.configurator
@ -92,7 +94,7 @@ def request_configuration(hass, config, api, authenticator):
_CONFIGURING[DOMAIN], "Invalid verification code"
)
elif result == ValidationResult.VALIDATED:
setup_august(hass, config, api, authenticator)
setup_august(hass, config, api, authenticator, token_refresh_lock)
if DOMAIN not in _CONFIGURING:
authenticator.send_verification_code()
@ -113,7 +115,7 @@ def request_configuration(hass, config, api, authenticator):
)
def setup_august(hass, config, api, authenticator):
def setup_august(hass, config, api, authenticator, token_refresh_lock):
"""Set up the August component."""
authentication = None
@ -136,7 +138,9 @@ def setup_august(hass, config, api, authenticator):
if DOMAIN in _CONFIGURING:
hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN))
hass.data[DATA_AUGUST] = AugustData(hass, api, authentication, authenticator)
hass.data[DATA_AUGUST] = AugustData(
hass, api, authentication, authenticator, token_refresh_lock
)
for component in AUGUST_COMPONENTS:
discovery.load_platform(hass, component, DOMAIN, {}, config)
@ -146,13 +150,13 @@ def setup_august(hass, config, api, authenticator):
_LOGGER.error("Invalid password provided")
return False
if state == AuthenticationState.REQUIRES_VALIDATION:
request_configuration(hass, config, api, authenticator)
request_configuration(hass, config, api, authenticator, token_refresh_lock)
return True
return False
def setup(hass, config):
async def async_setup(hass, config):
"""Set up the August component."""
conf = config[DOMAIN]
@ -184,16 +188,20 @@ def setup(hass, config):
_LOGGER.debug("August HTTP session closed.")
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session)
_LOGGER.debug("Registered for Home Assistant stop event")
return setup_august(hass, config, api, authenticator)
token_refresh_lock = asyncio.Lock()
return await hass.async_add_executor_job(
setup_august, hass, config, api, authenticator, token_refresh_lock
)
class AugustData:
"""August data object."""
def __init__(self, hass, api, authentication, authenticator):
def __init__(self, hass, api, authentication, authenticator, token_refresh_lock):
"""Init August data object."""
self._hass = hass
self._api = api
@ -201,6 +209,7 @@ class AugustData:
self._access_token = authentication.access_token
self._access_token_expires = authentication.access_token_expires
self._token_refresh_lock = token_refresh_lock
self._doorbells = self._api.get_doorbells(self._access_token) or []
self._locks = self._api.get_operable_locks(self._access_token) or []
self._house_ids = set()
@ -230,50 +239,48 @@ class AugustData:
"""Return a list of locks."""
return self._locks
def _refresh_access_token_if_needed(self):
async def _async_refresh_access_token_if_needed(self):
"""Refresh the august access token if needed."""
if self._authenticator.should_refresh():
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 with self._token_refresh_lock:
await self._hass.async_add_executor_job(self._refresh_access_token)
def get_device_activities(self, device_id, *activity_types):
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):
"""Return a list of activities."""
_LOGGER.debug("Getting device activities")
self._update_device_activities()
_LOGGER.debug("Getting device activities for %s", device_id)
await self._async_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):
async def async_get_latest_device_activity(self, device_id, *activity_types):
"""Return latest activity."""
activities = self.get_device_activities(device_id, *activity_types)
activities = await self.async_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):
async def _async_update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT):
"""Update data object with latest from August API."""
# This is the only place we refresh the api token
# in order to avoid multiple threads from doing it at the same time
# since there will only be one activity refresh at a time
#
# In the future when this module is converted to async we should
# use a lock to prevent all api calls while the token
# is being refreshed as this is a better solution
#
self._refresh_access_token_if_needed()
await self._async_refresh_access_token_if_needed()
return await self._hass.async_add_executor_job(
partial(self._update_device_activities, limit=ACTIVITY_FETCH_LIMIT)
)
def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT):
_LOGGER.debug("Start retrieving device activities")
for house_id in self.house_ids:
_LOGGER.debug("Updating device activity for house id %s", house_id)
@ -290,12 +297,15 @@ class AugustData:
_LOGGER.debug("Completed retrieving device activities")
def get_doorbell_detail(self, doorbell_id):
async def async_get_doorbell_detail(self, doorbell_id):
"""Return doorbell detail."""
self._update_doorbells()
await self._async_update_doorbells()
return self._doorbell_detail_by_id.get(doorbell_id)
@Throttle(MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES)
async def _async_update_doorbells(self):
await self._hass.async_add_executor_job(self._update_doorbells)
def _update_doorbells(self):
detail_by_id = {}
@ -341,32 +351,35 @@ class AugustData:
self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc
return True
def get_lock_status(self, lock_id):
async def async_get_lock_status(self, lock_id):
"""Return status if the door is locked or unlocked.
This is status for the lock itself.
"""
self._update_locks()
await self._async_update_locks()
return self._lock_status_by_id.get(lock_id)
def get_lock_detail(self, lock_id):
async def async_get_lock_detail(self, lock_id):
"""Return lock detail."""
self._update_locks()
await self._async_update_locks()
return self._lock_detail_by_id.get(lock_id)
def get_door_state(self, lock_id):
async def async_get_door_state(self, lock_id):
"""Return status if the door is open or closed.
This is the status from the door sensor.
"""
self._update_locks_status()
await self._async_update_locks_status()
return self._door_state_by_id.get(lock_id)
def _update_locks(self):
self._update_locks_status()
self._update_locks_detail()
async def _async_update_locks(self):
await self._async_update_locks_status()
await self._async_update_locks_detail()
@Throttle(MIN_TIME_BETWEEN_LOCK_STATUS_UPDATES)
async def _async_update_locks_status(self):
await self._hass.async_add_executor_job(self._update_locks_status)
def _update_locks_status(self):
status_by_id = {}
state_by_id = {}
@ -431,6 +444,9 @@ class AugustData:
return self._door_last_state_update_time_utc_by_id[lock_id]
@Throttle(MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES)
async def _async_update_locks_detail(self):
await self._hass.async_add_executor_job(self._update_locks_detail)
def _update_locks_detail(self):
detail_by_id = {}

View file

@ -15,35 +15,39 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=10)
def _retrieve_door_state(data, lock):
async def _async_retrieve_door_state(data, lock):
"""Get the latest state of the DoorSense sensor."""
return data.get_door_state(lock.device_id)
return await data.async_get_door_state(lock.device_id)
def _retrieve_online_state(data, doorbell):
async def _async_retrieve_online_state(data, doorbell):
"""Get the latest state of the sensor."""
detail = data.get_doorbell_detail(doorbell.device_id)
detail = await data.async_get_doorbell_detail(doorbell.device_id)
if detail is None:
return None
return detail.is_online
def _retrieve_motion_state(data, doorbell):
async def _async_retrieve_motion_state(data, doorbell):
return _activity_time_based_state(
return await _async_activity_time_based_state(
data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING]
)
def _retrieve_ding_state(data, doorbell):
async def _async_retrieve_ding_state(data, doorbell):
return _activity_time_based_state(data, doorbell, [ActivityType.DOORBELL_DING])
return await _async_activity_time_based_state(
data, doorbell, [ActivityType.DOORBELL_DING]
)
def _activity_time_based_state(data, doorbell, activity_types):
async def _async_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)
latest = await data.async_get_latest_device_activity(
doorbell.device_id, *activity_types
)
if latest is not None:
start = latest.activity_start_time
@ -52,25 +56,25 @@ def _activity_time_based_state(data, doorbell, activity_types):
return None
# Sensor types: Name, device_class, state_provider
SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _retrieve_door_state]}
# Sensor types: Name, device_class, async_state_provider
SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _async_retrieve_door_state]}
SENSOR_TYPES_DOORBELL = {
"doorbell_ding": ["Ding", "occupancy", _retrieve_ding_state],
"doorbell_motion": ["Motion", "motion", _retrieve_motion_state],
"doorbell_online": ["Online", "connectivity", _retrieve_online_state],
"doorbell_ding": ["Ding", "occupancy", _async_retrieve_ding_state],
"doorbell_motion": ["Motion", "motion", _async_retrieve_motion_state],
"doorbell_online": ["Online", "connectivity", _async_retrieve_online_state],
}
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the August binary sensors."""
data = hass.data[DATA_AUGUST]
devices = []
for door in data.locks:
for sensor_type in SENSOR_TYPES_DOOR:
state_provider = SENSOR_TYPES_DOOR[sensor_type][2]
if state_provider(data, door) is LockDoorStatus.UNKNOWN:
async_state_provider = SENSOR_TYPES_DOOR[sensor_type][2]
if await async_state_provider(data, door) is LockDoorStatus.UNKNOWN:
_LOGGER.debug(
"Not adding sensor class %s for lock %s ",
SENSOR_TYPES_DOOR[sensor_type][1],
@ -94,7 +98,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
)
devices.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell))
add_entities(devices, True)
async_add_entities(devices, True)
class AugustDoorBinarySensor(BinarySensorDevice):
@ -130,15 +134,15 @@ class AugustDoorBinarySensor(BinarySensorDevice):
self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][0]
)
def update(self):
"""Get the latest state of the sensor."""
state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2]
self._state = state_provider(self._data, self._door)
async def async_update(self):
"""Get the latest state of the sensor and update activity."""
async_state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2]
self._state = await async_state_provider(self._data, self._door)
self._available = self._state is not None
self._state = self._state == LockDoorStatus.OPEN
door_activity = self._data.get_latest_device_activity(
door_activity = await self._data.async_get_latest_device_activity(
self._door.device_id, ActivityType.DOOR_OPERATION
)
@ -226,10 +230,10 @@ class AugustDoorbellBinarySensor(BinarySensorDevice):
self._doorbell.device_name, SENSOR_TYPES_DOORBELL[self._sensor_type][0]
)
def update(self):
async def async_update(self):
"""Get the latest state of the sensor."""
state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2]
self._state = state_provider(self._data, self._doorbell)
async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2]
self._state = await async_state_provider(self._data, self._doorbell)
self._available = self._doorbell.is_online
@property

View file

@ -10,7 +10,7 @@ from . import DATA_AUGUST, DEFAULT_TIMEOUT
SCAN_INTERVAL = timedelta(seconds=10)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up August cameras."""
data = hass.data[DATA_AUGUST]
devices = []
@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for doorbell in data.doorbells:
devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT))
add_entities(devices, True)
async_add_entities(devices, True)
class AugustCamera(Camera):
@ -58,9 +58,9 @@ class AugustCamera(Camera):
"""Return the camera model."""
return "Doorbell"
def camera_image(self):
async def async_camera_image(self):
"""Return bytes of camera image."""
latest = self._data.get_doorbell_detail(self._doorbell.device_id)
latest = await self._data.async_get_doorbell_detail(self._doorbell.device_id)
if self._image_url is not latest.image_url:
self._image_url = latest.image_url

View file

@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=10)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up August locks."""
data = hass.data[DATA_AUGUST]
devices = []
@ -25,7 +25,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
_LOGGER.debug("Adding lock for %s", lock.device_name)
devices.append(AugustLock(data, lock))
add_entities(devices, True)
async_add_entities(devices, True)
class AugustLock(LockDevice):
@ -40,16 +40,20 @@ class AugustLock(LockDevice):
self._changed_by = None
self._available = False
def lock(self, **kwargs):
async def async_lock(self, **kwargs):
"""Lock the device."""
update_start_time_utc = dt.utcnow()
lock_status = self._data.lock(self._lock.device_id)
lock_status = await self.hass.async_add_executor_job(
self._data.lock, self._lock.device_id
)
self._update_lock_status(lock_status, update_start_time_utc)
def unlock(self, **kwargs):
async def async_unlock(self, **kwargs):
"""Unlock the device."""
update_start_time_utc = dt.utcnow()
lock_status = self._data.unlock(self._lock.device_id)
lock_status = await self.hass.async_add_executor_job(
self._data.unlock, self._lock.device_id
)
self._update_lock_status(lock_status, update_start_time_utc)
def _update_lock_status(self, lock_status, update_start_time_utc):
@ -60,14 +64,13 @@ class AugustLock(LockDevice):
)
self.schedule_update_ha_state()
def update(self):
"""Get the latest state of the sensor."""
self._lock_status = self._data.get_lock_status(self._lock.device_id)
async def async_update(self):
"""Get the latest state of the sensor and update activity."""
self._lock_status = await self._data.async_get_lock_status(self._lock.device_id)
self._available = self._lock_status is not None
self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id)
self._lock_detail = self._data.get_lock_detail(self._lock.device_id)
lock_activity = self._data.get_latest_device_activity(
lock_activity = await self._data.async_get_latest_device_activity(
self._lock.device_id, ActivityType.LOCK_OPERATION
)

View file

@ -1,4 +1,5 @@
"""The tests for the august platform."""
import asyncio
from unittest.mock import MagicMock
from homeassistant.components import august
@ -9,21 +10,23 @@ from tests.components.august.mocks import (
)
def test__refresh_access_token():
async def test__refresh_access_token(hass):
"""Set up things to be run when tests are started."""
authentication = _mock_august_authentication("original_token", 1234)
authenticator = _mock_august_authenticator()
token_refresh_lock = asyncio.Lock()
data = august.AugustData(
MagicMock(name="hass"), MagicMock(name="api"), authentication, authenticator
hass, MagicMock(name="api"), authentication, authenticator, token_refresh_lock
)
data._refresh_access_token_if_needed()
await data._async_refresh_access_token_if_needed()
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
)
data._refresh_access_token_if_needed()
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