diff --git a/pyschlage/lock.py b/pyschlage/lock.py index 9760e6d..ee1c04b 100644 --- a/pyschlage/lock.py +++ b/pyschlage/lock.py @@ -12,6 +12,31 @@ from .user import User +@dataclass +class LockStateMetadata: + """Metadata about the current lock state.""" + + action_type: str + """The action type that last changed the lock state.""" + + uuid: str | None = None + """The UUID of the actor that changed the lock state.""" + + name: str | None = None + """Human readable name of the access code that changed the lock state. + + If the lock state was not changed by an access code, this will be None. + """ + + @classmethod + def from_json(cls, json: dict) -> LockStateMetadata: + """Creates a LockStateMetadata from a JSON object. + + :meta private: + """ + return cls(action_type=json["actionType"], uuid=json["UUID"], name=json["name"]) + + @dataclass class Lock(Mutable): """A Schlage WiFi lock.""" @@ -43,6 +68,9 @@ class Lock(Mutable): is_jammed: bool | None = False """Whether the lock has identified itself as jammed or None if lock is unavailable.""" + lock_state_metadata: LockStateMetadata | None = None + """Metadata about the current lock state.""" + beeper_enabled: bool = False """Whether the keypress beep is enabled.""" @@ -89,6 +117,12 @@ def from_json(cls, auth: Auth, json: dict) -> Lock: is_locked = attributes["lockState"] == 1 is_jammed = attributes["lockState"] == 2 + lock_state_metadata = None + if "lockStateMetadata" in attributes: + lock_state_metadata = LockStateMetadata.from_json( + attributes["lockStateMetadata"] + ) + users: dict[str, User] = {} for user_json in json.get("users", []): user = User.from_json(user_json) @@ -103,6 +137,7 @@ def from_json(cls, auth: Auth, json: dict) -> Lock: battery_level=attributes.get("batteryLevel"), is_locked=is_locked, is_jammed=is_jammed, + lock_state_metadata=lock_state_metadata, beeper_enabled=attributes.get("beeperEnabled") == 1, lock_and_leave_enabled=attributes.get("lockAndLeaveEnabled") == 1, auto_lock_time=attributes.get("autoLockTime", 0), @@ -175,28 +210,26 @@ def last_changed_by( ) -> str | None: """Determines the last entity or user that changed the lock state. - :param logs: Recent log entries to search through for the last change. - If None, new logs will be fetched. - :type logs: list[LockLog] or None + :param logs: Unused. Kept for legacy reasons. :rtype: str """ - if logs is None: - logs = self.logs() - - want_prefix = "Locked by " if self.is_locked else "Unlocked by " - want_prefix_len = len(want_prefix) - for log in sorted(logs, reverse=True, key=lambda log: log.created_at): - if not log.message.startswith(want_prefix): - continue - message = log.message[want_prefix_len:] - if message == "keypad": - if code := self.access_codes.get(log.access_code_id, None): - return f"{message} - {code.name}" - elif message == "mobile device": - if user := self.users.get(log.accessor_id, None): - return f"{message} - {user.name}" - return message - return None + _ = logs # For pylint + if self.lock_state_metadata is None: + return None + + if self.lock_state_metadata.action_type == "thumbTurn": + return "thumbturn" + + if self.lock_state_metadata.action_type == "accesscode": + return f"keypad - {self.lock_state_metadata.name}" + + if self.lock_state_metadata.action_type == "virtualKey": + user = self.users.get(self.lock_state_metadata.uuid) + if user: + return f"mobile device - {user.name}" + return "mobile device" + + return "unknown" def logs(self, limit: int | None = None, sort_desc: bool = False) -> list[LockLog]: """Fetches activity logs for the lock. diff --git a/tests/test_lock.py b/tests/test_lock.py index 546fa2a..ebe1d5c 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -183,110 +183,23 @@ def test_add_access_code(self, mock_auth, lock_json, access_code_json): class TestChangedBy: - @pytest.mark.parametrize("actor", ["keypad", "thumbturn"]) - def test_lock(self, wifi_lock: Lock, actor: str) -> None: - other = { - "keypad": "thumbturn", - "thumbturn": "keypad", - }[actor] - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message=f"Unlocked by {other}", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message=f"Locked by {actor}", - ), - ] - assert wifi_lock.last_changed_by(logs) == actor - - @pytest.mark.parametrize("actor", ["keypad", "thumbturn"]) - def test_unlock(self, wifi_lock: Lock, actor: str) -> None: - other = { - "keypad": "thumbturn", - "thumbturn": "keypad", - }[actor] - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message=f"Locked by {other}", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message=f"Unlocked by {actor}", - ), - ] - wifi_lock.is_locked = False - assert wifi_lock.last_changed_by(logs) == actor - - def test_lock_keypad_code(self, wifi_lock: Lock, access_code: AccessCode) -> None: - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message="Locked by keypad", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message="Locked by keypad", - access_code_id=access_code.access_code_id, - ), - ] - assert wifi_lock.last_changed_by(logs) == "keypad - Friendly name" - - def test_lock_mobile_device(self, wifi_lock: Lock) -> None: - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message="Locked by mobile device", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message="Locked by mobile device", - accessor_id="user-uuid", - ), - ] - assert wifi_lock.last_changed_by(logs) == "mobile device - asdf" - - def test_lock_mobile_device_unknown_user(self, wifi_lock: Lock) -> None: - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message="Locked by mobile device", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message="Locked by mobile device", - accessor_id="some-other-user-uuid", - ), - ] - assert wifi_lock.last_changed_by(logs) == "mobile device" - - def test_no_useful_logs(self, wifi_lock: Lock) -> None: - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message="Lock jammed", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message="Firmware updated", - ), - ] - assert wifi_lock.last_changed_by(logs) is None - - def test_load_logs(self, wifi_lock: Lock) -> None: - logs = [ - LockLog( - created_at=datetime(2023, 1, 1, 0, 0, 0), - message="Unlocked by keypad", - ), - LockLog( - created_at=datetime(2023, 1, 1, 1, 0, 0), - message="Locked by thumbturn", - ), - ] - with mock.patch.object(wifi_lock, "logs") as mock_logs: - mock_logs.return_value = logs - assert wifi_lock.last_changed_by() == "thumbturn" - mock_logs.assert_called_once_with() + def test_thumbturn(self, wifi_lock: Lock) -> None: + wifi_lock.lock_state_metadata.action_type = "thumbTurn" + assert wifi_lock.last_changed_by() == "thumbturn" + + def test_keypad(self, wifi_lock: Lock) -> None: + wifi_lock.lock_state_metadata.action_type = "accesscode" + wifi_lock.lock_state_metadata.name = "secret code" + assert wifi_lock.last_changed_by() == "keypad - secret code" + + def test_mobile_device(self, wifi_lock: Lock) -> None: + wifi_lock.lock_state_metadata.action_type = "virtualKey" + wifi_lock.lock_state_metadata.uuid = "user-uuid" + assert wifi_lock.last_changed_by() == "mobile device - asdf" + + def test_unknown(self, wifi_lock: Lock) -> None: + assert wifi_lock.last_changed_by() == "unknown" + + def test_no_metadata(self, wifi_lock: Lock) -> None: + wifi_lock.lock_state_metadata = None + assert wifi_lock.last_changed_by() is None