From 78078dcd5d4061f128c02757014d763f5f6a5cb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Feb 2020 22:17:24 +0000 Subject: [PATCH] Add lock_return_activity and unlock_return_activity apis It is now possible to call the lock and unlock remote operation and get back a LockOperationActivity that can be consumed by update_lock_detail_from_activity. If the lock supports doorsense, a DoorOperationActivity is also returned since the underlying August API returns this. Lock operations now avoid the need to fetch lock details afterward which further reduces the number of API calls we make to the August API --- august/api.py | 122 ++++++++++++++---- august/lock.py | 13 +- august/util.py | 2 - setup.py | 2 +- tests/fixtures/lock.json | 26 ++++ tests/fixtures/lock_without_doorstate.json | 25 ++++ tests/fixtures/unlock.json | 26 ++++ tests/fixtures/unlock_without_doorstate.json | 25 ++++ tests/test_api.py | 128 +++++++++++++++++++ tests/{test_lock.py => test_util.py} | 16 ++- 10 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/lock.json create mode 100644 tests/fixtures/lock_without_doorstate.json create mode 100644 tests/fixtures/unlock.json create mode 100644 tests/fixtures/unlock_without_doorstate.json rename tests/{test_lock.py => test_util.py} (84%) diff --git a/august/api.py b/august/api.py index 829deb5..19071bf 100644 --- a/august/api.py +++ b/august/api.py @@ -2,6 +2,7 @@ import logging import time +import dateutil.parser from requests import Session, request from requests.exceptions import HTTPError @@ -22,8 +23,10 @@ from august.lock import ( Lock, LockDetail, - determine_lock_status, + LockDoorStatus, determine_door_state, + determine_lock_status, + door_state_to_string, ) from august.pin import Pin @@ -168,18 +171,9 @@ def get_house_activities(self, access_token, house_id, limit=8): activities = [] for activity_json in response.json(): - action = activity_json.get("action") - - if action in ACTIVITY_ACTIONS_DOORBELL_DING: - activities.append(DoorbellDingActivity(activity_json)) - elif action in ACTIVITY_ACTIONS_DOORBELL_MOTION: - activities.append(DoorbellMotionActivity(activity_json)) - elif action in ACTIVITY_ACTIONS_DOORBELL_VIEW: - activities.append(DoorbellViewActivity(activity_json)) - elif action in ACTIVITY_ACTIONS_LOCK_OPERATION: - activities.append(LockOperationActivity(activity_json)) - elif action in ACTIVITY_ACTIONS_DOOR_OPERATION: - activities.append(DoorOperationActivity(activity_json)) + activity = _activity_from_dict(activity_json) + if activity: + activities.append(activity) return activities @@ -239,26 +233,58 @@ def get_pins(self, access_token, lock_id): return [Pin(pin_json) for pin_json in json_dict.get("loaded", [])] - def lock(self, access_token, lock_id): - json_dict = self._call_api( + def _call_lock_operation(self, url_str, access_token, lock_id): + return self._call_api( "put", - API_LOCK_URL.format(lock_id=lock_id), + url_str.format(lock_id=lock_id), access_token=access_token, timeout=self._command_timeout, ).json() + def _lock(self, access_token, lock_id): + return self._call_lock_operation(API_LOCK_URL, access_token, lock_id) + + def lock(self, access_token, lock_id): + """Execute a remote lock operation. + + Returns a LockStatus state. + """ + json_dict = self._lock(access_token, lock_id) return determine_lock_status(json_dict.get("status")) + def lock_return_activities(self, access_token, lock_id): + """Execute a remote lock operation. + + Returns an array of one or more august.activity.Activity objects + + If the lock supports door sense one of the activities + will include the current door state. + """ + json_dict = self._lock(access_token, lock_id) + return _convert_lock_result_to_activities(json_dict) + + def _unlock(self, access_token, lock_id): + return self._call_lock_operation(API_UNLOCK_URL, access_token, lock_id) + def unlock(self, access_token, lock_id): - json_dict = self._call_api( - "put", - API_UNLOCK_URL.format(lock_id=lock_id), - access_token=access_token, - timeout=self._command_timeout, - ).json() + """Execute a remote unlock operation. + Returns a LockStatus state. + """ + json_dict = self._unlock(access_token, lock_id) return determine_lock_status(json_dict.get("status")) + def unlock_return_activities(self, access_token, lock_id): + """Execute a remote lock operation. + + Returns an array of one or more august.activity.Activity objects + + If the lock supports door sense one of the activities + will include the current door state. + """ + json_dict = self._unlock(access_token, lock_id) + return _convert_lock_result_to_activities(json_dict) + def refresh_access_token(self, access_token): response = self._call_api("get", API_GET_HOUSES_URL, access_token=access_token) @@ -335,3 +361,55 @@ def _raise_response_exceptions(response): response=err.response, ) from err raise err + + +def _convert_lock_result_to_activities(lock_json_dict): + activities = [] + lock_info_json_dict = lock_json_dict.get("info", {}) + lock_id = lock_info_json_dict.get("lockID") + lock_action_text = lock_info_json_dict.get("action") + activity_epoch = _datetime_string_to_epoch(lock_info_json_dict.get("startTime")) + activity_lock_dict = _map_lock_result_to_activity( + lock_id, activity_epoch, lock_action_text + ) + activities.append(activity_lock_dict) + + door_state = determine_door_state(lock_json_dict.get("doorState")) + if door_state != LockDoorStatus.UNKNOWN: + activity_door_dict = _map_lock_result_to_activity( + lock_id, activity_epoch, door_state_to_string(door_state) + ) + activities.append(activity_door_dict) + + return activities + + +def _activity_from_dict(activity_dict): + action = activity_dict.get("action") + + if action in ACTIVITY_ACTIONS_DOORBELL_DING: + return DoorbellDingActivity(activity_dict) + if action in ACTIVITY_ACTIONS_DOORBELL_MOTION: + return DoorbellMotionActivity(activity_dict) + if action in ACTIVITY_ACTIONS_DOORBELL_VIEW: + return DoorbellViewActivity(activity_dict) + if action in ACTIVITY_ACTIONS_LOCK_OPERATION: + return LockOperationActivity(activity_dict) + if action in ACTIVITY_ACTIONS_DOOR_OPERATION: + return DoorOperationActivity(activity_dict) + return None + + +def _map_lock_result_to_activity(lock_id, activity_epoch, action_text): + """Create an august activity from a lock result.""" + mapped_dict = { + "dateTime": activity_epoch, + "deviceID": lock_id, + "deviceType": "lock", + "action": action_text, + } + return _activity_from_dict(mapped_dict) + + +def _datetime_string_to_epoch(datetime_string): + return dateutil.parser.parse(datetime_string).timestamp() * 1000 diff --git a/august/lock.py b/august/lock.py index a5098d0..c82aa49 100644 --- a/august/lock.py +++ b/august/lock.py @@ -9,8 +9,8 @@ LOCKED_STATUS = ("locked", "kAugLockState_Locked") UNLOCKED_STATUS = ("unlocked", "kAugLockState_Unlocked") -CLOSED_STATUS = ("closed", "kAugLockDoorState_Closed") -OPEN_STATUS = ("open", "kAugLockDoorState_Open") +CLOSED_STATUS = ("closed", "kAugLockDoorState_Closed", "kAugDoorState_Closed") +OPEN_STATUS = ("open", "kAugLockDoorState_Open", "kAugDoorState_Open") class Lock(Device): @@ -160,3 +160,12 @@ def determine_door_state(status): if status in OPEN_STATUS: return LockDoorStatus.OPEN return LockDoorStatus.UNKNOWN + + +def door_state_to_string(door_status): + """Returns the normalized value that determine_door_state represents.""" + if door_status == LockDoorStatus.OPEN: + return "dooropen" + if door_status == LockDoorStatus.CLOSED: + return "doorclosed" + raise ValueError diff --git a/august/util.py b/august/util.py index 4a32ab4..bd92e16 100644 --- a/august/util.py +++ b/august/util.py @@ -10,8 +10,6 @@ def update_lock_detail_from_activity(lock_detail, activity): """Update the LockDetail from an activity.""" activity_end_time_utc = as_utc_from_local(activity.activity_end_time) - if activity.house_id != lock_detail.house_id: - raise ValueError if activity.device_id != lock_detail.device_id: raise ValueError if isinstance(activity, LockOperationActivity): diff --git a/setup.py b/setup.py index d459d8b..b3691c5 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='py-august', - version='0.16.0', + version='0.17.0', packages=['august'], url='https://github.com/snjoetw/py-august', license='MIT', diff --git a/tests/fixtures/lock.json b/tests/fixtures/lock.json new file mode 100644 index 0000000..a366f7e --- /dev/null +++ b/tests/fixtures/lock.json @@ -0,0 +1,26 @@ +{ + "resultsFromOperationCache" : false, + "retryCount" : 1, + "info" : { + "lockType" : "lock_version_3", + "lockID" : "ABC123", + "lockStatusChanged" : true, + "rssi" : -87, + "wlanRSSI" : -42, + "context" : { + "startDate" : "2020-02-19T19:44:54.370Z", + "transactionID" : "transid", + "retryCount" : 1 + }, + "serialNumber" : "serial", + "action" : "lock", + "wlanSNR" : 56, + "duration" : 3119, + "startTime" : "2020-02-19T19:44:54.371Z", + "serial" : "serial", + "bridgeID" : "brdigeid" + }, + "doorState" : "kAugDoorState_Closed", + "status" : "kAugLockState_Locked", + "totalTime" : 3133 +} diff --git a/tests/fixtures/lock_without_doorstate.json b/tests/fixtures/lock_without_doorstate.json new file mode 100644 index 0000000..70f64cf --- /dev/null +++ b/tests/fixtures/lock_without_doorstate.json @@ -0,0 +1,25 @@ +{ + "resultsFromOperationCache" : false, + "retryCount" : 1, + "info" : { + "lockType" : "lock_version_3", + "lockID" : "ABC123", + "lockStatusChanged" : true, + "rssi" : -87, + "wlanRSSI" : -42, + "context" : { + "startDate" : "2020-02-19T19:44:54.370Z", + "transactionID" : "transid", + "retryCount" : 1 + }, + "serialNumber" : "serial", + "action" : "lock", + "wlanSNR" : 56, + "duration" : 3119, + "startTime" : "2020-02-19T19:44:54.371Z", + "serial" : "serial", + "bridgeID" : "brdigeid" + }, + "status" : "kAugLockState_Locked", + "totalTime" : 3133 +} diff --git a/tests/fixtures/unlock.json b/tests/fixtures/unlock.json new file mode 100644 index 0000000..e56cb8b --- /dev/null +++ b/tests/fixtures/unlock.json @@ -0,0 +1,26 @@ +{ + "resultsFromOperationCache" : false, + "info" : { + "bridgeID" : "bridgeid", + "duration" : 3773, + "lockStatusChanged" : true, + "serial" : "serial", + "startTime" : "2020-02-19T19:44:26.745Z", + "lockID" : "ABC", + "context" : { + "transactionID" : "transid", + "retryCount" : 1, + "startDate" : "2020-02-19T19:44:26.744Z" + }, + "lockType" : "lock_version_3", + "serialNumber" : "serialnum", + "wlanRSSI" : -41, + "action" : "unlock", + "rssi" : -88, + "wlanSNR" : 58 + }, + "status" : "kAugLockState_Unlocked", + "totalTime" : 3784, + "retryCount" : 1, + "doorState" : "kAugDoorState_Closed" +} diff --git a/tests/fixtures/unlock_without_doorstate.json b/tests/fixtures/unlock_without_doorstate.json new file mode 100644 index 0000000..01910c8 --- /dev/null +++ b/tests/fixtures/unlock_without_doorstate.json @@ -0,0 +1,25 @@ +{ + "resultsFromOperationCache" : false, + "info" : { + "bridgeID" : "bridgeid", + "duration" : 3773, + "lockStatusChanged" : true, + "serial" : "serial", + "startTime" : "2020-02-19T19:44:26.745Z", + "lockID" : "ABC123", + "context" : { + "transactionID" : "transid", + "retryCount" : 1, + "startDate" : "2020-02-19T19:44:26.744Z" + }, + "lockType" : "lock_version_3", + "serialNumber" : "serialnum", + "wlanRSSI" : -41, + "action" : "unlock", + "rssi" : -88, + "wlanSNR" : 58 + }, + "status" : "kAugLockState_Unlocked", + "totalTime" : 3784, + "retryCount" : 1 +} diff --git a/tests/test_api.py b/tests/test_api.py index fc1d001..19681da 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -385,6 +385,134 @@ def test_get_lock_door_status_with_unknown_response(self, mock): self.assertEqual(LockDoorStatus.UNKNOWN, door_status) + @requests_mock.Mocker() + def test_lock_from_fixture(self, mock): + lock_id = 1234 + mock.register_uri( + "put", API_LOCK_URL.format(lock_id=lock_id), text=load_fixture("lock.json") + ) + + api = Api() + status = api.lock(ACCESS_TOKEN, lock_id) + + self.assertEqual(LockStatus.LOCKED, status) + + @requests_mock.Mocker() + def test_unlock_from_fixture(self, mock): + lock_id = 1234 + mock.register_uri( + "put", + API_UNLOCK_URL.format(lock_id=lock_id), + text=load_fixture("unlock.json"), + ) + + api = Api() + status = api.unlock(ACCESS_TOKEN, lock_id) + + self.assertEqual(LockStatus.UNLOCKED, status) + + @requests_mock.Mocker() + def test_lock_return_activities_from_fixture(self, mock): + lock_id = 1234 + mock.register_uri( + "put", API_LOCK_URL.format(lock_id=lock_id), text=load_fixture("lock.json") + ) + + api = Api() + activities = api.lock_return_activities(ACCESS_TOKEN, lock_id) + expected_lock_dt = dateutil.parser.parse("2020-02-19T19:44:54.371Z").replace( + tzinfo=None + ) + + self.assertEqual(len(activities), 2) + self.assertIsInstance(activities[0], august.activity.LockOperationActivity) + self.assertEqual(activities[0].device_id, "ABC123") + self.assertEqual(activities[0].device_type, "lock") + self.assertEqual(activities[0].action, "lock") + self.assertEqual(activities[0].activity_start_time, expected_lock_dt) + self.assertEqual(activities[0].activity_end_time, expected_lock_dt) + self.assertIsInstance(activities[1], august.activity.DoorOperationActivity) + self.assertEqual(activities[1].device_id, "ABC123") + self.assertEqual(activities[1].device_type, "lock") + self.assertEqual(activities[1].action, "doorclosed") + self.assertEqual(activities[0].activity_start_time, expected_lock_dt) + self.assertEqual(activities[0].activity_end_time, expected_lock_dt) + + @requests_mock.Mocker() + def test_unlock_return_activities_from_fixture(self, mock): + lock_id = 1234 + mock.register_uri( + "put", + API_UNLOCK_URL.format(lock_id=lock_id), + text=load_fixture("unlock.json"), + ) + + api = Api() + activities = api.unlock_return_activities(ACCESS_TOKEN, lock_id) + expected_unlock_dt = dateutil.parser.parse("2020-02-19T19:44:26.745Z").replace( + tzinfo=None + ) + + self.assertEqual(len(activities), 2) + self.assertIsInstance(activities[0], august.activity.LockOperationActivity) + self.assertEqual(activities[0].device_id, "ABC") + self.assertEqual(activities[0].device_type, "lock") + self.assertEqual(activities[0].action, "unlock") + self.assertEqual(activities[0].activity_start_time, expected_unlock_dt) + self.assertEqual(activities[0].activity_end_time, expected_unlock_dt) + self.assertIsInstance(activities[1], august.activity.DoorOperationActivity) + self.assertEqual(activities[1].device_id, "ABC") + self.assertEqual(activities[1].device_type, "lock") + self.assertEqual(activities[1].action, "doorclosed") + self.assertEqual(activities[1].activity_start_time, expected_unlock_dt) + self.assertEqual(activities[1].activity_end_time, expected_unlock_dt) + + @requests_mock.Mocker() + def test_lock_return_activities_from_fixture_with_no_doorstate(self, mock): + lock_id = 1234 + mock.register_uri( + "put", + API_LOCK_URL.format(lock_id=lock_id), + text=load_fixture("lock_without_doorstate.json"), + ) + + api = Api() + activities = api.lock_return_activities(ACCESS_TOKEN, lock_id) + expected_lock_dt = dateutil.parser.parse("2020-02-19T19:44:54.371Z").replace( + tzinfo=None + ) + + self.assertEqual(len(activities), 1) + self.assertIsInstance(activities[0], august.activity.LockOperationActivity) + self.assertEqual(activities[0].device_id, "ABC123") + self.assertEqual(activities[0].device_type, "lock") + self.assertEqual(activities[0].action, "lock") + self.assertEqual(activities[0].activity_start_time, expected_lock_dt) + self.assertEqual(activities[0].activity_end_time, expected_lock_dt) + + @requests_mock.Mocker() + def test_unlock_return_activities_from_fixture_with_no_doorstate(self, mock): + lock_id = 1234 + mock.register_uri( + "put", + API_UNLOCK_URL.format(lock_id=lock_id), + text=load_fixture("unlock_without_doorstate.json"), + ) + + api = Api() + activities = api.unlock_return_activities(ACCESS_TOKEN, lock_id) + expected_unlock_dt = dateutil.parser.parse("2020-02-19T19:44:26.745Z").replace( + tzinfo=None + ) + + self.assertEqual(len(activities), 1) + self.assertIsInstance(activities[0], august.activity.LockOperationActivity) + self.assertEqual(activities[0].device_id, "ABC123") + self.assertEqual(activities[0].device_type, "lock") + self.assertEqual(activities[0].action, "unlock") + self.assertEqual(activities[0].activity_start_time, expected_unlock_dt) + self.assertEqual(activities[0].activity_end_time, expected_unlock_dt) + @requests_mock.Mocker() def test_lock(self, mock): lock_id = 1234 diff --git a/tests/test_lock.py b/tests/test_util.py similarity index 84% rename from tests/test_lock.py rename to tests/test_util.py index ef150ea..00ff8de 100644 --- a/tests/test_lock.py +++ b/tests/test_util.py @@ -8,6 +8,7 @@ from august.activity import DoorOperationActivity, LockOperationActivity from august.lock import LockDetail, LockDoorStatus, LockStatus from august.util import update_lock_detail_from_activity, as_utc_from_local +from august.api import _convert_lock_result_to_activities def load_fixture(filename): @@ -98,7 +99,20 @@ def test_update_lock_with_activity(self): lock, closed_operation_wrong_deviceid_activity ) - with self.assertRaises(ValueError): + # We do not always have the houseid so we do not throw + # as long as the deviceid is correct since they are unique + self.assertFalse( update_lock_detail_from_activity( lock, closed_operation_wrong_houseid_activity ) + ) + + self.assertEqual(LockDoorStatus.OPEN, lock.door_state) + self.assertEqual(LockStatus.LOCKED, lock.lock_status) + activities = _convert_lock_result_to_activities( + json.loads(load_fixture("unlock.json")) + ) + for activity in activities: + self.assertTrue(update_lock_detail_from_activity(lock, activity)) + self.assertEqual(LockDoorStatus.CLOSED, lock.door_state) + self.assertEqual(LockStatus.UNLOCKED, lock.lock_status)