Skip to content

Commit

Permalink
Merge pull request #84 from dknowles2/changedv2
Browse files Browse the repository at this point in the history
Use the lock state metadata to get the last_changed_by value
  • Loading branch information
dknowles2 committed Aug 13, 2023
2 parents 256f563 + f28d0d4 commit 709f20a
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 127 deletions.
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

0 comments on commit 709f20a

Please sign in to comment.