Convert august to be push instead of poll (#47544)

This commit is contained in:
J. Nick Koston 2021-03-21 19:35:12 -10:00 committed by GitHub
parent 8e4c0e3ff7
commit a2c4b438ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 683 additions and 183 deletions

View file

@ -1,14 +1,16 @@
"""Support for August devices."""
import asyncio
import itertools
from itertools import chain
import logging
from aiohttp import ClientError, ClientResponseError
from august.exceptions import AugustApiAIOHTTPError
from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.pubnub_activity import activities_from_pubnub_message
from yalexs.pubnub_async import AugustPubNub, async_create_pubnub
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, HTTP_UNAUTHORIZED
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from .activity import ActivityStream
@ -19,6 +21,13 @@ from .subscriber import AugustSubscriberMixin
_LOGGER = logging.getLogger(__name__)
API_CACHED_ATTRS = (
"door_state",
"door_state_datetime",
"lock_status",
"lock_status_datetime",
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the August component from YAML."""
@ -60,6 +69,9 @@ def _async_start_reauth(hass: HomeAssistant, entry: ConfigEntry):
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
hass.data[DOMAIN][entry.entry_id][DATA_AUGUST].async_stop()
unload_ok = all(
await asyncio.gather(
*[
@ -114,25 +126,27 @@ class AugustData(AugustSubscriberMixin):
self._doorbells_by_id = {}
self._locks_by_id = {}
self._house_ids = set()
self._pubnub_unsub = None
async def async_setup(self):
"""Async setup of august device data and activities."""
locks = (
await self._api.async_get_operable_locks(self._august_gateway.access_token)
or []
)
doorbells = (
await self._api.async_get_doorbells(self._august_gateway.access_token) or []
token = self._august_gateway.access_token
user_data, locks, doorbells = await asyncio.gather(
self._api.async_get_user(token),
self._api.async_get_operable_locks(token),
self._api.async_get_doorbells(token),
)
if not doorbells:
doorbells = []
if not locks:
locks = []
self._doorbells_by_id = {device.device_id: device for device in doorbells}
self._locks_by_id = {device.device_id: device for device in locks}
self._house_ids = {
device.house_id for device in itertools.chain(locks, doorbells)
}
self._house_ids = {device.house_id for device in chain(locks, doorbells)}
await self._async_refresh_device_detail_by_ids(
[device.device_id for device in itertools.chain(locks, doorbells)]
[device.device_id for device in chain(locks, doorbells)]
)
# We remove all devices that we are missing
@ -142,10 +156,32 @@ class AugustData(AugustSubscriberMixin):
self._remove_inoperative_locks()
self._remove_inoperative_doorbells()
pubnub = AugustPubNub()
for device in self._device_detail_by_id.values():
pubnub.register_device(device)
self.activity_stream = ActivityStream(
self._hass, self._api, self._august_gateway, self._house_ids
self._hass, self._api, self._august_gateway, self._house_ids, pubnub
)
await self.activity_stream.async_setup()
pubnub.subscribe(self.async_pubnub_message)
self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub)
@callback
def async_pubnub_message(self, device_id, date_time, message):
"""Process a pubnub message."""
device = self.get_device_detail(device_id)
activities = activities_from_pubnub_message(device, date_time, message)
if activities:
self.activity_stream.async_process_newer_device_activities(activities)
self.async_signal_device_id_update(device.device_id)
self.activity_stream.async_schedule_house_id_refresh(device.house_id)
@callback
def async_stop(self):
"""Stop the subscriptions."""
self._pubnub_unsub()
self.activity_stream.async_stop()
@property
def doorbells(self):
@ -165,11 +201,22 @@ class AugustData(AugustSubscriberMixin):
await self._async_refresh_device_detail_by_ids(self._subscriptions.keys())
async def _async_refresh_device_detail_by_ids(self, device_ids_list):
for device_id in device_ids_list:
await asyncio.gather(
*[
self._async_refresh_device_detail_by_id(device_id)
for device_id in device_ids_list
]
)
async def _async_refresh_device_detail_by_id(self, device_id):
if device_id in self._locks_by_id:
if self.activity_stream and self.activity_stream.pubnub.connected:
saved_attrs = _save_live_attrs(self._device_detail_by_id[device_id])
await self._async_update_device_detail(
self._locks_by_id[device_id], self._api.async_get_lock_detail
)
if self.activity_stream and self.activity_stream.pubnub.connected:
_restore_live_attrs(self._device_detail_by_id[device_id], saved_attrs)
# keypads are always attached to locks
if (
device_id in self._device_detail_by_id
@ -213,9 +260,9 @@ class AugustData(AugustSubscriberMixin):
def _get_device_name(self, device_id):
"""Return doorbell or lock name as August has it stored."""
if self._locks_by_id.get(device_id):
if device_id in self._locks_by_id:
return self._locks_by_id[device_id].device_name
if self._doorbells_by_id.get(device_id):
if device_id in self._doorbells_by_id:
return self._doorbells_by_id[device_id].device_name
async def async_lock(self, device_id):
@ -252,8 +299,7 @@ class AugustData(AugustSubscriberMixin):
return ret
def _remove_inoperative_doorbells(self):
doorbells = list(self.doorbells)
for doorbell in doorbells:
for doorbell in list(self.doorbells):
device_id = doorbell.device_id
doorbell_is_operative = False
doorbell_detail = self._device_detail_by_id.get(device_id)
@ -273,9 +319,7 @@ class AugustData(AugustSubscriberMixin):
# Remove non-operative locks as there must
# be a bridge (August Connect) for them to
# be usable
locks = list(self.locks)
for lock in locks:
for lock in list(self.locks):
device_id = lock.device_id
lock_is_operative = False
lock_detail = self._device_detail_by_id.get(device_id)
@ -289,14 +333,27 @@ class AugustData(AugustSubscriberMixin):
"The lock %s could not be setup because it does not have a bridge (Connect)",
lock.device_name,
)
elif not lock_detail.bridge.operative:
_LOGGER.info(
"The lock %s could not be setup because the bridge (Connect) is not operative",
lock.device_name,
)
# Bridge may come back online later so we still add the device since we will
# have a pubnub subscription to tell use when it recovers
else:
lock_is_operative = True
if not lock_is_operative:
del self._locks_by_id[device_id]
del self._device_detail_by_id[device_id]
def _save_live_attrs(lock_detail):
"""Store the attributes that the lock detail api may have an invalid cache for.
Since we are connected to pubnub we may have more current data
then the api so we want to restore the most current data after
updating battery state etc.
"""
return {attr: getattr(lock_detail, attr) for attr in API_CACHED_ATTRS}
def _restore_live_attrs(lock_detail, attrs):
"""Restore the non-cache attributes after a cached update."""
for attr, value in attrs.items():
setattr(lock_detail, attr, value)

View file

@ -1,8 +1,12 @@
"""Consume the august activity stream."""
import asyncio
import logging
from aiohttp import ClientError
from homeassistant.core import callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from .const import ACTIVITY_UPDATE_INTERVAL
@ -17,27 +21,58 @@ ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500
class ActivityStream(AugustSubscriberMixin):
"""August activity stream handler."""
def __init__(self, hass, api, august_gateway, house_ids):
def __init__(self, hass, api, august_gateway, house_ids, pubnub):
"""Init August activity stream object."""
super().__init__(hass, ACTIVITY_UPDATE_INTERVAL)
self._hass = hass
self._schedule_updates = {}
self._august_gateway = august_gateway
self._api = api
self._house_ids = house_ids
self._latest_activities_by_id_type = {}
self._latest_activities = {}
self._last_update_time = None
self._abort_async_track_time_interval = None
self.pubnub = pubnub
self._update_debounce = {}
async def async_setup(self):
"""Token refresh check and catch up the activity stream."""
await self._async_refresh(utcnow)
for house_id in self._house_ids:
self._update_debounce[house_id] = self._async_create_debouncer(house_id)
await self._async_refresh(utcnow())
@callback
def _async_create_debouncer(self, house_id):
"""Create a debouncer for the house id."""
async def _async_update_house_id():
await self._async_update_house_id(house_id)
return Debouncer(
self._hass,
_LOGGER,
cooldown=ACTIVITY_UPDATE_INTERVAL.seconds,
immediate=True,
function=_async_update_house_id,
)
@callback
def async_stop(self):
"""Cleanup any debounces."""
for debouncer in self._update_debounce.values():
debouncer.async_cancel()
for house_id in self._schedule_updates:
if self._schedule_updates[house_id] is not None:
self._schedule_updates[house_id]()
self._schedule_updates[house_id] = None
def get_latest_device_activity(self, device_id, activity_types):
"""Return latest activity that is one of the acitivty_types."""
if device_id not in self._latest_activities_by_id_type:
if device_id not in self._latest_activities:
return None
latest_device_activities = self._latest_activities_by_id_type[device_id]
latest_device_activities = self._latest_activities[device_id]
latest_activity = None
for activity_type in activity_types:
@ -54,21 +89,48 @@ class ActivityStream(AugustSubscriberMixin):
async def _async_refresh(self, time):
"""Update the activity stream from August."""
# This is the only place we refresh the api token
await self._august_gateway.async_refresh_access_token_if_needed()
if self.pubnub.connected:
_LOGGER.debug("Skipping update because pubnub is connected")
return
await self._async_update_device_activities(time)
async def _async_update_device_activities(self, time):
_LOGGER.debug("Start retrieving device activities")
await asyncio.gather(
*[
self._update_debounce[house_id].async_call()
for house_id in self._house_ids
]
)
self._last_update_time = time
limit = (
ACTIVITY_STREAM_FETCH_LIMIT
if self._last_update_time
else ACTIVITY_CATCH_UP_FETCH_LIMIT
@callback
def async_schedule_house_id_refresh(self, house_id):
"""Update for a house activities now and once in the future."""
if self._schedule_updates.get(house_id):
self._schedule_updates[house_id]()
self._schedule_updates[house_id] = None
async def _update_house_activities(_):
await self._update_debounce[house_id].async_call()
self._hass.async_create_task(self._update_debounce[house_id].async_call())
# Schedule an update past the debounce to ensure
# we catch the case where the lock operator is
# not updated or the lock failed
self._schedule_updates[house_id] = async_call_later(
self._hass, ACTIVITY_UPDATE_INTERVAL.seconds + 1, _update_house_activities
)
for house_id in self._house_ids:
async def _async_update_house_id(self, house_id):
"""Update device activities for a house."""
if self._last_update_time:
limit = ACTIVITY_STREAM_FETCH_LIMIT
else:
limit = ACTIVITY_CATCH_UP_FETCH_LIMIT
_LOGGER.debug("Updating device activity for house id %s", house_id)
try:
activities = await self._api.async_get_house_activities(
@ -81,15 +143,17 @@ class ActivityStream(AugustSubscriberMixin):
ex,
)
# Make sure we process the next house if one of them fails
continue
return
_LOGGER.debug(
"Completed retrieving device activities for house id %s", house_id
)
updated_device_ids = self._process_newer_device_activities(activities)
updated_device_ids = self.async_process_newer_device_activities(activities)
if not updated_device_ids:
return
if updated_device_ids:
for device_id in updated_device_ids:
_LOGGER.debug(
"async_signal_device_id_update (from activity stream): %s",
@ -97,19 +161,14 @@ class ActivityStream(AugustSubscriberMixin):
)
self.async_signal_device_id_update(device_id)
self._last_update_time = time
def _process_newer_device_activities(self, activities):
def async_process_newer_device_activities(self, activities):
"""Process activities if they are newer than the last one."""
updated_device_ids = set()
for activity in activities:
device_id = activity.device_id
activity_type = activity.activity_type
self._latest_activities_by_id_type.setdefault(device_id, {})
lastest_activity = self._latest_activities_by_id_type[device_id].get(
activity_type
)
device_activities = self._latest_activities.setdefault(device_id, {})
lastest_activity = device_activities.get(activity_type)
# Ignore activities that are older than the latest one
if (
@ -118,7 +177,7 @@ class ActivityStream(AugustSubscriberMixin):
):
continue
self._latest_activities_by_id_type[device_id][activity_type] = activity
device_activities[activity_type] = activity
updated_device_ids.add(device_id)

View file

@ -2,9 +2,9 @@
from datetime import datetime, timedelta
import logging
from august.activity import ActivityType
from august.lock import LockDoorStatus
from august.util import update_lock_detail_from_activity
from yalexs.activity import ACTION_DOORBELL_CALL_MISSED, ActivityType
from yalexs.lock import LockDoorStatus
from yalexs.util import update_lock_detail_from_activity
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
@ -14,15 +14,15 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
from homeassistant.helpers.event import async_call_later
from .const import DATA_AUGUST, DOMAIN
from .const import ACTIVITY_UPDATE_INTERVAL, DATA_AUGUST, DOMAIN
from .entity import AugustEntityMixin
_LOGGER = logging.getLogger(__name__)
TIME_TO_DECLARE_DETECTION = timedelta(seconds=60)
TIME_TO_DECLARE_DETECTION = timedelta(seconds=ACTIVITY_UPDATE_INTERVAL.seconds)
TIME_TO_RECHECK_DETECTION = timedelta(seconds=ACTIVITY_UPDATE_INTERVAL.seconds * 3)
def _retrieve_online_state(data, detail):
@ -35,30 +35,43 @@ def _retrieve_online_state(data, detail):
def _retrieve_motion_state(data, detail):
return _activity_time_based_state(
data,
detail.device_id,
[ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING],
latest = data.activity_stream.get_latest_device_activity(
detail.device_id, {ActivityType.DOORBELL_MOTION}
)
if latest is None:
return False
return _activity_time_based_state(latest)
def _retrieve_ding_state(data, detail):
return _activity_time_based_state(
data, detail.device_id, [ActivityType.DOORBELL_DING]
latest = data.activity_stream.get_latest_device_activity(
detail.device_id, {ActivityType.DOORBELL_DING}
)
if latest is None:
return False
def _activity_time_based_state(data, device_id, activity_types):
if (
data.activity_stream.pubnub.connected
and latest.action == ACTION_DOORBELL_CALL_MISSED
):
return False
return _activity_time_based_state(latest)
def _activity_time_based_state(latest):
"""Get the latest state of the sensor."""
latest = data.activity_stream.get_latest_device_activity(device_id, activity_types)
if latest is not None:
start = latest.activity_start_time
end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION
return start <= datetime.now() <= end
return None
return start <= _native_datetime() <= end
def _native_datetime():
"""Return time in the format august uses without timezone."""
return datetime.now()
SENSOR_NAME = 0
@ -143,12 +156,19 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity):
def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
door_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, [ActivityType.DOOR_OPERATION]
self._device_id, {ActivityType.DOOR_OPERATION}
)
if door_activity is not None:
update_lock_detail_from_activity(self._detail, door_activity)
bridge_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, {ActivityType.BRIDGE_OPERATION}
)
if bridge_activity is not None:
update_lock_detail_from_activity(self._detail, bridge_activity)
@property
def unique_id(self) -> str:
"""Get the unique of the door open binary sensor."""
@ -179,25 +199,30 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity):
"""Return true if the binary sensor is on."""
return self._state
@property
def _sensor_config(self):
"""Return the config for the sensor."""
return SENSOR_TYPES_DOORBELL[self._sensor_type]
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_DEVICE_CLASS]
return self._sensor_config[SENSOR_DEVICE_CLASS]
@property
def name(self):
"""Return the name of the binary sensor."""
return f"{self._device.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}"
return f"{self._device.device_name} {self._sensor_config[SENSOR_NAME]}"
@property
def _state_provider(self):
"""Return the state provider for the binary sensor."""
return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_PROVIDER]
return self._sensor_config[SENSOR_STATE_PROVIDER]
@property
def _is_time_based(self):
"""Return true of false if the sensor is time based."""
return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_IS_TIME_BASED]
return self._sensor_config[SENSOR_STATE_IS_TIME_BASED]
@callback
def _update_from_data(self):
@ -228,14 +253,17 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity):
"""Timer callback for sensor update."""
self._check_for_off_update_listener = None
self._update_from_data()
if not self._state:
self.async_write_ha_state()
self._check_for_off_update_listener = async_track_point_in_utc_time(
self.hass, _scheduled_update, utcnow() + TIME_TO_DECLARE_DETECTION
self._check_for_off_update_listener = async_call_later(
self.hass, TIME_TO_RECHECK_DETECTION.seconds, _scheduled_update
)
def _cancel_any_pending_updates(self):
"""Cancel any updates to recheck a sensor to see if it is ready to turn off."""
if self._check_for_off_update_listener:
if not self._check_for_off_update_listener:
return
_LOGGER.debug("%s: canceled pending update", self.entity_id)
self._check_for_off_update_listener()
self._check_for_off_update_listener = None
@ -248,7 +276,4 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity):
@property
def unique_id(self) -> str:
"""Get the unique id of the doorbell sensor."""
return (
f"{self._device_id}_"
f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}"
)
return f"{self._device_id}_{self._sensor_config[SENSOR_NAME].lower()}"

View file

@ -1,7 +1,7 @@
"""Support for August doorbell camera."""
from august.activity import ActivityType
from august.util import update_doorbell_image_from_activity
from yalexs.activity import ActivityType
from yalexs.util import update_doorbell_image_from_activity
from homeassistant.components.camera import Camera
from homeassistant.core import callback
@ -63,7 +63,7 @@ class AugustCamera(AugustEntityMixin, Camera):
def _update_from_data(self):
"""Get the latest state of the sensor."""
doorbell_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, [ActivityType.DOORBELL_MOTION]
self._device_id, {ActivityType.DOORBELL_MOTION}
)
if doorbell_activity is not None:

View file

@ -1,8 +1,8 @@
"""Config flow for August integration."""
import logging
from august.authenticator import ValidationResult
import voluptuous as vol
from yalexs.authenticator import ValidationResult
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME

View file

@ -5,8 +5,8 @@ import logging
import os
from aiohttp import ClientError, ClientResponseError
from august.api_async import ApiAsync
from august.authenticator_async import AuthenticationState, AuthenticatorAsync
from yalexs.api_async import ApiAsync
from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync
from homeassistant.const import (
CONF_PASSWORD,

View file

@ -1,9 +1,9 @@
"""Support for August lock."""
import logging
from august.activity import ActivityType
from august.lock import LockStatus
from august.util import update_lock_detail_from_activity
from yalexs.activity import ActivityType
from yalexs.lock import LockStatus
from yalexs.util import update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity
from homeassistant.const import ATTR_BATTERY_LEVEL
@ -73,13 +73,21 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
lock_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, [ActivityType.LOCK_OPERATION]
self._device_id,
{ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
)
if lock_activity is not None:
self._changed_by = lock_activity.operated_by
update_lock_detail_from_activity(self._detail, lock_activity)
bridge_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, {ActivityType.BRIDGE_OPERATION}
)
if bridge_activity is not None:
update_lock_detail_from_activity(self._detail, bridge_activity)
self._update_lock_status_from_detail()
@property

View file

@ -2,7 +2,7 @@
"domain": "august",
"name": "August",
"documentation": "https://www.home-assistant.io/integrations/august",
"requirements": ["py-august==0.25.2"],
"requirements": ["yalexs==1.1.4"],
"codeowners": ["@bdraco"],
"dhcp": [
{"hostname":"connect","macaddress":"D86162*"},

View file

@ -1,7 +1,7 @@
"""Support for August sensors."""
import logging
from august.activity import ActivityType
from yalexs.activity import ActivityType
from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
from homeassistant.const import ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE
@ -154,7 +154,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity):
def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
lock_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, [ActivityType.LOCK_OPERATION]
self._device_id, {ActivityType.LOCK_OPERATION}
)
self._available = True

View file

@ -1191,9 +1191,6 @@ pushover_complete==1.1.1
# homeassistant.components.rpi_gpio_pwm
pwmled==1.6.7
# homeassistant.components.august
py-august==0.25.2
# homeassistant.components.canary
py-canary==0.5.1
@ -2347,6 +2344,9 @@ xs1-api-client==3.0.0
# homeassistant.components.yale_smart_alarm
yalesmartalarmclient==0.1.6
# homeassistant.components.august
yalexs==1.1.4
# homeassistant.components.yeelight
yeelight==0.5.4

View file

@ -616,9 +616,6 @@ pure-python-adb[async]==0.3.0.dev0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
# homeassistant.components.august
py-august==0.25.2
# homeassistant.components.canary
py-canary==0.5.1
@ -1208,6 +1205,9 @@ xbox-webapi==2.0.8
# homeassistant.components.zestimate
xmltodict==0.12.0
# homeassistant.components.august
yalexs==1.1.4
# homeassistant.components.yeelight
yeelight==0.5.4

View file

@ -6,21 +6,26 @@ import time
# from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from august.activity import (
from yalexs.activity import (
ACTIVITY_ACTIONS_BRIDGE_OPERATION,
ACTIVITY_ACTIONS_DOOR_OPERATION,
ACTIVITY_ACTIONS_DOORBELL_DING,
ACTIVITY_ACTIONS_DOORBELL_MOTION,
ACTIVITY_ACTIONS_DOORBELL_VIEW,
ACTIVITY_ACTIONS_LOCK_OPERATION,
SOURCE_LOCK_OPERATE,
SOURCE_LOG,
BridgeOperationActivity,
DoorbellDingActivity,
DoorbellMotionActivity,
DoorbellViewActivity,
DoorOperationActivity,
LockOperationActivity,
)
from august.authenticator import AuthenticationState
from august.doorbell import Doorbell, DoorbellDetail
from august.lock import Lock, LockDetail
from yalexs.authenticator import AuthenticationState
from yalexs.doorbell import Doorbell, DoorbellDetail
from yalexs.lock import Lock, LockDetail
from yalexs.pubnub_async import AugustPubNub
from homeassistant.components.august.const import CONF_LOGIN_METHOD, DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
@ -48,7 +53,9 @@ def _mock_authenticator(auth_state):
@patch("homeassistant.components.august.gateway.ApiAsync")
@patch("homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate")
async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock):
async def _mock_setup_august(
hass, api_instance, pubnub_mock, authenticate_mock, api_mock
):
"""Set up august integration."""
authenticate_mock.side_effect = MagicMock(
return_value=_mock_august_authentication(
@ -62,16 +69,21 @@ async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock):
options={},
)
entry.add_to_hass(hass)
with patch("homeassistant.components.august.async_create_pubnub"), patch(
"homeassistant.components.august.AugustPubNub", return_value=pubnub_mock
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return True
return entry
async def _create_august_with_devices(
hass, devices, api_call_side_effects=None, activities=None
hass, devices, api_call_side_effects=None, activities=None, pubnub=None
):
if api_call_side_effects is None:
api_call_side_effects = {}
if pubnub is None:
pubnub = AugustPubNub()
device_data = {"doorbells": [], "locks": []}
for device in devices:
@ -152,10 +164,12 @@ async def _create_august_with_devices(
"unlock_return_activities"
] = unlock_return_activities_side_effect
return await _mock_setup_august_with_api_side_effects(hass, api_call_side_effects)
return await _mock_setup_august_with_api_side_effects(
hass, api_call_side_effects, pubnub
)
async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects):
async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, pubnub):
api_instance = MagicMock(name="Api")
if api_call_side_effects["get_lock_detail"]:
@ -193,11 +207,13 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects):
side_effect=api_call_side_effects["unlock_return_activities"]
)
return await _mock_setup_august(hass, api_instance)
api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"})
return await _mock_setup_august(hass, api_instance, pubnub)
def _mock_august_authentication(token_text, token_timestamp, state):
authentication = MagicMock(name="august.authentication")
authentication = MagicMock(name="yalexs.authentication")
type(authentication).state = PropertyMock(return_value=state)
type(authentication).access_token = PropertyMock(return_value=token_text)
type(authentication).access_token_expires = PropertyMock(
@ -301,23 +317,25 @@ async def _mock_doorsense_missing_august_lock_detail(hass):
def _mock_lock_operation_activity(lock, action, offset):
return LockOperationActivity(
SOURCE_LOCK_OPERATE,
{
"dateTime": (time.time() + offset) * 1000,
"deviceID": lock.device_id,
"deviceType": "lock",
"action": action,
}
},
)
def _mock_door_operation_activity(lock, action, offset):
return DoorOperationActivity(
SOURCE_LOCK_OPERATE,
{
"dateTime": (time.time() + offset) * 1000,
"deviceID": lock.device_id,
"deviceType": "lock",
"action": action,
}
},
)
@ -327,13 +345,15 @@ def _activity_from_dict(activity_dict):
activity_dict["dateTime"] = time.time() * 1000
if action in ACTIVITY_ACTIONS_DOORBELL_DING:
return DoorbellDingActivity(activity_dict)
return DoorbellDingActivity(SOURCE_LOG, activity_dict)
if action in ACTIVITY_ACTIONS_DOORBELL_MOTION:
return DoorbellMotionActivity(activity_dict)
return DoorbellMotionActivity(SOURCE_LOG, activity_dict)
if action in ACTIVITY_ACTIONS_DOORBELL_VIEW:
return DoorbellViewActivity(activity_dict)
return DoorbellViewActivity(SOURCE_LOG, activity_dict)
if action in ACTIVITY_ACTIONS_LOCK_OPERATION:
return LockOperationActivity(activity_dict)
return LockOperationActivity(SOURCE_LOG, activity_dict)
if action in ACTIVITY_ACTIONS_DOOR_OPERATION:
return DoorOperationActivity(activity_dict)
return DoorOperationActivity(SOURCE_LOG, activity_dict)
if action in ACTIVITY_ACTIONS_BRIDGE_OPERATION:
return BridgeOperationActivity(SOURCE_LOG, activity_dict)
return None

View file

@ -1,4 +1,9 @@
"""The binary_sensor tests for the august platform."""
import datetime
from unittest.mock import Mock, patch
from yalexs.pubnub_async import AugustPubNub
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
@ -9,7 +14,9 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
)
from homeassistant.helpers import device_registry as dr
import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed
from tests.components.august.mocks import (
_create_august_with_devices,
_mock_activities_from_fixture,
@ -52,6 +59,22 @@ async def test_doorsense(hass):
assert binary_sensor_online_with_doorsense_name.state == STATE_OFF
async def test_lock_bridge_offline(hass):
"""Test creation of a lock with doorsense and bridge that goes offline."""
lock_one = await _mock_lock_from_fixture(
hass, "get_lock.online_with_doorsense.json"
)
activities = await _mock_activities_from_fixture(
hass, "get_activity.bridge_offline.json"
)
await _create_august_with_devices(hass, [lock_one], activities=activities)
binary_sensor_online_with_doorsense_name = hass.states.get(
"binary_sensor.online_with_doorsense_name_open"
)
assert binary_sensor_online_with_doorsense_name.state == STATE_UNAVAILABLE
async def test_create_doorbell(hass):
"""Test creation of a doorbell."""
doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
@ -112,6 +135,108 @@ async def test_create_doorbell_with_motion(hass):
"binary_sensor.k98gidt45gul_name_ding"
)
assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
new_time = dt_util.utcnow() + datetime.timedelta(seconds=40)
native_time = datetime.datetime.now() + datetime.timedelta(seconds=40)
with patch(
"homeassistant.components.august.binary_sensor._native_datetime",
return_value=native_time,
):
async_fire_time_changed(hass, new_time)
await hass.async_block_till_done()
binary_sensor_k98gidt45gul_name_motion = hass.states.get(
"binary_sensor.k98gidt45gul_name_motion"
)
assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
async def test_doorbell_update_via_pubnub(hass):
"""Test creation of a doorbell that can be updated via pubnub."""
doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
pubnub = AugustPubNub()
await _create_august_with_devices(hass, [doorbell_one], pubnub=pubnub)
assert doorbell_one.pubsub_channel == "7c7a6672-59c8-3333-ffff-dcd98705cccc"
binary_sensor_k98gidt45gul_name_motion = hass.states.get(
"binary_sensor.k98gidt45gul_name_motion"
)
assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
binary_sensor_k98gidt45gul_name_ding = hass.states.get(
"binary_sensor.k98gidt45gul_name_ding"
)
assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
pubnub.message(
pubnub,
Mock(
channel=doorbell_one.pubsub_channel,
timetoken=dt_util.utcnow().timestamp() * 10000000,
message={
"status": "imagecapture",
"data": {
"result": {
"created_at": "2021-03-16T01:07:08.817Z",
"secure_url": "https://dyu7azbnaoi74.cloudfront.net/zip/images/zip.jpeg",
},
},
},
),
)
await hass.async_block_till_done()
binary_sensor_k98gidt45gul_name_motion = hass.states.get(
"binary_sensor.k98gidt45gul_name_motion"
)
assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON
binary_sensor_k98gidt45gul_name_ding = hass.states.get(
"binary_sensor.k98gidt45gul_name_ding"
)
assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
new_time = dt_util.utcnow() + datetime.timedelta(seconds=40)
native_time = datetime.datetime.now() + datetime.timedelta(seconds=40)
with patch(
"homeassistant.components.august.binary_sensor._native_datetime",
return_value=native_time,
):
async_fire_time_changed(hass, new_time)
await hass.async_block_till_done()
binary_sensor_k98gidt45gul_name_motion = hass.states.get(
"binary_sensor.k98gidt45gul_name_motion"
)
assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
pubnub.message(
pubnub,
Mock(
channel=doorbell_one.pubsub_channel,
timetoken=dt_util.utcnow().timestamp() * 10000000,
message={
"status": "buttonpush",
},
),
)
await hass.async_block_till_done()
binary_sensor_k98gidt45gul_name_ding = hass.states.get(
"binary_sensor.k98gidt45gul_name_ding"
)
assert binary_sensor_k98gidt45gul_name_ding.state == STATE_ON
new_time = dt_util.utcnow() + datetime.timedelta(seconds=40)
native_time = datetime.datetime.now() + datetime.timedelta(seconds=40)
with patch(
"homeassistant.components.august.binary_sensor._native_datetime",
return_value=native_time,
):
async_fire_time_changed(hass, new_time)
await hass.async_block_till_done()
binary_sensor_k98gidt45gul_name_ding = hass.states.get(
"binary_sensor.k98gidt45gul_name_ding"
)
assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
async def test_doorbell_device_registry(hass):

View file

@ -1,7 +1,7 @@
"""Test the August config flow."""
from unittest.mock import patch
from august.authenticator import ValidationResult
from yalexs.authenticator import ValidationResult
from homeassistant import config_entries, setup
from homeassistant.components.august.const import (

View file

@ -1,7 +1,7 @@
"""The gateway tests for the august platform."""
from unittest.mock import MagicMock, patch
from august.authenticator_common import AuthenticationState
from yalexs.authenticator_common import AuthenticationState
from homeassistant.components.august.const import DOMAIN
from homeassistant.components.august.gateway import AugustGateway

View file

@ -3,13 +3,14 @@ import asyncio
from unittest.mock import patch
from aiohttp import ClientResponseError
from august.authenticator_common import AuthenticationState
from august.exceptions import AugustApiAIOHTTPError
from yalexs.authenticator_common import AuthenticationState
from yalexs.exceptions import AugustApiAIOHTTPError
from homeassistant import setup
from homeassistant.components.august.const import DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.config_entries import (
ENTRY_STATE_LOADED,
ENTRY_STATE_SETUP_ERROR,
ENTRY_STATE_SETUP_RETRY,
)
@ -46,7 +47,7 @@ async def test_august_is_offline(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"august.authenticator_async.AuthenticatorAsync.async_authenticate",
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
side_effect=asyncio.TimeoutError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
@ -152,7 +153,7 @@ async def test_auth_fails(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"august.authenticator_async.AuthenticatorAsync.async_authenticate",
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
side_effect=ClientResponseError(None, None, status=401),
):
await hass.config_entries.async_setup(config_entry.entry_id)
@ -178,7 +179,7 @@ async def test_bad_password(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"august.authenticator_async.AuthenticatorAsync.async_authenticate",
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_authentication(
"original_token", 1234, AuthenticationState.BAD_PASSWORD
),
@ -206,7 +207,7 @@ async def test_http_failure(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"august.authenticator_async.AuthenticatorAsync.async_authenticate",
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
side_effect=ClientResponseError(None, None, status=500),
):
await hass.config_entries.async_setup(config_entry.entry_id)
@ -230,7 +231,7 @@ async def test_unknown_auth_state(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"august.authenticator_async.AuthenticatorAsync.async_authenticate",
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_authentication("original_token", 1234, None),
):
await hass.config_entries.async_setup(config_entry.entry_id)
@ -256,7 +257,7 @@ async def test_requires_validation_state(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"august.authenticator_async.AuthenticatorAsync.async_authenticate",
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_authentication(
"original_token", 1234, AuthenticationState.REQUIRES_VALIDATION
),
@ -268,3 +269,18 @@ async def test_requires_validation_state(hass):
assert len(hass.config_entries.flow.async_progress()) == 1
assert hass.config_entries.flow.async_progress()[0]["context"]["source"] == "reauth"
async def test_load_unload(hass):
"""Config entry can be unloaded."""
august_operative_lock = await _mock_operative_august_lock_detail(hass)
august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass)
config_entry = await _create_august_with_devices(
hass, [august_operative_lock, august_inoperative_lock]
)
assert config_entry.state == ENTRY_STATE_LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()

View file

@ -1,15 +1,23 @@
"""The lock tests for the august platform."""
import datetime
from unittest.mock import Mock
from yalexs.pubnub_async import AugustPubNub
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_LOCK,
SERVICE_UNLOCK,
STATE_LOCKED,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
STATE_UNLOCKED,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed
from tests.components.august.mocks import (
_create_august_with_devices,
_mock_activities_from_fixture,
@ -112,3 +120,116 @@ async def test_one_lock_unknown_state(hass):
lock_brokenid_name = hass.states.get("lock.brokenid_name")
assert lock_brokenid_name.state == STATE_UNKNOWN
async def test_lock_bridge_offline(hass):
"""Test creation of a lock with doorsense and bridge that goes offline."""
lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
activities = await _mock_activities_from_fixture(
hass, "get_activity.bridge_offline.json"
)
await _create_august_with_devices(hass, [lock_one], activities=activities)
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_UNAVAILABLE
async def test_lock_bridge_online(hass):
"""Test creation of a lock with doorsense and bridge that goes offline."""
lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
activities = await _mock_activities_from_fixture(
hass, "get_activity.bridge_online.json"
)
await _create_august_with_devices(hass, [lock_one], activities=activities)
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_LOCKED
async def test_lock_update_via_pubnub(hass):
"""Test creation of a lock with doorsense and bridge."""
lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
assert lock_one.pubsub_channel == "pubsub"
pubnub = AugustPubNub()
activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json")
config_entry = await _create_august_with_devices(
hass, [lock_one], activities=activities, pubnub=pubnub
)
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_LOCKED
pubnub.message(
pubnub,
Mock(
channel=lock_one.pubsub_channel,
timetoken=dt_util.utcnow().timestamp() * 10000000,
message={
"status": "kAugLockState_Unlocking",
},
),
)
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_UNLOCKED
pubnub.message(
pubnub,
Mock(
channel=lock_one.pubsub_channel,
timetoken=dt_util.utcnow().timestamp() * 10000000,
message={
"status": "kAugLockState_Locking",
},
),
)
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_LOCKED
async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30))
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_LOCKED
pubnub.connected = True
async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30))
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_LOCKED
# Ensure pubnub status is always preserved
async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2))
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_LOCKED
pubnub.message(
pubnub,
Mock(
channel=lock_one.pubsub_channel,
timetoken=dt_util.utcnow().timestamp() * 10000000,
message={
"status": "kAugLockState_Unlocking",
},
),
)
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_UNLOCKED
async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4))
await hass.async_block_till_done()
lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
assert lock_online_with_doorsense_name.state == STATE_UNLOCKED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()

View file

@ -0,0 +1,34 @@
[{
"entities" : {
"activity" : "mockActivity2",
"house" : "123",
"device" : "online_with_doorsense",
"callingUser" : "mockUserId2",
"otherUser" : "deleted"
},
"callingUser" : {
"LastName" : "elven princess",
"UserID" : "mockUserId2",
"FirstName" : "Your favorite"
},
"otherUser" : {
"LastName" : "User",
"UserName" : "deleteduser",
"FirstName" : "Unknown",
"UserID" : "deleted",
"PhoneNo" : "deleted"
},
"deviceType" : "lock",
"deviceName" : "MockHouseTDoor",
"action" : "associated_bridge_offline",
"dateTime" : 1582007218000,
"info" : {
"remote" : true,
"DateLogActionID" : "ABC+Time"
},
"deviceID" : "online_with_doorsense",
"house" : {
"houseName" : "MockHouse",
"houseID" : "123"
}
}]

View file

@ -0,0 +1,34 @@
[{
"entities" : {
"activity" : "mockActivity2",
"house" : "123",
"device" : "online_with_doorsense",
"callingUser" : "mockUserId2",
"otherUser" : "deleted"
},
"callingUser" : {
"LastName" : "elven princess",
"UserID" : "mockUserId2",
"FirstName" : "Your favorite"
},
"otherUser" : {
"LastName" : "User",
"UserName" : "deleteduser",
"FirstName" : "Unknown",
"UserID" : "deleted",
"PhoneNo" : "deleted"
},
"deviceType" : "lock",
"deviceName" : "MockHouseTDoor",
"action" : "associated_bridge_online",
"dateTime" : 1582007218000,
"info" : {
"remote" : true,
"DateLogActionID" : "ABC+Time"
},
"deviceID" : "online_with_doorsense",
"house" : {
"houseName" : "MockHouse",
"houseID" : "123"
}
}]

View file

@ -55,7 +55,7 @@
"reconnect"
],
"doorbellID" : "K98GiDT45GUL",
"HouseID" : "3dd2accaea08",
"HouseID" : "mockhouseid1",
"telemetry" : {
"signal_level" : -56,
"date" : "2017-12-10 08:05:12",

View file

@ -13,9 +13,10 @@
"updated" : "2000-00-00T00:00:00.447Z"
}
},
"pubsubChannel":"pubsub",
"Calibrated" : false,
"Created" : "2000-00-00T00:00:00.447Z",
"HouseID" : "123",
"HouseID" : "mockhouseid1",
"HouseName" : "Test",
"LockID" : "online_with_doorsense",
"LockName" : "Online door with doorsense",