Audit state handling off august bridges and sensors (#31935)

* Audit state handling of august bridges and sensors

This addresses issue #29980

* Prevent setting up august locks that do not have a bridge as they will never work

* Prevent locks showing available when their bridge is offline

* Prevent door sensors from showing available when their bridge is offline

* Prevent creating door sensors for locks that do not have them

* Prevent doorbells showing unavailable when they are in standby mode

* Set SCAN_INTERVAL for binary_sensors to 5 seconds as
  data comes in from the activity endpoint more frequently

* Update homeassistant/components/august/__init__.py

raise if the detail is missing when checking doorsense

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

* Handle another place where the lock detail could not exist

* Address review comments

* Handle lock detail update failing and add test

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
pull/31962/head
J. Nick Koston 2020-02-18 14:11:05 -06:00 committed by GitHub
parent 239dfaba4b
commit 17f3332c89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 202 additions and 54 deletions

View File

@ -225,6 +225,13 @@ class AugustData:
self._door_state_by_id = {}
self._activities_by_id = {}
# We check the locks right away so we can
# remove inoperative ones
self._update_locks_status()
self._update_locks_detail()
self._filter_inoperative_locks()
@property
def house_ids(self):
"""Return a list of house_ids."""
@ -352,6 +359,14 @@ class AugustData:
self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc
return True
def lock_has_doorsense(self, lock_id):
"""Determine if a lock has doorsense installed and can tell when the door is open or closed."""
# We do not update here since this is not expected
# to change until restart
if self._lock_detail_by_id[lock_id] is None:
return False
return self._lock_detail_by_id[lock_id].doorsense
async def async_get_lock_status(self, lock_id):
"""Return status if the door is locked or unlocked.
@ -497,6 +512,33 @@ class AugustData:
device_id,
)
def _filter_inoperative_locks(self):
# Remove non-operative locks as there must
# be a bridge (August Connect) for them to
# be usable
operative_locks = []
for lock in self._locks:
lock_detail = self._lock_detail_by_id.get(lock.device_id)
if lock_detail is None:
_LOGGER.info(
"The lock %s could not be setup because the system could not fetch details about the lock.",
lock.device_name,
)
elif lock_detail.bridge is None:
_LOGGER.info(
"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,
)
else:
operative_locks.append(lock)
self._locks = operative_locks
def _call_api_operation_that_requires_bridge(
device_name, operation_name, func, *args, **kwargs

View File

@ -12,7 +12,7 @@ from . import DATA_AUGUST
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=10)
SCAN_INTERVAL = timedelta(seconds=5)
async def _async_retrieve_door_state(data, lock):
@ -51,11 +51,15 @@ async def _async_activity_time_based_state(data, doorbell, activity_types):
if latest is not None:
start = latest.activity_start_time
end = latest.activity_end_time + timedelta(seconds=30)
end = latest.activity_end_time + timedelta(seconds=45)
return start <= datetime.now() <= end
return None
SENSOR_NAME = 0
SENSOR_DEVICE_CLASS = 1
SENSOR_STATE_PROVIDER = 2
# sensor_type: [name, device_class, async_state_provider]
SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _async_retrieve_door_state]}
@ -73,18 +77,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
for door in data.locks:
for sensor_type in SENSOR_TYPES_DOOR:
async_state_provider = SENSOR_TYPES_DOOR[sensor_type][2]
if await async_state_provider(data, door) is LockDoorStatus.UNKNOWN:
if not data.lock_has_doorsense(door.device_id):
_LOGGER.debug(
"Not adding sensor class %s for lock %s ",
SENSOR_TYPES_DOOR[sensor_type][1],
SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS],
door.device_name,
)
continue
_LOGGER.debug(
"Adding sensor class %s for %s",
SENSOR_TYPES_DOOR[sensor_type][1],
SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS],
door.device_name,
)
devices.append(AugustDoorBinarySensor(data, sensor_type, door))
@ -93,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
for sensor_type in SENSOR_TYPES_DOORBELL:
_LOGGER.debug(
"Adding doorbell sensor class %s for %s",
SENSOR_TYPES_DOORBELL[sensor_type][1],
SENSOR_TYPES_DOORBELL[sensor_type][SENSOR_DEVICE_CLASS],
doorbell.device_name,
)
devices.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell))
@ -125,22 +128,25 @@ class AugustDoorBinarySensor(BinarySensorDevice):
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return SENSOR_TYPES_DOOR[self._sensor_type][1]
return SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_DEVICE_CLASS]
@property
def name(self):
"""Return the name of the binary sensor."""
return "{} {}".format(
self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][0]
self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME]
)
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
async_state_provider = SENSOR_TYPES_DOOR[self._sensor_type][
SENSOR_STATE_PROVIDER
]
lock_door_state = await async_state_provider(self._data, self._door)
self._available = (
lock_door_state is not None and lock_door_state != LockDoorStatus.UNKNOWN
)
self._state = lock_door_state == LockDoorStatus.OPEN
door_activity = await self._data.async_get_latest_device_activity(
self._door.device_id, ActivityType.DOOR_OPERATION
@ -193,7 +199,8 @@ class AugustDoorBinarySensor(BinarySensorDevice):
def unique_id(self) -> str:
"""Get the unique of the door open binary sensor."""
return "{:s}_{:s}".format(
self._door.device_id, SENSOR_TYPES_DOOR[self._sensor_type][0].lower()
self._door.device_id,
SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME].lower(),
)
@ -221,25 +228,31 @@ class AugustDoorbellBinarySensor(BinarySensorDevice):
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return SENSOR_TYPES_DOORBELL[self._sensor_type][1]
return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_DEVICE_CLASS]
@property
def name(self):
"""Return the name of the binary sensor."""
return "{} {}".format(
self._doorbell.device_name, SENSOR_TYPES_DOORBELL[self._sensor_type][0]
self._doorbell.device_name,
SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME],
)
async def async_update(self):
"""Get the latest state of the sensor."""
async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2]
async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][
SENSOR_STATE_PROVIDER
]
self._state = await async_state_provider(self._data, self._doorbell)
self._available = self._doorbell.is_online
# 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
# to consider is available or we will not report motion or dings
self._available = self._doorbell.is_online or self._doorbell.status == "standby"
@property
def unique_id(self) -> str:
"""Get the unique id of the doorbell sensor."""
return "{:s}_{:s}".format(
self._doorbell.device_id,
SENSOR_TYPES_DOORBELL[self._sensor_type][0].lower(),
SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower(),
)

View File

@ -67,7 +67,9 @@ class AugustLock(LockDevice):
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._available = (
self._lock_status is not None and self._lock_status != LockStatus.UNKNOWN
)
self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id)
lock_activity = await self._data.async_get_latest_device_activity(

View File

@ -5,7 +5,7 @@ from unittest.mock import MagicMock, PropertyMock
from august.activity import Activity
from august.api import Api
from august.exceptions import AugustApiHTTPError
from august.lock import Lock
from august.lock import Lock, LockDetail
from homeassistant.components.august import AugustData
from homeassistant.components.august.binary_sensor import AugustDoorBinarySensor
@ -13,15 +13,7 @@ from homeassistant.components.august.lock import AugustLock
from homeassistant.util import dt
class MockAugustApi(Api):
"""A mock for py-august Api class."""
def _call_api(self, *args, **kwargs):
"""Mock the time activity started."""
raise AugustApiHTTPError("This should bubble up as its user consumable")
class MockAugustApiFailing(MockAugustApi):
class MockAugustApiFailing(Api):
"""A mock for py-august Api class that always has an AugustApiHTTPError."""
def _call_api(self, *args, **kwargs):
@ -94,7 +86,7 @@ class MockAugustComponentData(AugustData):
self,
last_lock_status_update_timestamp=1,
last_door_state_update_timestamp=1,
api=MockAugustApi(),
api=MockAugustApiFailing(),
access_token="mocked_access_token",
locks=[],
doorbells=[],
@ -158,8 +150,46 @@ def _mock_august_authentication(token_text, token_timestamp):
return authentication
def _mock_august_lock():
return Lock(
"mockdeviceid1",
{"LockName": "Mocked Lock 1", "HouseID": "mockhouseid1", "UserType": "owner"},
)
def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"):
return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid))
def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"):
return {
"_id": lockid,
"LockID": lockid,
"LockName": lockid + " Name",
"HouseID": houseid,
"UserType": "owner",
"SerialNumber": "mockserial",
"battery": 90,
"currentFirmwareVersion": "mockfirmware",
"Bridge": {
"_id": "bridgeid1",
"firmwareVersion": "mockfirm",
"operative": True,
},
"LockStatus": {"doorState": "open"},
}
def _mock_operative_august_lock_detail(lockid):
operative_lock_detail_data = _mock_august_lock_data(lockid=lockid)
return LockDetail(operative_lock_detail_data)
def _mock_inoperative_august_lock_detail(lockid):
inoperative_lock_detail_data = _mock_august_lock_data(lockid=lockid)
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)
def _mock_doorsense_missing_august_lock_detail(lockid):
doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid)
del doorsense_lock_detail_data["LockStatus"]["doorState"]
return LockDetail(doorsense_lock_detail_data)

View File

@ -2,6 +2,9 @@
import asyncio
from unittest.mock import MagicMock
from august.lock import LockDetail
from requests import RequestException
from homeassistant.components import august
from homeassistant.exceptions import HomeAssistantError
@ -11,6 +14,10 @@ from tests.components.august.mocks import (
_mock_august_authentication,
_mock_august_authenticator,
_mock_august_lock,
_mock_doorsense_enabled_august_lock_detail,
_mock_doorsense_missing_august_lock_detail,
_mock_inoperative_august_lock_detail,
_mock_operative_august_lock_detail,
)
@ -19,7 +26,7 @@ def test_get_lock_name():
data = MockAugustComponentData(last_lock_status_update_timestamp=1)
lock = _mock_august_lock()
data.set_mocked_locks([lock])
assert data.get_lock_name("mockdeviceid1") == "Mocked Lock 1"
assert data.get_lock_name("mocklockid1") == "mocklockid1 Name"
def test_unlock_throws_august_api_http_error():
@ -29,11 +36,12 @@ def test_unlock_throws_august_api_http_error():
data.set_mocked_locks([lock])
last_err = None
try:
data.unlock("mockdeviceid1")
data.unlock("mocklockid1")
except HomeAssistantError as err:
last_err = err
assert (
str(last_err) == "Mocked Lock 1: This should bubble up as its user consumable"
str(last_err)
== "mocklockid1 Name: This should bubble up as its user consumable"
)
@ -44,14 +52,51 @@ def test_lock_throws_august_api_http_error():
data.set_mocked_locks([lock])
last_err = None
try:
data.unlock("mockdeviceid1")
data.unlock("mocklockid1")
except HomeAssistantError as err:
last_err = err
assert (
str(last_err) == "Mocked Lock 1: This should bubble up as its user consumable"
str(last_err)
== "mocklockid1 Name: This should bubble up as its user consumable"
)
def test_inoperative_locks_are_filtered_out():
"""Ensure inoperative locks do not get setup."""
august_operative_lock = _mock_operative_august_lock_detail("oplockid1")
data = _create_august_data_with_lock_details(
[august_operative_lock, _mock_inoperative_august_lock_detail("inoplockid1")]
)
assert len(data.locks) == 1
assert data.locks[0].device_id == "oplockid1"
def test_lock_has_doorsense():
"""Check to see if a lock has doorsense."""
data = _create_august_data_with_lock_details(
[
_mock_doorsense_enabled_august_lock_detail("doorsenselock1"),
_mock_doorsense_missing_august_lock_detail("nodoorsenselock1"),
RequestException("mocked request error"),
RequestException("mocked request error"),
]
)
assert data.lock_has_doorsense("doorsenselock1") is True
assert data.lock_has_doorsense("nodoorsenselock1") is False
# 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)
@ -72,3 +117,21 @@ async def test__refresh_access_token(hass):
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):
locks = []
for lock in lock_details:
if isinstance(lock, LockDetail):
locks.append(_mock_august_lock(lock.device_id))
authentication = _mock_august_authentication("original_token", 1234)
authenticator = _mock_august_authenticator()
token_refresh_lock = MagicMock()
api = MagicMock()
api.get_lock_status = MagicMock(return_value=(MagicMock(), MagicMock()))
api.get_lock_detail = MagicMock(side_effect=lock_details)
api.get_operable_locks = MagicMock(return_value=locks)
api.get_doorbells = MagicMock(return_value=[])
return august.AugustData(
MagicMock(), api, authentication, authenticator, token_refresh_lock
)

View File

@ -21,10 +21,7 @@ from tests.components.august.mocks import (
def test__sync_lock_activity_locked_via_onetouchlock():
"""Test _sync_lock_activity locking."""
data = MockAugustComponentData(last_lock_status_update_timestamp=1)
august_lock = _mock_august_lock()
data.set_mocked_locks([august_lock])
lock = MockAugustComponentLock(data, august_lock)
lock = _mocked_august_component_lock()
lock_activity_start_timestamp = 1234
lock_activity = MockActivity(
action=ACTION_LOCK_ONETOUCHLOCK,
@ -40,10 +37,7 @@ def test__sync_lock_activity_locked_via_onetouchlock():
def test__sync_lock_activity_locked_via_lock():
"""Test _sync_lock_activity locking."""
data = MockAugustComponentData(last_lock_status_update_timestamp=1)
august_lock = _mock_august_lock()
data.set_mocked_locks([august_lock])
lock = MockAugustComponentLock(data, august_lock)
lock = _mocked_august_component_lock()
lock_activity_start_timestamp = 1234
lock_activity = MockActivity(
action=ACTION_LOCK_LOCK,
@ -59,10 +53,7 @@ def test__sync_lock_activity_locked_via_lock():
def test__sync_lock_activity_unlocked():
"""Test _sync_lock_activity unlocking."""
data = MockAugustComponentData(last_lock_status_update_timestamp=1)
august_lock = _mock_august_lock()
data.set_mocked_locks([august_lock])
lock = MockAugustComponentLock(data, august_lock)
lock = _mocked_august_component_lock()
lock_activity_timestamp = 1234
lock_activity = MockActivity(
action=ACTION_LOCK_UNLOCK,
@ -110,3 +101,10 @@ def test__sync_lock_activity_ignores_old_data():
assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc(
datetime.datetime.fromtimestamp(first_lock_activity_timestamp)
)
def _mocked_august_component_lock():
data = MockAugustComponentData(last_lock_status_update_timestamp=1)
august_lock = _mock_august_lock()
data.set_mocked_locks([august_lock])
return MockAugustComponentLock(data, august_lock)