Skip to content

Commit

Permalink
Add support for managing door lock pins/codes via api (#95)
Browse files Browse the repository at this point in the history
  • Loading branch information
shauntarves authored Oct 14, 2022
1 parent 233d043 commit bf9b7ed
Show file tree
Hide file tree
Showing 9 changed files with 992 additions and 178 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ wyze-sdk = {path = "."}
requests = "*"
blackboxprotobuf = "*"
mintotp = "*"
pycryptodomex = "*"

[dev-packages]
flake8 = "*"
Expand Down
348 changes: 207 additions & 141 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion wyze_sdk/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def motion_sensors(self) -> MotionSensorsClient:

@property
def locks(self) -> LocksClient:
return LocksClient(token=self._token, base_url=self._base_url)
return LocksClient(token=self._token, base_url=self._base_url, user_id=self._user_id)

@property
def scales(self) -> ScalesClient:
Expand Down
102 changes: 98 additions & 4 deletions wyze_sdk/api/devices/locks.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
from abc import ABCMeta
from datetime import datetime
from datetime import datetime, timedelta
import re
from typing import Optional, Sequence

from wyze_sdk.api.base import BaseClient
from wyze_sdk.errors import WyzeRequestError
from wyze_sdk.models.devices import DeviceModels, Lock, LockGateway
from wyze_sdk.models.devices.locks import LockRecord
from wyze_sdk.models.devices.locks import LockKey, LockKeyPeriodicity, LockKeyPermission, LockKeyPermissionType, LockKeyType, LockRecord
from wyze_sdk.service import FordServiceClient, WyzeResponse
from wyze_sdk.signature import CBCEncryptor, MD5Hasher

# The relationship between locks and gateways is a bit complicated.
# Gateways can supposedly service multiple locks, with each lock
# being paired with exactly one gateway. The gateway is accessible
# from the Wyze app, but it really only exists there to modify WiFi
# network information in the event that it changes.
#
# Keypads are paired 1:1 with locks, with the lock entity storing
# the relationship key.


class BaseLockClient(BaseClient, metaclass=ABCMeta):
Expand All @@ -28,7 +40,7 @@ def _list_lock_gateways(self, **kwargs) -> Sequence[dict]:
class LockGatewaysClient(BaseLockClient):

def list(self, **kwargs) -> Sequence[LockGateway]:
"""Lists all lock gateway available to a Wyze account.
"""Lists all lock gateways available to a Wyze account.
:rtype: Sequence[LockGateway]
"""
Expand Down Expand Up @@ -117,7 +129,18 @@ def get_records(self, *, device_mac: str, limit: int = 20, since: datetime, unti
:rtype: Sequence[LockRecord]
"""
return [LockRecord(**record) for record in super()._ford_client().get_family_record(uuid=Lock.parse_uuid(mac=device_mac), begin=since, end=until, limit=limit, offset=offset)["family_record"]]
return [LockRecord(**record) for record in super()._ford_client().get_family_records(uuid=Lock.parse_uuid(mac=device_mac), begin=since, end=until, limit=limit, offset=offset)["family_record"]]

def get_keys(self, *, device_mac: str, **kwargs) -> Sequence[LockKey]:
"""Retrieves keys for a lock.
Args:
:param str device_mac: The device mac. e.g. ``ABCDEF1234567890``
:rtype: Sequence[LockKey]
"""
uuid = Lock.parse_uuid(mac=device_mac)
return [LockKey(type=LockKeyType.ACCESS_CODE, **password) for password in super()._ford_client().get_passwords(uuid=uuid)["passwords"]]

def info(self, *, device_mac: str, **kwargs) -> Optional[Lock]:
"""Retrieves details of a lock.
Expand Down Expand Up @@ -150,6 +173,77 @@ def info(self, *, device_mac: str, **kwargs) -> Optional[Lock]:

return Lock(**lock)

def _validate_access_code(self, access_code: str):
if access_code is None or access_code.strip() == '':
raise WyzeRequestError("access code must be a numeric code between 4 and 8 digits long")
if re.match('\d{4,8}$', access_code) is None:
raise WyzeRequestError(f"{access_code} is not a valid access code")

def _encrypt_access_code(self, access_code: str) -> str:
secret = self._ford_client().get_crypt_secret()["secret"]
return CBCEncryptor(self._ford_client().WYZE_FORD_IV_HEX).encrypt(MD5Hasher().hash(secret), access_code).hex()

def create_access_code(self, device_mac: str, access_code: str, name: Optional[str], permission: Optional[LockKeyPermission] = None, periodicity: Optional[LockKeyPeriodicity] = None, **kwargs) -> WyzeResponse:
"""Creates a guest access code on a lock.
:param str device_mac: The device mac. e.g. ``ABCDEF1234567890``
:param str access_code: The new access code. e.g. ``1234``
:param str name: The name for the guest access code.
:param LockKeyPermission permission: The access permission rules for the guest access code.
:param Optional[LockKeyPeriodicity] periodicity: The recurrance rules for a recurring guest access code.
:rtype: WyzeResponse
:raises WyzeRequestError: if the new access code is not valid
"""
self._validate_access_code(access_code=access_code)
if permission.type == LockKeyPermissionType.RECURRING and periodicity is None:
raise WyzeRequestError("periodicity must be provided when setting recurring permission")
if permission.type == LockKeyPermissionType.ONCE:
if permission.begin is None:
permission.begin = datetime.now()
if permission.end is None:
permission.end = permission.begin + timedelta(days=30)
if permission is None:
permission = LockKeyPermission(type=LockKeyPermissionType.ALWAYS)

uuid = Lock.parse_uuid(mac=device_mac)
return self._ford_client().add_password(uuid=uuid, password=self._encrypt_access_code(access_code=access_code), name=name, permission=permission, periodicity=periodicity, userid=self._user_id)

def delete_access_code(self, device_mac: str, access_code_id: int, **kwargs) -> WyzeResponse:
"""Deletes an access code from a lock.
:param str device_mac: The device mac. e.g. ``ABCDEF1234567890``
:param int access_code_id: The id of the access code to delete.
:rtype: WyzeResponse
"""
uuid = Lock.parse_uuid(mac=device_mac)
return self._ford_client().delete_password(uuid=uuid, password_id=str(access_code_id))

def update_access_code(self, device_mac: str, access_code_id: int, access_code: Optional[str] = None, name: Optional[str] = None, permission: LockKeyPermission = None, periodicity: Optional[LockKeyPeriodicity] = None, **kwargs) -> WyzeResponse:
"""Updates an existing access code on a lock.
:param str device_mac: The device mac. e.g. ``ABCDEF1234567890``
:param int access_code_id: The id of the access code to reset.
:param Optional[str] access_code: The new access code. e.g. ``1234``
:param Optional[str] name: The new name for the guest access code.
:param LockKeyPermission permission: The access permission rules for the guest access code.
:param Optional[LockKeyPeriodicity] periodicity: The recurrance rules for a recurring guest access code.
:rtype: WyzeResponse
:raises WyzeRequestError: if the new access code is not valid
"""
self._validate_access_code(access_code=access_code)
if permission is None:
raise WyzeRequestError("permission must be provided")
if permission.type == LockKeyPermissionType.RECURRING and periodicity is None:
raise WyzeRequestError("periodicity must be provided when setting recurring permission")

uuid = Lock.parse_uuid(mac=device_mac)
return self._ford_client().update_password(uuid=uuid, password_id=str(access_code_id), password=self._encrypt_access_code(access_code=access_code), name=name, permission=permission, periodicity=periodicity)

@property
def gateways(self) -> LockGatewaysClient:
"""Returns a lock gateway client.
Expand Down
15 changes: 14 additions & 1 deletion wyze_sdk/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import distutils.util
import logging
from abc import ABCMeta, abstractmethod
from datetime import datetime
from datetime import datetime, time
from functools import wraps
from typing import Any, Callable, Iterable, Optional, Sequence, Set, Union

Expand All @@ -29,6 +29,19 @@ def epoch_to_datetime(epoch: Union[int, float], ms: bool = False) -> datetime:
return datetime.fromtimestamp(float(epoch) / 1000 if ms else float(epoch))


def str_to_time(string: Union[int, str]) -> Optional[time]:
"""
Convert a string representation to a python time.
"""
if isinstance(string, int):
string = str(string)

if len(string) != 6:
return

return time(hour=int(string[0:2]), minute=int(string[2:4]), second=int(string[4:6]))


def show_unknown_key_warning(name: Union[str, object], others: dict):
if "type" in others:
others.pop("type")
Expand Down
6 changes: 3 additions & 3 deletions wyze_sdk/models/devices/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,11 +501,11 @@ class LockableMixin(metaclass=ABCMeta):

@property
def is_locked(self) -> bool:
return self.lock_state
return False

@property
def lock_state(self) -> bool:
return False if self._lock_state is None else self._lock_state.value
def lock_state(self) -> DeviceProp:
return self._lock_state

@lock_state.setter
def lock_state(self, value: DeviceProp):
Expand Down
Loading

0 comments on commit bf9b7ed

Please sign in to comment.