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)