Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the lock state metadata to get the last_changed_by value #84

Merged
merged 1 commit into from
Aug 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 53 additions & 20 deletions pyschlage/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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.
Expand Down
127 changes: 20 additions & 107 deletions tests/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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