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
parent
239dfaba4b
commit
17f3332c89
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue