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.""" """Support for August devices."""
import asyncio
from datetime import timedelta from datetime import timedelta
from functools import partial
import logging import logging
from august.api import Api from august.api import Api
@ -78,7 +80,7 @@ CONFIG_SCHEMA = vol.Schema(
AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"] 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.""" """Request configuration steps from the user."""
configurator = hass.components.configurator configurator = hass.components.configurator
@ -92,7 +94,7 @@ def request_configuration(hass, config, api, authenticator):
_CONFIGURING[DOMAIN], "Invalid verification code" _CONFIGURING[DOMAIN], "Invalid verification code"
) )
elif result == ValidationResult.VALIDATED: elif result == ValidationResult.VALIDATED:
setup_august(hass, config, api, authenticator) setup_august(hass, config, api, authenticator, token_refresh_lock)
if DOMAIN not in _CONFIGURING: if DOMAIN not in _CONFIGURING:
authenticator.send_verification_code() 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.""" """Set up the August component."""
authentication = None authentication = None
@ -136,7 +138,9 @@ def setup_august(hass, config, api, authenticator):
if DOMAIN in _CONFIGURING: if DOMAIN in _CONFIGURING:
hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) 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: for component in AUGUST_COMPONENTS:
discovery.load_platform(hass, component, DOMAIN, {}, config) discovery.load_platform(hass, component, DOMAIN, {}, config)
@ -146,13 +150,13 @@ def setup_august(hass, config, api, authenticator):
_LOGGER.error("Invalid password provided") _LOGGER.error("Invalid password provided")
return False return False
if state == AuthenticationState.REQUIRES_VALIDATION: if state == AuthenticationState.REQUIRES_VALIDATION:
request_configuration(hass, config, api, authenticator) request_configuration(hass, config, api, authenticator, token_refresh_lock)
return True return True
return False return False
def setup(hass, config): async def async_setup(hass, config):
"""Set up the August component.""" """Set up the August component."""
conf = config[DOMAIN] conf = config[DOMAIN]
@ -184,16 +188,20 @@ def setup(hass, config):
_LOGGER.debug("August HTTP session closed.") _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") _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: class AugustData:
"""August data object.""" """August data object."""
def __init__(self, hass, api, authentication, authenticator): def __init__(self, hass, api, authentication, authenticator, token_refresh_lock):
"""Init August data object.""" """Init August data object."""
self._hass = hass self._hass = hass
self._api = api self._api = api
@ -201,6 +209,7 @@ class AugustData:
self._access_token = authentication.access_token self._access_token = authentication.access_token
self._access_token_expires = authentication.access_token_expires 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._doorbells = self._api.get_doorbells(self._access_token) or []
self._locks = self._api.get_operable_locks(self._access_token) or [] self._locks = self._api.get_operable_locks(self._access_token) or []
self._house_ids = set() self._house_ids = set()
@ -230,13 +239,14 @@ class AugustData:
"""Return a list of locks.""" """Return a list of locks."""
return self._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.""" """Refresh the august access token if needed."""
if self._authenticator.should_refresh(): if self._authenticator.should_refresh():
refreshed_authentication = self._authenticator.refresh_access_token( async with self._token_refresh_lock:
force=False 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( _LOGGER.info(
"Refreshed august access token. The old token expired at %s, and the new token expires at %s", "Refreshed august access token. The old token expired at %s, and the new token expires at %s",
self._access_token_expires, self._access_token_expires,
@ -245,35 +255,32 @@ class AugustData:
self._access_token = refreshed_authentication.access_token self._access_token = refreshed_authentication.access_token
self._access_token_expires = refreshed_authentication.access_token_expires self._access_token_expires = refreshed_authentication.access_token_expires
def 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") _LOGGER.debug("Getting device activities for %s", device_id)
self._update_device_activities() await self._async_update_device_activities()
activities = self._activities_by_id.get(device_id, []) activities = self._activities_by_id.get(device_id, [])
if activity_types: if activity_types:
return [a for a in activities if a.activity_type in activity_types] return [a for a in activities if a.activity_type in activity_types]
return activities 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.""" """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) return next(iter(activities or []), None)
@Throttle(MIN_TIME_BETWEEN_UPDATES) @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.""" """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
# in order to avoid multiple threads from doing it at the same time await self._async_refresh_access_token_if_needed()
# since there will only be one activity refresh at a time return await self._hass.async_add_executor_job(
# partial(self._update_device_activities, limit=ACTIVITY_FETCH_LIMIT)
# 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()
def _update_device_activities(self, limit=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)
@ -290,12 +297,15 @@ class AugustData:
_LOGGER.debug("Completed retrieving device activities") _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.""" """Return doorbell detail."""
self._update_doorbells() await self._async_update_doorbells()
return self._doorbell_detail_by_id.get(doorbell_id) return self._doorbell_detail_by_id.get(doorbell_id)
@Throttle(MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES) @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): def _update_doorbells(self):
detail_by_id = {} detail_by_id = {}
@ -341,32 +351,35 @@ class AugustData:
self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc
return True 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. """Return status if the door is locked or unlocked.
This is status for the lock itself. This is status for the lock itself.
""" """
self._update_locks() await self._async_update_locks()
return self._lock_status_by_id.get(lock_id) 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.""" """Return lock detail."""
self._update_locks() await self._async_update_locks()
return self._lock_detail_by_id.get(lock_id) 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. """Return status if the door is open or closed.
This is the status from the door sensor. 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) return self._door_state_by_id.get(lock_id)
def _update_locks(self): async def _async_update_locks(self):
self._update_locks_status() await self._async_update_locks_status()
self._update_locks_detail() await self._async_update_locks_detail()
@Throttle(MIN_TIME_BETWEEN_LOCK_STATUS_UPDATES) @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): def _update_locks_status(self):
status_by_id = {} status_by_id = {}
state_by_id = {} state_by_id = {}
@ -431,6 +444,9 @@ class AugustData:
return self._door_last_state_update_time_utc_by_id[lock_id] return self._door_last_state_update_time_utc_by_id[lock_id]
@Throttle(MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES) @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): def _update_locks_detail(self):
detail_by_id = {} detail_by_id = {}

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
"""The tests for the august platform.""" """The tests for the august platform."""
import asyncio
from unittest.mock import MagicMock from unittest.mock import MagicMock
from homeassistant.components import august 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.""" """Set up things to be run when tests are started."""
authentication = _mock_august_authentication("original_token", 1234) authentication = _mock_august_authentication("original_token", 1234)
authenticator = _mock_august_authenticator() authenticator = _mock_august_authenticator()
token_refresh_lock = asyncio.Lock()
data = august.AugustData( 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.refresh_access_token.assert_not_called()
authenticator.should_refresh.return_value = 1 authenticator.should_refresh.return_value = 1
authenticator.refresh_access_token.return_value = _mock_august_authentication( authenticator.refresh_access_token.return_value = _mock_august_authentication(
"new_token", 5678 "new_token", 5678
) )
data._refresh_access_token_if_needed() await data._async_refresh_access_token_if_needed()
authenticator.refresh_access_token.assert_called() authenticator.refresh_access_token.assert_called()
assert data._access_token == "new_token" assert data._access_token == "new_token"
assert data._access_token_expires == 5678 assert data._access_token_expires == 5678