pax_global_header00006660000000000000000000000064145050062460014514gustar00rootroot0000000000000052 comment=efb0cf9cde218d4c8374fd3ec387ab6bc5d0b6cf emontnemery-py-dormakaba-dkey-efb0cf9/000077500000000000000000000000001450500624600201355ustar00rootroot00000000000000emontnemery-py-dormakaba-dkey-efb0cf9/.github/000077500000000000000000000000001450500624600214755ustar00rootroot00000000000000emontnemery-py-dormakaba-dkey-efb0cf9/.github/dependabot.yml000066400000000000000000000004101450500624600243200ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: pip directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 emontnemery-py-dormakaba-dkey-efb0cf9/.github/release-drafter.yml000066400000000000000000000002221450500624600252610ustar00rootroot00000000000000categories: - title: "⬆️ Dependencies" collapse-after: 1 labels: - "dependencies" template: | ## What's Changed $CHANGES emontnemery-py-dormakaba-dkey-efb0cf9/.github/workflows/000077500000000000000000000000001450500624600235325ustar00rootroot00000000000000emontnemery-py-dormakaba-dkey-efb0cf9/.github/workflows/pythonpublish.yml000066400000000000000000000015031450500624600271640ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: - published jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.0 - name: Set up Python uses: actions/setup-python@v4.7.0 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* emontnemery-py-dormakaba-dkey-efb0cf9/.github/workflows/release-drafter.yml000066400000000000000000000011261450500624600273220ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main permissions: contents: read jobs: update_release_draft: permissions: # write permission is required to create a github release contents: write # write permission is required for autolabeler # otherwise, read permission is required at least pull-requests: read runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v5.24.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} emontnemery-py-dormakaba-dkey-efb0cf9/.github/workflows/test.yml000066400000000000000000000021721450500624600252360ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Run Tests on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.0 - name: Set up Python 3.10 uses: actions/setup-python@v4.7.0 with: python-version: '3.10' - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - name: Check formatting with black run: | black examples py_dormakaba_dkey --check --diff - name: Lint with flake8 run: | flake8 examples py_dormakaba_dkey - name: Lint with isort run: | isort examples py_dormakaba_dkey - name: Lint with mypy run: | mypy examples py_dormakaba_dkey - name: Lint with pylint run: | pylint examples py_dormakaba_dkey emontnemery-py-dormakaba-dkey-efb0cf9/.gitignore000066400000000000000000000002151450500624600221230ustar00rootroot00000000000000.DS_Store .idea *.log tmp/ *.py[cod] *.egg htmlcov .projectile .venv/ venv/ .mypy_cache/ *.egg-info/ # Visual Studio Code .vscode/* dist emontnemery-py-dormakaba-dkey-efb0cf9/LICENSE000066400000000000000000000020501450500624600211370ustar00rootroot00000000000000MIT License Copyright (c) 2023 ESPHome Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. emontnemery-py-dormakaba-dkey-efb0cf9/README.md000066400000000000000000000001351450500624600214130ustar00rootroot00000000000000# Python Dormakaba dKey Python package to interact with a Dormakaba dKey lock via bluetooth emontnemery-py-dormakaba-dkey-efb0cf9/examples/000077500000000000000000000000001450500624600217535ustar00rootroot00000000000000emontnemery-py-dormakaba-dkey-efb0cf9/examples/associate.py000066400000000000000000000025261450500624600243050ustar00rootroot00000000000000"""Example which associates with a lock.""" from __future__ import annotations import asyncio import logging import sys from bleak import AdvertisementData, BleakScanner, BLEDevice from bleak.exc import BleakError from py_dormakaba_dkey import DKEYLock ADDRESS = "F0:94:0A:BD:3D:0A" _LOGGER = logging.getLogger(__name__) async def main(address: str) -> None: """Associate with a lock.""" found_lock_evt = asyncio.Event() lock_device = None def callback(device: BLEDevice, advertising_data: AdvertisementData): nonlocal lock_device if device.address == address: lock_device = device found_lock_evt.set() async with BleakScanner( detection_callback=callback, ): await found_lock_evt.wait() if not lock_device: raise BleakError(f"A device with address {address} could not be found.") lock = DKEYLock(lock_device) activation_code = "m8ll-41s4" associationdata = await lock.associate(activation_code) _LOGGER.info( "Association data: %s", associationdata.to_json() if associationdata else "", ) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) logging.getLogger("bleak.backends.bluezdbus.manager").setLevel(logging.WARNING) asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else ADDRESS)) emontnemery-py-dormakaba-dkey-efb0cf9/examples/unlock.py000066400000000000000000000031521450500624600236210ustar00rootroot00000000000000"""Example which unlocks a lock.""" from __future__ import annotations import asyncio import logging import sys from bleak import AdvertisementData, BleakScanner, BLEDevice from bleak.exc import BleakError from py_dormakaba_dkey import DKEYLock, Notifications from py_dormakaba_dkey.models import AssociationData _LOGGER = logger = logging.getLogger(__name__) ADDRESS = "F0:94:0A:BD:3D:0A" async def main(address: str) -> None: """Unlock a lock.""" found_lock_evt = asyncio.Event() lock_device = None def callback(device: BLEDevice, advertising_data: AdvertisementData): nonlocal lock_device if device.address == address: lock_device = device found_lock_evt.set() async with BleakScanner( detection_callback=callback, ): await found_lock_evt.wait() if not lock_device: raise BleakError(f"A device with address {address} could not be found.") lock = DKEYLock(lock_device) key_holder_id = bytes.fromhex("2cf9002a") # b"\xc1\x0c\x00)" secret = bytes.fromhex( "7e23fb0fe19095d996944b0428a0b2f55405e5c1a6676740d0afa8462801c4cb" ) lock.set_association_data(AssociationData(key_holder_id, secret)) def on_notifiction(notification: Notifications) -> None: _LOGGER.info("on_notifiction: %s", notification) lock.register_callback(on_notifiction) await lock.unlock() if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) logging.getLogger("bleak.backends.bluezdbus.manager").setLevel(logging.WARNING) asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else ADDRESS)) emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/000077500000000000000000000000001450500624600236025ustar00rootroot00000000000000emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/__init__.py000066400000000000000000000003741450500624600257170ustar00rootroot00000000000000"""Dormakaba DKEY Manager""" from __future__ import annotations from . import errors from .commands import Notifications from .dkey import DKEYLock, device_filter __all__ = [ "DKEYLock", "Notifications", "device_filter", "errors", ] emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/commands.py000066400000000000000000001003241450500624600257550ustar00rootroot00000000000000"""Models for commands which can be sent to and received from a lock.""" from __future__ import annotations from abc import ABC, abstractmethod from enum import IntEnum import struct from typing import Any, TypeVar from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from .errors import InvalidCommand from .models import ErrorCode class Command(ABC): """Base class for commands.""" cmd_id: int _format: str _len: int @property @abstractmethod def as_bytes(self) -> bytes: """Return serialized representation of the command.""" @classmethod @abstractmethod def from_bytes(cls, data: bytes) -> Command: """Initialize from serialized representation of the command.""" @property def _header(self) -> bytes: """Return packed header.""" return struct.pack("!BB", self.cmd_id, self._len) def _pack(self, *args: Any) -> bytes: """Pack the command to bytes.""" return self._header + struct.pack(self._format, *args) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" if len(data) != 2 + cls._len: raise InvalidCommand("Invalid length", data.hex()) if data[0] != cls.cmd_id or data[1] != cls._len: raise InvalidCommand("Invalid header", data.hex()) _CMD_T = TypeVar("_CMD_T", bound=Command) class AuthChallengeBase(Command): """Authentication Base Command.""" _format = "!16s" _len = struct.calcsize(_format) _nonce_label: str def __init__(self, nonce: bytes) -> None: """Initialize.""" self.nonce = nonce def __str__(self) -> str: return f"{self.__class__.__name__} {self._nonce_label}: {self.nonce.hex()}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.nonce) @classmethod def from_bytes(cls, data: bytes) -> AuthChallengeBase: """Initialize from serialized representation of the command.""" cls._validate(data) (challenge,) = struct.unpack_from(cls._format, data, 2) return cls(challenge) class ECDHPublicReplyCmdBase(Command): """ECDH Public Base Command.""" def __init__(self, public_key: ec.EllipticCurvePublicKey) -> None: """Initialize.""" self.public_key = public_key public_key_bytes = public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) self._format = f"!{len(public_key_bytes[1:])}s" self._len = struct.calcsize(self._format) def __str__(self) -> str: public_bytes = self.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) return f"{self.__class__.__name__} peer public key: {public_bytes[1:].hex()}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" public_bytes = self.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) return self._pack(public_bytes[1:]) @classmethod def _calc_format(cls, data: bytes) -> str: return f"!{len(data)-2}s" @classmethod def _calc_len(cls, data: bytes) -> int: return struct.calcsize(cls._calc_format(data)) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" if len(data) < 2: raise InvalidCommand("Invalid length", data.hex()) if len(data) != 2 + cls._calc_len(data): raise InvalidCommand("Invalid length", data.hex()) if data[0] != cls.cmd_id or data[1] != cls._calc_len(data): raise InvalidCommand("Invalid header", data.hex()) @classmethod def from_bytes(cls, data: bytes) -> ECDHPublicReplyCmdBase: """Initialize from serialized representation of the command.""" cls._validate(data) fmt = cls._calc_format(data) (public_key_bytes,) = struct.unpack_from(fmt, data, 2) public_key = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), b"\x04" + public_key_bytes ) return cls(public_key) class HandshakeCheckBase(Command): """Base for handshake check.""" _format = "!14s" _len = struct.calcsize(_format) _expected_msg: str = "handshakecheck" def __init__(self, msg: str) -> None: """Initialize.""" self.msg = msg def __str__(self) -> str: return f"{self.__class__.__name__} msg: {self.msg}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.msg) @classmethod def from_bytes(cls, data: bytes) -> HandshakeCheckBase: """Initialize from serialized representation of the command.""" cls._validate(data) msg_bytes: bytes (msg_bytes,) = struct.unpack_from(cls._format, data, 2) msg = msg_bytes.decode("UTF-8") return cls(msg) class UnknownCommand(Command): """Unknown command.""" def __init__(self, data: bytes): """Initialize.""" self.data = data self.cmd_id = data[0] def __str__(self) -> str: return f"{self.__class__.__name__} data: {self.data.hex()}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self.data @classmethod def from_bytes(cls, data: bytes) -> UnknownCommand: """Initialize from serialized representation of the command.""" return cls(data) class GetIdentificationCmd(Command): """Get Identification Command.""" cmd_id = 0x00 _format = "!4s" _len = struct.calcsize(_format) def __init__(self, ident: bytes): """Initialize.""" self.ident = ident def __str__(self) -> str: return f"{self.__class__.__name__} ident: {self.ident.hex()}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.ident) @classmethod def from_bytes(cls, data: bytes) -> GetIdentificationCmd: """Initialize from serialized representation of the command.""" cls._validate(data) (ident,) = struct.unpack_from(cls._format, data, 2) return cls(ident) class AuthChallengeCmd(AuthChallengeBase): """Authentication Challenge Command.""" cmd_id = 0x01 _nonce_label = "challenge" class AuthChallengeAcceptedCmd(HandshakeCheckBase): """Authentication Challenge Accepted Command.""" cmd_id = 0x02 class UnlockType(IntEnum): """Unlock type.""" DIRECT_UNLOCK = 0 AUTO_UNLOCK = 1 DIRECT_UNLOCK_LOCK = 2 AUTO_UNLOCK_LOCK = 3 HANDSFREE_UNLOCK = 4 HANDSFREE_UNLOCK_LOCK = 5 class InOutStatus(IntEnum): """In/out status.""" OUT = 0 IN = 1 UNKNOWN = -1 class UnlockCmd(Command): """Unlock Command.""" cmd_id = 0x03 _format = "!BBbBBBB" _len = struct.calcsize(_format) def __init__( self, unlock_type: UnlockType, in_out_settings: int, in_out_status: InOutStatus ) -> None: """Initialize.""" self.unlock_type = unlock_type self.in_out_settings = in_out_settings self.in_out_status = in_out_status self._unknown_1 = 0x8D self._unknown_2 = 0x80 self._unknown_3 = 0x8D self._unknown_4 = 0x89 @classmethod def defaults(cls) -> UnlockCmd: """Return an instance with default settings.""" return cls(UnlockType.AUTO_UNLOCK, 0, InOutStatus.IN) def __str__(self) -> str: return ( f"{self.__class__.__name__} unlock_type: {self.unlock_type.name}, " f"in_out_status: {self.in_out_status.name}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack( self.unlock_type, self.in_out_settings, self.in_out_status, self._unknown_1, self._unknown_2, self._unknown_3, self._unknown_4, ) @classmethod def from_bytes(cls, data: bytes) -> UnlockCmd: """Initialize from serialized representation of the command.""" raise NotImplementedError class DisconnectReason(IntEnum): """Disconnect reason.""" REMOTE_DISCONNECT = 0 KICKED_OUT = 1 TIMEOUT = 2 OOR = 3 ASSOCIATION = 4 KEY_DISABLED = 5 DEFAULT = -1 class DisconnectReqCmd(Command): """Disconnect request command.""" cmd_id = 0x04 _format = "!b" _len = struct.calcsize(_format) def __init__(self, disconnect_reason: DisconnectReason) -> None: """Initialize.""" self.disconnect_reason = disconnect_reason def __str__(self) -> str: return f"{self.__class__.__name__} reason: {self.disconnect_reason.name}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.disconnect_reason) @classmethod def from_bytes(cls, data: bytes) -> DisconnectReqCmd: """Initialize from serialized representation of the command.""" cls._validate(data) (disconnect_reason,) = struct.unpack_from(cls._format, data, 2) return cls(DisconnectReason(disconnect_reason)) class AssociationParametersCmd(Command): """Association Parameters Command.""" cmd_id = 0x09 _format = "!4s16s" _len = struct.calcsize(_format) def __init__(self, key_holder_id: bytes, secret: bytes) -> None: """Initialize.""" self.key_holder_id = key_holder_id self.secret = secret def __str__(self) -> str: return ( f"{self.__class__.__name__} key_holder_id: {self.key_holder_id.hex()}, " f"secret: {self.secret.hex()}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack( self.key_holder_id, self.secret, ) @classmethod def from_bytes(cls, data: bytes) -> AssociationParametersCmd: """Initialize from serialized representation of the command.""" cls._validate(data) (key_holder_id, secret) = struct.unpack_from(cls._format, data, 2) return cls(key_holder_id, secret) class NotAssociatedCmd(Command): """Not Associated Command.""" cmd_id = 0x13 _format = "" _len = struct.calcsize(_format) def __str__(self) -> str: return f"{self.__class__.__name__}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack() @classmethod def from_bytes(cls, data: bytes) -> NotAssociatedCmd: """Initialize from serialized representation of the command.""" cls._validate(data) return cls() class DoorType(IntEnum): """Door type.""" UNKNOWN_DOOR = 0 FRONT_DOOR = 1 BACK_DOOR = 2 GARAGE_DOOR = 3 OTHER_DOOR = 4 NO_DOOR = -2 DEFAULT = -1 class DetectorSide(IntEnum): """Detector side.""" DETECTOR_INSIDE = 1 DETECTOR_OUTSIDE = 3 class KeyFeatures(IntEnum): """Key features.""" HANDSFREE = 1 KEY_DISABLED = 2 CODE_REQUIRED = 4 ONE_TIME = 8 LOCK_HASH_REQUIRED = 16 SHELL_PROTECTION = 32 AUTO_LOCK = 64 AWAY_MODE = 128 class DetTypeNameCmd(Command): """Detector Type and Name Command.""" cmd_id = 0x15 def __init__( self, device_id: bytes, door_type: DoorType, detector_side: DetectorSide, device_name: str, key_features: int, initialized: bool | None, ) -> None: """Initialize.""" self.device_id = device_id self.door_type = door_type self.detector_side = detector_side self.device_name = device_name self.key_features = key_features self.initialized = initialized def __str__(self) -> str: return ( f"{self.__class__.__name__} device_id: {self.device_id.hex()}, door_type: " f"{self.door_type}, detector_side: {self.detector_side}, device_name: " f"{self.device_name}, key_features: {self.key_features}, initialized: " f"{self.initialized}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" raise NotImplementedError @classmethod def _calc_format(cls, data: bytes) -> str: return "!4sbB12sBB" if len(data) == 22 else "!4sbB12sB" @classmethod def _calc_len(cls, data: bytes) -> int: return struct.calcsize(cls._calc_format(data)) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" if len(data) != 2 + cls._calc_len(data): raise InvalidCommand("Invalid length", data.hex()) if data[0] != cls.cmd_id or data[1] != cls._calc_len(data): raise InvalidCommand("Invalid header", data.hex()) @classmethod def from_bytes(cls, data: bytes) -> DetTypeNameCmd: """Initialize from serialized representation of the command.""" cls._validate(data) fmt = cls._calc_format(data) if len(data) == 22: ( device_id, door_type, side, device_name_bytes, key_features, initialized, ) = struct.unpack_from(fmt, data, 2) else: ( device_id, door_type, side, device_name_bytes, key_features, ) = struct.unpack_from(fmt, data, 2) initialized = None door_type = DoorType(door_type) side = DetectorSide(side) device_name = device_name_bytes.decode("ISO_8859_1").split("\x00")[0] return cls(device_id, door_type, side, device_name, key_features, initialized) class ECDHPublicCmd(ECDHPublicReplyCmdBase): """ECDH Public Key Command.""" cmd_id = 0x17 class ECDHPublicReplyCmd(ECDHPublicReplyCmdBase): """ECDH Public Key Reply Command.""" cmd_id = 0x18 class GetNotificationsMaskCmd(Command): """Get Notifications Mask Command.""" cmd_id = 0x1B _format = "" _len = struct.calcsize(_format) def __str__(self) -> str: return f"{self.__class__.__name__}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack() @classmethod def from_bytes(cls, data: bytes) -> GetNotificationsMaskCmd: """Initialize from serialized representation of the command.""" cls._validate(data) return cls() class SetEnabledNotificationsMaskCmd(Command): """Get Enabled Notifications Mask Command.""" cmd_id = 0x1C _format = "!B" _len = struct.calcsize(_format) def __init__(self, notifications_mask: int) -> None: """Initialize.""" self.notifications_mask = notifications_mask # Same as notifications? def __str__(self) -> str: return f"{self.__class__.__name__} mask: {self.notifications_mask:02x}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.notifications_mask) @classmethod def from_bytes(cls, data: bytes) -> SetEnabledNotificationsMaskCmd: """Initialize from serialized representation of the command.""" cls._validate(data) (notifications_mask,) = struct.unpack_from(cls._format, data, 2) return cls(notifications_mask) class DoorPosition(IntEnum): """Door position.""" CLOSED = 0 OPEN = 1 UNKNOWN = -1 class UnlockStatus(IntEnum): """Unlock status.""" UNLOCKED = 0 LOCKED = 1 SECURITY_LOCKED = 2 UNLOCKED_SECURITY_LOCKED = 3 UNLOCKED_FORCED_UNLOCK = 4 UNKNOWN = -1 class DoorHandleState(IntEnum): """Door handle state.""" HANDLE_IDLE = 0 HANDLE_UP_DOWN = 1 UNKNOWN = 255 class UnlockMode(IntEnum): """Unlock mode.""" UNLOCKED = 0 LOCKED = 1 UNKNOWN = -1 class AwayMode(IntEnum): """Away mode.""" ENABLED = 1 DISABLED = 2 UNKNOWN = -1 class ShellProtectionStatus(IntEnum): """Shell protection status.""" ENABLED = 1 DISABLED = 2 UNKNOWN = -1 class NotificationType(IntEnum): """Notification type.""" BATTERY = 0 DOOR_POSITION = 1 UNLOCK_STATUS = 2 UNLOCK_MODE = 3 AWAY_MODE = 4 SHELL_PROTECTION_STATUS = 5 SHB_DELAYED_UNLOCK = 6 class Notifications: """Notifications.""" battery: int | None = None door_position: DoorPosition | None = None unlock_status: UnlockStatus | None = None door_handle_state: DoorHandleState | None = None unlock_mode: UnlockMode | None = None away_mode: AwayMode | None = None shell_protection_status: ShellProtectionStatus | None = None shb_delayed_unlock: int | None = None # 0 means off def __str__(self) -> str: values = [] for attr in ("battery", "shb_delayed_unlock"): if (value := getattr(self, attr)) is not None: values.append(f"{attr}: {value}") for attr in ( "door_position", "unlock_status", "door_handle_state", "unlock_mode", "away_mode", "shell_protection_status", ): if (value := getattr(self, attr)) is not None: values.append(f"{attr}: {value.name}") return ", ".join(values) def update(self, other: Notifications) -> None: """Merge notifications.""" for attr in ( "battery", "door_position", "unlock_status", "door_handle_state", "unlock_mode", "away_mode", "shell_protection_status", "shb_delayed_unlock", ): if (value := getattr(other, attr)) is not None: setattr(self, attr, value) @classmethod def from_bytes(cls, data: bytes) -> Notifications: """Initialize from serialized representation.""" if len(data) & 0x1: raise InvalidCommand("Invalid data", data.hex()) instance = cls() pos = 0 while pos < len(data): notification_type = NotificationType(data[pos]) value = data[pos + 1] pos += 2 if notification_type == NotificationType.BATTERY: instance.battery = value continue if notification_type == NotificationType.DOOR_POSITION: instance.door_position = DoorPosition(value) continue if notification_type == NotificationType.UNLOCK_STATUS: instance.unlock_status = UnlockStatus(value & 0x7F) instance.door_handle_state = DoorHandleState((value & 0x80) >> 7) continue if notification_type == NotificationType.UNLOCK_MODE: instance.unlock_mode = UnlockMode(value) continue if notification_type == NotificationType.AWAY_MODE: instance.away_mode = AwayMode(value) continue if notification_type == NotificationType.SHELL_PROTECTION_STATUS: instance.shell_protection_status = ShellProtectionStatus(value) continue if notification_type == NotificationType.SHB_DELAYED_UNLOCK: instance.shb_delayed_unlock = value continue raise InvalidCommand("Unknown notification", notification_type, data.hex()) return instance class NotificationsUpdateBase(Command): """Notifications update base.""" def __init__(self, notifications: Notifications) -> None: """Initialize.""" self.notifications = notifications def __str__(self) -> str: return f"{self.__class__.__name__} notifications: {self.notifications}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.notifications) @classmethod def _calc_format(cls, data: bytes) -> str: return f"!{len(data)-2}s" @classmethod def _calc_len(cls, data: bytes) -> int: return struct.calcsize(cls._calc_format(data)) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" if len(data) < 2: raise InvalidCommand("Invalid length", data.hex()) if len(data) != 2 + cls._calc_len(data): raise InvalidCommand("Invalid length", data.hex()) if data[0] != cls.cmd_id or data[1] != cls._calc_len(data): raise InvalidCommand("Invalid header", data.hex()) @classmethod def from_bytes(cls, data: bytes) -> NotificationsUpdateBase: """Initialize from serialized representation of the command.""" cls._validate(data) notifications = Notifications.from_bytes(data[2:]) return cls(notifications) class NotificationsUpdateCmd(NotificationsUpdateBase): """Set Enabled Notifications Mask Response.""" cmd_id = 0x1D class AuthPinChallengeCmd(AuthChallengeBase): """Authenticate PIN Challenge Command.""" cmd_id = 0x23 _nonce_label = "challenge" class AuthPinChallengeReplyCmd(AuthChallengeBase): """Authenticate PIN Challenge Reply Command.""" cmd_id = 0x24 _nonce_label = "reply" class AuthCheckCmd(HandshakeCheckBase): """Authentication Check Command.""" cmd_id = 0x25 class LockMode(IntEnum): """Lock mode.""" LOCK_MODE = 1 SHB_MODE = 2 IN_MODE = 3 OUT_MODE = 4 REMOTE_INIT = 5 FORCED_UNLOCK = 6 class ChangeModeCmd(Command): """Change Mode Command.""" cmd_id = 0x27 _format = "!B" _len = struct.calcsize(_format) def __init__(self, mode: LockMode) -> None: """Initialize.""" self.mode = mode def __str__(self) -> str: return f"{self.__class__.__name__} mode: {self.mode.name}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.mode) @classmethod def from_bytes(cls, data: bytes) -> ChangeModeCmd: """Initialize from serialized representation of the command.""" cls._validate(data) (mode,) = struct.unpack_from(cls._format, data, 2) return cls(LockMode(mode)) class RequestChallengeCmd(Command): """Request Challenge Command.""" cmd_id = 0x34 _format = "!B4s" _len = struct.calcsize(_format) def __init__(self, ver: int, key_holder_id: bytes) -> None: """Initialize.""" self.key_holder_id = key_holder_id self.ver = ver @classmethod def defaults(cls) -> RequestChallengeCmd: """Return an instance with default settings.""" return cls(1, bytes.fromhex("fffffffe")) def __str__(self) -> str: return ( f"{self.__class__.__name__} ver:{self.ver}, id:{self.key_holder_id.hex()}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.ver, self.key_holder_id) @classmethod def from_bytes(cls, data: bytes) -> RequestChallengeCmd: """Initialize from serialized representation of the command.""" cls._validate(data) (ver, key_holder_id) = struct.unpack_from(cls._format, data, 2) return cls(ver, key_holder_id) class StatusReportType(IntEnum): """Status report type""" STUCK = 1 STUCK_V2 = 2 INIT_REPORT = 3 LOCK_UNLOCK_REPORT = 4 MISSING_TIMEOUT = 10 GENERIC_STATUS_REPORT = -16 class StatusReportCmd(Command): """Status Report Command.""" cmd_id = 0x35 def __init__( self, status_report_type: StatusReportType, status_report: bytes ) -> None: """Initialize.""" self._format = f"!b{len(status_report[1:])}s" self._len = struct.calcsize(self._format) self.status_report_type = status_report_type self.status_report = status_report def __str__(self) -> str: return ( f"{self.__class__.__name__} type:{self.status_report_type.name}, report:" f"{self.status_report.hex()}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.status_report_type, self.status_report) @classmethod def _calc_format(cls, data: bytes) -> str: return f"!b{len(data)-3}s" @classmethod def _calc_len(cls, data: bytes) -> int: return struct.calcsize(cls._calc_format(data)) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" if len(data) < 3: raise InvalidCommand("Invalid length", data.hex()) if len(data) != 2 + cls._calc_len(data): raise InvalidCommand("Invalid length", data.hex()) if data[0] != cls.cmd_id or data[1] != cls._calc_len(data): raise InvalidCommand("Invalid header", data.hex()) @classmethod def from_bytes(cls, data: bytes) -> StatusReportCmd: """Initialize from serialized representation of the command.""" cls._validate(data) fmt = cls._calc_format(data) (status_report_type, status_report) = struct.unpack_from(fmt, data, 2) return cls(StatusReportType(status_report_type), status_report) class DeviceType(IntEnum): """Device type.""" DETECTOR = 1 FOB = 2 KEYPAD = 3 ANDROID_KEY_APP = 4 ANDROID_ADMIN_APP = 5 IOS_KEY_APP = 6 IOS_ADMIN_APP = 7 GATEWAY = 8 SMART_HOME_BTN = 9 PARTNER_KEY = 10 class GetIdentificationRsp(Command): """Get Identification Response.""" cmd_id = 0x50 _format = "!HBBB4sB7sB" _len = struct.calcsize(_format) def __init__( self, protocol_version: int, sw_version_major: int, sw_version_minor: int, sw_version_patch: int, key_holder_id: bytes, has_cookie: bool, user_id: bytes, device_type: DeviceType, ) -> None: """Initialize.""" self.protocol_version = protocol_version self.sw_version_major = sw_version_major self.sw_version_minor = sw_version_minor self.sw_version_patch = sw_version_patch self.key_holder_id = key_holder_id self.has_cookie = has_cookie self.user_id = user_id self.device_type: DeviceType = device_type @classmethod def defaults(cls, key_holder_id: bytes) -> GetIdentificationRsp: """Return an instance with default settings.""" device_type = DeviceType.ANDROID_KEY_APP user_id = "sweDoor".encode("ASCII") return cls(27, 1, 2, 6, key_holder_id, False, user_id, device_type) def __str__(self) -> str: return ( f"{self.__class__.__name__} protocol_ver: {self.protocol_version}, sw_ver:" f" {self.sw_version} , key_holder_id: {self.key_holder_id.hex()}, " f"has_cookie: {self.has_cookie}, user_id: {self.user_id.hex()}, " f"device_type: {self.device_type.name}" ) @property def sw_version(self) -> str: "Return sw version." return ( f"{self.sw_version_major}.{self.sw_version_minor}." f"{self.sw_version_patch}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack( self.protocol_version, self.sw_version_major, self.sw_version_minor, self.sw_version_patch, self.key_holder_id, self.has_cookie, self.user_id, self.device_type, ) @classmethod def from_bytes(cls, data: bytes) -> GetIdentificationRsp: """Initialize from serialized representation of the command.""" cls._validate(data) ( protocol_version, sw_version_major, sw_version_minor, sw_version_patch, key_holder_id, has_cookie, user_id, device_type, ) = struct.unpack_from(cls._format, data, 2) return cls( protocol_version, sw_version_major, sw_version_minor, sw_version_patch, key_holder_id, has_cookie, user_id, DeviceType(device_type), ) class AckRsp(Command): """Acknowledge Response.""" cmd_id = 0x51 _format = "!BB" _len = struct.calcsize(_format) def __init__(self, error_code: ErrorCode, cmd_id: int): """Initialize.""" self.error_code = error_code self.ack_cmd_id = cmd_id def __str__(self) -> str: return ( f"{self.__class__.__name__} error: {self.error_code.name}, cmd_id: " f"{self.ack_cmd_id:02x}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.error_code, self.ack_cmd_id) @classmethod def from_bytes(cls, data: bytes) -> AckRsp: """Initialize from serialized representation of the command.""" (error_code, ack_cmd_id) = struct.unpack_from(cls._format, data, 2) return cls(ErrorCode(error_code), ack_cmd_id) class AuthChallengeRsp(AuthChallengeBase): """Authentication Challenge Response.""" cmd_id = 0x52 _nonce_label = "response" class GetNotificationsMaskRsp(Command): """Get Notifications Mask Response.""" cmd_id = 0x57 _format = "!B" _len = struct.calcsize(_format) def __init__(self, notifications_mask: int) -> None: """Initialize.""" self.notifications_mask = notifications_mask def __str__(self) -> str: return f"{self.__class__.__name__} mask: {self.notifications_mask:02x}" @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.notifications_mask) @classmethod def from_bytes(cls, data: bytes) -> GetNotificationsMaskRsp: """Initialize from serialized representation of the command.""" cls._validate(data) (notifications_mask,) = struct.unpack_from(cls._format, data, 2) return cls(notifications_mask) class SetEnabledNotificationsMaskRsp(NotificationsUpdateBase): """Set Enabled Notifications Mask Response.""" cmd_id = 0x58 class UnlockRsp(Command): """Unlock Response.""" cmd_id = 0x5A _format = "!Bl" _len = struct.calcsize(_format) def __init__(self, error_code: ErrorCode, timeout: int): """Initialize.""" self.error_code = error_code self.timeout = timeout def __str__(self) -> str: return ( f"{self.__class__.__name__} error: {self.error_code.name}, timeout: " f"{self.timeout}" ) @property def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self.error_code, self.timeout) @classmethod def from_bytes(cls, data: bytes) -> UnlockRsp: """Initialize from serialized representation of the command.""" (error_code, timeout) = struct.unpack_from(cls._format, data, 2) return cls(ErrorCode(error_code), timeout) COMMAND_TYPES: dict[int, type[Command]] = { 0x00: GetIdentificationCmd, 0x01: AuthChallengeCmd, 0x02: AuthChallengeAcceptedCmd, 0x04: DisconnectReqCmd, 0x09: AssociationParametersCmd, 0x13: NotAssociatedCmd, 0x15: DetTypeNameCmd, 0x17: ECDHPublicCmd, 0x1D: NotificationsUpdateCmd, 0x23: AuthPinChallengeCmd, 0x25: AuthCheckCmd, 0x35: StatusReportCmd, 0x50: GetIdentificationRsp, 0x51: AckRsp, 0x57: GetNotificationsMaskRsp, 0x58: SetEnabledNotificationsMaskRsp, 0x5A: UnlockRsp, } def parse_command(data: bytes) -> Command: """Parse data and return Command.""" if len(data) < 2 or (len(data) - 2) != data[1]: raise InvalidCommand("Invalid length", data.hex()) if command_type := COMMAND_TYPES.get(data[0]): return command_type.from_bytes(data) return UnknownCommand.from_bytes(data) emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/crypto.py000066400000000000000000000156351450500624600255060ustar00rootroot00000000000000"""Cryptography helpers.""" from __future__ import annotations import binascii import logging import os from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from .errors import InvalidCommand _LOGGER = logging.getLogger(__name__) class AESCrypto: """AES in CFB mode.""" _cipher = None def __init__(self, iv_lock: bytes, iv_last_bytes: bytes, pin: str) -> None: """Initialize.""" self._iv_lock = iv_lock self._iv_last_bytes = iv_last_bytes self._pin = bytes(pin + "*" * 8, "ASCII") IV = bytearray() IV.extend(self._iv_lock[0:12]) IV.extend(reversed(self._iv_last_bytes)) self._iv = bytes(IV) self._encryptor = Cipher(algorithms.AES128(self._pin), modes.ECB()).encryptor() self._primary_iv = bytes([IV[0] ^ 171]) + IV[1:16] self._secondary_iv = bytes(IV) self._primary_iv = self._encryptor.update(self._primary_iv) @property def iv(self) -> bytes: """Return IV.""" return self._iv def handle_auth_pin_challenge(self) -> bytes: """Handle a PIN authentication challenge.""" _LOGGER.debug( "handle_auth_pin_challenge PIN: %s, IV: %s", self._pin.hex(), self._iv.hex() ) encryptor = Cipher(algorithms.AES128(self._pin), modes.ECB()).encryptor() reply = encryptor.update(self._iv) + encryptor.finalize() _LOGGER.debug("Hash: %s", reply.hex()) return reply def decrypt_incoming(self, recv_ct: bytes) -> bytes: """Decrypt incoming data and validate checksum.""" # Decrypt the data self._secondary_iv = self._encryptor.update(self._secondary_iv) recv_pt = bytes(a ^ b for (a, b) in zip(self._secondary_iv, recv_ct)) # Decrypt the checksum check_ct = bytes(recv_ct[16:] + bytes([0] * 12)) self._secondary_iv = self._encryptor.update(self._secondary_iv) check_pt = bytes(a ^ b for (a, b) in zip(self._secondary_iv, check_ct)) # Validate checksum check = binascii.crc_hqx(recv_pt + check_pt[0:2], 0xFFFF) if (check_pt[3] << 8 | check_pt[2]) != check: raise InvalidCommand(f"Invalid CRC16: {check_pt[2:4].hex()} != {check:04x}") return recv_pt def encrypt_send(self, send_pt: bytes) -> bytes: """Append checksum and encrypt data.""" # Pad plaintext send_pt += bytes(16 - len(send_pt)) # Encrypt message self._primary_iv = self._encryptor.update(self._primary_iv) send_ct = bytes(a ^ b for (a, b) in zip(self._primary_iv, send_pt)) # Calculate checksum salt = os.urandom(2) check = binascii.crc_hqx(send_pt + salt, 0xFFFF) check_pt = bytes(salt) + bytes([check & 255, (check & 0xFF00) >> 8]) # Encrypt checksum self._primary_iv = self._encryptor.update(self._primary_iv) check_ct = bytes(a ^ b for (a, b) in zip(self._primary_iv[0:4], check_pt)) return send_ct + check_ct class TripleAESCrypto: """Triple-AES in CFB-like mode.""" _cipher = None def __init__(self, iv_lock: bytes, iv_last_bytes: bytes, secret: bytes) -> None: """Initialize.""" self._iv_lock = iv_lock self._iv_last_bytes = iv_last_bytes self._secret = secret IV = bytearray() IV.extend(self._iv_lock[0:12]) IV.extend(reversed(self._iv_last_bytes)) self._iv = bytes(IV) digest = hashes.Hash(hashes.SHA256()) digest.update(secret + self._iv) digest_bytes = digest.finalize() self._cipher1 = Cipher( algorithms.AES128(digest_bytes[0:16]), modes.ECB() ).encryptor() self._cipher2 = Cipher( algorithms.AES128(digest_bytes[16:32]), modes.ECB() ).encryptor() # If protocol >= 13 self._primary_iv = bytes([IV[0] ^ 171]) + IV[1:16] # If protocol >= 12 self._secondary_iv = bytes(IV) self._primary_iv = self._cipher1.update(self._primary_iv) def handle_auth_challenge(self) -> bytes: """Handle an authentication challenge.""" _LOGGER.debug( "handle_auth_challenge PIN: %s, IV: %s", self._secret.hex(), self._iv.hex() ) _hash = self._cipher1.update( self._cipher2.update(self._cipher1.update(self._iv)) ) _LOGGER.debug("Hash: %s", _hash.hex()) return _hash def decrypt_incoming(self, recv_ct: bytes) -> bytes: """Decrypt incoming data and validate checksum.""" # Decrypt the data tmp = self._cipher1.update(self._secondary_iv) recv_pt = bytes(a ^ b for (a, b) in zip(tmp, recv_ct)) tmp = self._cipher2.update(self._secondary_iv) recv_pt = bytes(a ^ b for (a, b) in zip(tmp, recv_pt)) self._secondary_iv = tmp = self._cipher1.update(self._secondary_iv) recv_pt = bytes(a ^ b for (a, b) in zip(tmp, recv_pt)) # Decrypt the checksum check_ct = bytes(recv_ct[16:] + bytes([0] * 12)) tmp = self._cipher1.update(self._secondary_iv) check_pt = bytes(a ^ b for (a, b) in zip(tmp, check_ct)) tmp = self._cipher2.update(self._secondary_iv) check_pt = bytes(a ^ b for (a, b) in zip(tmp, check_pt)) self._secondary_iv = tmp = self._cipher1.update(self._secondary_iv) check_pt = bytes(a ^ b for (a, b) in zip(tmp, check_pt)) # Validate checksum check = binascii.crc_hqx(recv_pt + check_pt[0:2], 0xFFFF) if (check_pt[3] << 8 | check_pt[2]) != check: raise InvalidCommand(f"Invalid CRC16: {check_pt[2:4].hex()} != {check:04x}") return recv_pt def encrypt_send(self, send_pt: bytes) -> bytes: """Append checksum and encrypt data.""" # Pad plaintext send_pt += bytes(16 - len(send_pt)) # Encrypt message primary_iv_copy = bytes(self._primary_iv) self._primary_iv = tmp = self._cipher1.update(self._primary_iv) send_ct = bytes(a ^ b for (a, b) in zip(tmp, send_pt)) tmp = self._cipher2.update(primary_iv_copy) send_ct = bytes(a ^ b for (a, b) in zip(tmp, send_ct)) tmp = self._cipher1.update(primary_iv_copy) send_ct = bytes(a ^ b for (a, b) in zip(tmp, send_ct)) # Calculate checksum salt = os.urandom(2) check = binascii.crc_hqx(send_pt + salt, 0xFFFF) check_pt = bytes(salt) + bytes([check & 255, (check & 0xFF00) >> 8]) # Encrypt checksum primary_iv_copy = bytes(self._primary_iv) self._primary_iv = tmp = self._cipher1.update(self._primary_iv) check_ct = bytes(a ^ b for (a, b) in zip(tmp, check_pt)) tmp = self._cipher2.update(primary_iv_copy) check_ct = bytes(a ^ b for (a, b) in zip(tmp, check_ct)) tmp = self._cipher1.update(primary_iv_copy) check_ct = bytes(a ^ b for (a, b) in zip(tmp, check_ct)) return send_ct + check_ct emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/dkey.py000066400000000000000000001057351450500624600251230ustar00rootroot00000000000000"""Dormakaba DKEY Manager""" from __future__ import annotations from abc import ABC, abstractmethod import asyncio from collections import deque from collections.abc import Callable import logging import os from typing import TypeVar, cast from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import ( BLEAK_RETRY_EXCEPTIONS, BleakClientWithServiceCache, establish_connection, retry_bluetooth_connection_error, ) from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from . import commands as cmds from .commands import _CMD_T, Command, LockMode, Notifications, parse_command from .crypto import AESCrypto, TripleAESCrypto from .errors import ( CommandFailed, Disconnected, DkeyError, InvalidActivationCode, InvalidCommand, NotAssociated, NotAuthenticated, NotConnected, Timeout, UnsupportedProtocolVersion, WrongActivationCode, ) from .models import AssociationData, DeviceInfo, DisconnectReason, ErrorCode _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") ADDRESS = "F0:94:0A:BD:3D:0A" SERVICE_UUID = "e7a60000-6639-429f-94fd-86de8ea26897" CHARACTERISTIC_UUID_TO_SERVER = "e7a60001-6639-429f-94fd-86de8ea26897" CHARACTERISTIC_UUID_FROM_SERVER = "e7a60002-6639-429f-94fd-86de8ea26897" # Enable to debug framing and deframing of commands DEBUG_COMMAND_FRAMING = True # Enable to debug decrypt/encrypt of commands DEBUG_COMMAND_CRYPT = True DISCONNECT_DELAY = 30 DEFAULT_ATTEMPTS = 3 ACTIVATION_CODE_ALLOWED = "BCDFGHJKLMNPQRSTVWXZ0123456789" SUPPORTED_PROTOCOL_VERSIONS = (26, 27) def device_filter(advertisement_data: AdvertisementData) -> bool: """Return True if the device is supported.""" uuids = advertisement_data.service_uuids if SERVICE_UUID in uuids or CHARACTERISTIC_UUID_TO_SERVER in uuids: return True return False class BaseProcedure(ABC): """Base class for procedures.""" enable_notifications: bool = False need_auth: bool = False def __init__(self, lock: DKEYLock) -> None: """Initialize.""" self._lock = lock @abstractmethod async def execute(self) -> bool: """Execute the procedure""" class AssociateProcedure(BaseProcedure): """Associate with a lock.""" # Associate procedure: # -> GetIdentificationCmd # <- GetIdentificationRsp # <- GetIdentificationCmd # -> GetIdentificationRsp # -> RequestChallengeCmd # <- AuthPinChallengeCmd # -> AckRsp # -> AuthPinChallengeReplyCmd # <- AckRsp (SUCCESS | WRONG_PIN) # - ENABLE ENCRYPTION - # <- AuthCheckCmd # -> AckRsp # <- ECDHPublicCmd # -> AckRsp # -> ECDHPublicReplyCmd # <- AckRsp # <- AssociationParametersCmd def __init__(self, lock: DKEYLock, activation_code: str) -> None: """Initialize.""" super().__init__(lock) self._activation_code = activation_code self.key_holder_id: bytes | None = None self.secret: bytes | None = None async def execute(self) -> bool: """Execute the procedure""" iv_last_bytes = os.urandom(4) ident_cmd_fut = self._lock.receive_once(cmds.GetIdentificationCmd) ident_rsp_fut = self._lock.receive_once(cmds.GetIdentificationRsp) await self._lock.send_cmd(cmds.GetIdentificationCmd(iv_last_bytes)) await asyncio.gather(ident_cmd_fut, ident_rsp_fut) self._lock.on_identification(ident_rsp_fut.result()) # Set key holder id to -2 to trigger association sequence await self._lock.send_cmd( cmds.GetIdentificationRsp.defaults(bytes.fromhex("fffffffe")) ) auth_challenge_fut = self._lock.receive_once(cmds.AuthPinChallengeCmd) await self._lock.send_cmd(cmds.RequestChallengeCmd.defaults()) await auth_challenge_fut await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.AuthPinChallengeCmd.cmd_id) ) iv_lock = auth_challenge_fut.result().nonce crypto = AESCrypto(iv_lock, iv_last_bytes, self._activation_code) reply = crypto.handle_auth_pin_challenge() ack_fut = self._lock.receive_once(cmds.AckRsp) auth_check_fut = self._lock.receive_once(cmds.AuthCheckCmd) self._lock.set_crypto(crypto, 2) await self._lock.send_cmd(cmds.AuthPinChallengeReplyCmd(reply)) await ack_fut if ack_fut.result().error_code != ErrorCode.SUCCESS: auth_check_fut.cancel() if ack_fut.result().error_code == ErrorCode.WRONG_PIN: raise WrongActivationCode raise CommandFailed(ack_fut.result().error_code) await auth_check_fut ecdh_public_fut = self._lock.receive_once(cmds.ECDHPublicCmd) await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.AuthCheckCmd.cmd_id) ) await ecdh_public_fut await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.ECDHPublicCmd.cmd_id) ) peer_public_key = ecdh_public_fut.result().public_key private_key = ec.generate_private_key(ec.SECP256R1()) public_key = private_key.public_key() _LOGGER.debug( "%s: Private key: %s", self._lock.name, private_key.private_bytes( serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ).hex(), ) association_params_fut = self._lock.receive_once(cmds.AssociationParametersCmd) await self._lock.send_cmd(cmds.ECDHPublicReplyCmd(public_key)) await association_params_fut await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.AssociationParametersCmd.cmd_id) ) shared_secret = private_key.exchange(ec.ECDH(), peer_public_key) _LOGGER.debug("%s: Shared secret: %s", self._lock.name, shared_secret.hex()) iv = crypto.iv key = shared_secret[0:16] msg = association_params_fut.result().secret encryptor = Cipher(algorithms.AES128(key), modes.CFB(iv)).encryptor() _hash = encryptor.update(msg) + encryptor.finalize() self.key_holder_id = association_params_fut.result().key_holder_id self.secret = _hash + shared_secret[16:] _LOGGER.debug("%s: Hash: %s", self._lock.name, _hash.hex()) det_type_name_fut = self._lock.receive_once(cmds.DetTypeNameCmd) await det_type_name_fut self._lock.on_lock_type_name(det_type_name_fut.result()) await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.DetTypeNameCmd.cmd_id) ) return True class AuthenticateProcedure(BaseProcedure): """Authenticate with a lock.""" # Auth procedure: # -> GetIdentificationCmd # <- GetIdentificationRsp # <- GetIdentificationCmd # -> GetIdentificationRsp # <- AuthChallengeCmd | NotAssociatedCmd # -> AuthChallengeRsp # - ENABLE ENCRYPTION - # <- AuthChallengeAcceptedCmd # -> AckRsp # <- DetTypeNameCmd # -> AckRsp def __init__(self, lock: DKEYLock, key_holder_id: bytes, secret: bytes) -> None: """Initialize.""" super().__init__(lock) self._key_holder_id = key_holder_id self._secret = secret async def execute(self) -> bool: """Execute the procedure""" iv_last_bytes = os.urandom(4) ident_cmd_fut = self._lock.receive_once(cmds.GetIdentificationCmd) ident_rsp_fut = self._lock.receive_once(cmds.GetIdentificationRsp) await self._lock.send_cmd(cmds.GetIdentificationCmd(iv_last_bytes)) await asyncio.gather(ident_cmd_fut, ident_rsp_fut) self._lock.on_identification(ident_rsp_fut.result()) auth_challenge_fut = self._lock.receive_once(cmds.AuthChallengeCmd) not_associated_fut = self._lock.receive_once(cmds.NotAssociatedCmd) await self._lock.send_cmd( cmds.GetIdentificationRsp.defaults(self._key_holder_id) ) done, pending = await asyncio.wait( (auth_challenge_fut, not_associated_fut), return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() if isinstance(done.pop().result(), cmds.NotAssociatedCmd): raise NotAssociated iv_lock = auth_challenge_fut.result().nonce crypto = TripleAESCrypto(iv_lock, iv_last_bytes, self._secret) reply = crypto.handle_auth_challenge() _LOGGER.debug("%s: Auth reply: %s", self._lock.name, reply.hex()) auth_challenge_accepted_fut = self._lock.receive_once( cmds.AuthChallengeAcceptedCmd ) self._lock.set_crypto(crypto, 1) await self._lock.send_cmd(cmds.AuthChallengeRsp(reply)) await auth_challenge_accepted_fut _LOGGER.debug("%s: Challenge accepted", self._lock.name) await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.AuthChallengeAcceptedCmd.cmd_id) ) det_type_name_fut = self._lock.receive_once(cmds.DetTypeNameCmd) await det_type_name_fut self._lock.on_lock_type_name(det_type_name_fut.result()) await self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.DetTypeNameCmd.cmd_id) ) return True class EnableNotificationsProcedure(BaseProcedure): """Enable notifications on a lock.""" need_auth = True # Enable notification procedure: # -> GetNotificationsMaskCmd # <- GetNotificationsMaskRsp # -> SetEnabledNotificationsMaskCmd # <- SetEnabledNotificationsMaskRsp # <- NotificationsUpdateCmd async def execute(self) -> bool: """Execute the procedure""" def handle_status_report( cmd: cmds.StatusReportCmd, ) -> None: asyncio.create_task( self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.StatusReportCmd.cmd_id) ) ) def handle_notification_update( cmd: cmds.NotificationsUpdateCmd, ) -> None: asyncio.create_task( self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.NotificationsUpdateCmd.cmd_id) ) ) self._lock.on_notification(cmd.notifications) def handle_disconnect_request( cmd: cmds.DisconnectReqCmd, ) -> None: asyncio.create_task( self._lock.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, cmds.DisconnectReqCmd.cmd_id) ) ) self._lock.on_disconnect_req() self._lock.receive_notifications( cmds.DisconnectReqCmd, handle_disconnect_request ) self._lock.receive_notifications( cmds.NotificationsUpdateCmd, handle_notification_update ) self._lock.receive_notifications(cmds.StatusReportCmd, handle_status_report) notifications_mask_fut = self._lock.receive_once(cmds.GetNotificationsMaskRsp) await self._lock.send_cmd(cmds.GetNotificationsMaskCmd()) await notifications_mask_fut enabled_notifications_fut = self._lock.receive_once( cmds.SetEnabledNotificationsMaskRsp ) await self._lock.send_cmd(cmds.SetEnabledNotificationsMaskCmd(0x7F)) # Nothing # await self._lock.send_cmd(SetEnabledNotificationsMaskCmd(0x01)) # Door_position # await self._lock.send_cmd(SetEnabledNotificationsMaskCmd(0x02)) # unlock status # await self._lock.send_cmd(SetEnabledNotificationsMaskCmd(0x04)) await enabled_notifications_fut self._lock.on_notification(enabled_notifications_fut.result().notifications) return True class ChangeModeProcedure(BaseProcedure): """Change mode of a lock.""" enable_notifications = True need_auth = True # Change mode procedure: # -> ChangeModeCmd # <- StatusReportCmd (if lock/unlock) def __init__(self, lock: DKEYLock, mode: cmds.LockMode) -> None: """Initialize.""" super().__init__(lock) self._mode = mode async def execute(self) -> bool: """Execute the procedure""" await self._lock.send_cmd(cmds.ChangeModeCmd(self._mode)) return True class UnlockProcedure(BaseProcedure): """Unlock a lock.""" enable_notifications = True need_auth = True # Unlock procedure: # -> UnlockCmd # <- UnlockRsp # <- StatusReportCmd async def execute(self) -> bool: """Execute the procedure""" unlock_rsp_fut = self._lock.receive_once(cmds.UnlockRsp) await self._lock.send_cmd(cmds.UnlockCmd.defaults()) await unlock_rsp_fut return True class NullProcedure(BaseProcedure): """Do nothing.""" enable_notifications = True need_auth = True async def execute(self) -> bool: """Execute the procedure""" return True class DKEYLock: """Manage a Dormakaba DKEY lock.""" _key_holder_id: bytes | None = None _secret: bytes | None = None def __init__( self, ble_device: BLEDevice, advertisement_data: AdvertisementData | None = None ): """Initialize.""" self._advertisement_data = advertisement_data self._authenticated: bool = False self._ble_device = ble_device self._callbacks: list[Callable[[cmds.Notifications], None]] = [] self._client: BleakClient | None = None self._command_handlers: dict[int, Callable[[Command], None]] = {} self._command_handlers_oneshot: dict[int, asyncio.Future[Command]] = {} self._connect_lock: asyncio.Lock = asyncio.Lock() self._crypto: AESCrypto | TripleAESCrypto | None = None self._crypto_delay: int = 0 self._disconnect_reason: DisconnectReason | None = None self._disconnect_timer: asyncio.TimerHandle | None = None self._expected_disconnect: bool = False self._notifications_enabled: bool = False self._procedure_lock: asyncio.Lock = asyncio.Lock() self._rx_segment: bytearray | None = None self._rx_segment_cmd_id: int | None = None self._tx_queue: deque[tuple[str, bytes]] = deque() self.loop = asyncio.get_running_loop() self.device_info = DeviceInfo() self.state: Notifications = Notifications() def set_ble_device_and_advertisement_data( self, ble_device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Set the ble device.""" self._ble_device = ble_device self._advertisement_data = advertisement_data @property def address(self) -> str: """Get the address of the device.""" return str(self._ble_device.address) @property def battery_level(self) -> int | None: """Get the battery level of the device.""" battery_level = self.state.battery if battery_level is None: return None if battery_level >= 28: return 100 if battery_level >= 25: return 75 if battery_level >= 23: return 50 if battery_level >= 20: return 25 if battery_level > 17: return 5 if battery_level > 0: return 1 return 0 @property def battery_criticl(self) -> bool | None: """Return True if the battery level is critically low.""" if (battery_level := self.state.battery) is None: return None return battery_level <= 17 @property def battery_warning(self) -> bool | None: """Return True if the battery level is low.""" if (battery_level := self.state.battery) is None: return None return battery_level < 20 @property def name(self) -> str: """Get the name of the device.""" return str(self._ble_device.name or self._ble_device.address) @property def rssi(self) -> int | None: """Get the rssi of the device.""" if self._advertisement_data: return self._advertisement_data.rssi return None def set_association_data(self, association_data: AssociationData) -> None: """Set key holder id and secret.""" _LOGGER.debug( "%s: Set association data %s", self.name, association_data.to_json() ) self._key_holder_id = association_data.key_holder_id self._secret = association_data.secret @staticmethod def _validate_activation_code(activation_code: str) -> str: """Validate the activation code, raises if it's not valid.""" trans = str.maketrans("", "", "- \t") activation_code = activation_code.translate(trans) activation_code = activation_code.upper() if any(c not in ACTIVATION_CODE_ALLOWED for c in activation_code): raise InvalidActivationCode return activation_code async def associate(self, activation_code: str) -> AssociationData: """Associate with the lock.""" _LOGGER.debug("%s: Associate %s", self.name, activation_code) activation_code = self._validate_activation_code(activation_code) associate_proc = AssociateProcedure(self, activation_code) if ( not await self._execute(associate_proc) or associate_proc.key_holder_id is None or associate_proc.secret is None ): raise DkeyError association_data = AssociationData( associate_proc.key_holder_id, associate_proc.secret ) self.set_association_data(association_data) return association_data async def connect(self) -> None: """Connect the lock. Note: A connection is automatically established when performing an operation on the lock. This can be called to ensure the lock is in range. """ _LOGGER.debug("%s: Connect", self.name) await self._ensure_connected() async def disconnect(self) -> None: """Disconnect from the lock.""" _LOGGER.debug("%s: Disconnect", self.name) await self._execute_disconnect(DisconnectReason.USER_REQUESTED) def _fire_callbacks(self, notifications: Notifications) -> None: """Fire the callbacks.""" _LOGGER.debug("_fire_callbacks") for callback in self._callbacks: callback(notifications) def register_callback( self, callback: Callable[[Notifications], None] ) -> Callable[[], None]: """Register a callback to be called when the state changes.""" def unregister_callback() -> None: self._callbacks.remove(callback) self._callbacks.append(callback) return unregister_callback async def lock(self) -> bool: """Lock the lock.""" _LOGGER.debug("%s: Lock", self.name) change_mode_proc = ChangeModeProcedure(self, LockMode.LOCK_MODE) return await self._execute(change_mode_proc) async def set_mode(self, mode: LockMode) -> bool: """Lock the lock.""" _LOGGER.debug("%s: Set mode %s", self.name, mode.name) change_mode_proc = ChangeModeProcedure(self, mode) return await self._execute(change_mode_proc) async def unlock(self) -> bool: """Unlock the lock.""" _LOGGER.debug("%s: Unlock", self.name) unlock_proc = UnlockProcedure(self) return await self._execute(unlock_proc) async def update(self) -> bool: """Update the lock's status.""" _LOGGER.debug("%s: Update", self.name) null_proc = NullProcedure(self) return await self._execute(null_proc) @retry_bluetooth_connection_error(DEFAULT_ATTEMPTS) # type: ignore[misc] async def _execute(self, procedure: BaseProcedure) -> bool: """Execute a procedure.""" if self._procedure_lock.locked(): _LOGGER.debug( "%s: Procedure already in progress, waiting for it to complete; " "RSSI: %s", self.name, self.rssi, ) async with self._procedure_lock: try: if procedure.need_auth: await self._enable_notifications_when_locked() else: await self._ensure_connected() result = await procedure.execute() return result except asyncio.CancelledError as err: if self._disconnect_reason is None: raise DkeyError from err if self._disconnect_reason == DisconnectReason.TIMEOUT: raise Timeout from err raise Disconnected(self._disconnect_reason) from err except DkeyError: self._disconnect(DisconnectReason.ERROR) raise async def _enable_notifications_when_locked(self) -> None: """Enable notifications.""" await self._ensure_authenticated() if self._notifications_enabled: return notify_proc = EnableNotificationsProcedure(self) await notify_proc.execute() self._notifications_enabled = True async def _ensure_authenticated(self) -> None: """Ensure we're authenticated with the lock.""" await self._ensure_connected() if self._authenticated: return if self._key_holder_id is None or self._secret is None: raise NotAuthenticated auth_proc = AuthenticateProcedure(self, self._key_holder_id, self._secret) await auth_proc.execute() self._authenticated = True async def _ensure_connected(self) -> None: """Ensure connection to device is established.""" if self._connect_lock.locked(): _LOGGER.debug( "%s: Connection already in progress, waiting for it to complete; " "RSSI: %s", self.name, self.rssi, ) if self._client and self._client.is_connected: self._reset_disconnect_timer() return async with self._connect_lock: # Check again while holding the lock if self._client and self._client.is_connected: self._reset_disconnect_timer() return _LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi) client = await establish_connection( BleakClientWithServiceCache, self._ble_device, self.name, self._disconnected, use_services_cache=True, ble_device_callback=lambda: self._ble_device, ) await client.pair() _LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi) # services = client.services # for service in services: # _LOGGER.debug("%s:service: %s", self.name, service.uuid) # characteristics = service.characteristics # for char in characteristics: # _LOGGER.debug("%s:characteristic: %s", self.name, char.uuid) # resolved = self._resolve_characteristics(client.services) # if not resolved: # # Try to handle services failing to load # resolved = self._resolve_characteristics(await client.get_services()) self._client = client self._disconnect_reason = None self._reset_disconnect_timer() _LOGGER.debug( "%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi ) await client.start_notify( CHARACTERISTIC_UUID_TO_SERVER, self._notification_handler ) await client.start_notify( CHARACTERISTIC_UUID_FROM_SERVER, self._notification_handler ) def _raise_if_not_connected(self) -> None: """Raise if the connection to device is lost.""" if self._client and self._client.is_connected: self._reset_disconnect_timer() return raise NotConnected def _reset_disconnect_timer(self) -> None: """Reset disconnect timer.""" if self._disconnect_timer: self._disconnect_timer.cancel() self._expected_disconnect = False self._disconnect_timer = self.loop.call_later( DISCONNECT_DELAY, self._timed_disconnect ) def _disconnected(self, client: BleakClient) -> None: """Disconnected callback.""" if self._expected_disconnect: _LOGGER.debug( "%s: Disconnected from device; RSSI: %s", self.name, self.rssi ) return _LOGGER.warning( "%s: Device unexpectedly disconnected; RSSI: %s", self.name, self.rssi, ) self._client = None self._disconnect(DisconnectReason.UNEXPECTED) def _timed_disconnect(self) -> None: """Disconnect from device.""" self._disconnect_timer = None asyncio.create_task(self._execute_timed_disconnect()) async def _execute_timed_disconnect(self) -> None: """Execute timed disconnection.""" _LOGGER.debug( "%s: Disconnecting after timeout of %s", self.name, DISCONNECT_DELAY, ) await self._execute_disconnect(DisconnectReason.TIMEOUT) def _disconnect(self, reason: DisconnectReason) -> None: """Disconnect from device.""" asyncio.create_task(self._execute_disconnect(reason)) async def _execute_disconnect(self, reason: DisconnectReason) -> None: """Execute disconnection.""" _LOGGER.debug("%s: Execute disconnect", self.name) if self._connect_lock.locked(): _LOGGER.debug( "%s: Connection already in progress, waiting for it to complete; " "RSSI: %s", self.name, self.rssi, ) async with self._connect_lock: client = self._client self._client = None if client and client.is_connected: self._expected_disconnect = True await client.stop_notify(CHARACTERISTIC_UUID_TO_SERVER) await client.stop_notify(CHARACTERISTIC_UUID_FROM_SERVER) await client.disconnect() self._reset(reason) _LOGGER.debug("%s: Execute disconnect done", self.name) def _reset(self, reason: DisconnectReason) -> None: """Reset.""" _LOGGER.debug("%s: reset", self.name) self._authenticated = False self._command_handlers = {} for fut in self._command_handlers_oneshot.values(): fut.cancel() self._command_handlers_oneshot = {} self._crypto = None self._crypto_delay = 0 self._disconnect_reason = reason if self._disconnect_timer: self._disconnect_timer.cancel() self._disconnect_timer = None self._notifications_enabled = False self._rx_segment = None self._rx_segment_cmd_id = None self._tx_queue.clear() async def _notification_handler( self, characteristic: BleakGATTCharacteristic, data: bytes ) -> None: """Notification handler.""" self._reset_disconnect_timer() if self._crypto and self._crypto_delay: self._crypto_delay -= 1 elif self._crypto and not self._crypto_delay: if DEBUG_COMMAND_CRYPT: _LOGGER.debug("RX ct: %02x: %s", characteristic.handle, data.hex()) try: data = self._crypto.decrypt_incoming(data) except InvalidCommand as err: _LOGGER.warning("Could not decrypt received data %s", err) self._disconnect(DisconnectReason.INVALID_COMMAND) return # Validate and truncate padded decrypted packet if not (data[0] & 0x80): if data[1] > 16: _LOGGER.warning("Received invalid command %s", data.hex()) self._disconnect(DisconnectReason.INVALID_COMMAND) return data = data[0 : data[1] + 2] if len(data) < 2: _LOGGER.warning("Received invalid command %s", data.hex()) self._disconnect(DisconnectReason.INVALID_COMMAND) return if data[0] & 0x80: if DEBUG_COMMAND_FRAMING: _LOGGER.debug( "RX: segment: %02x: %s", characteristic.handle, data.hex() ) # Segmented packet if not self._rx_segment: self._rx_segment = bytearray() self._rx_segment_cmd_id = data[0] & 0x7F if self._rx_segment_cmd_id != data[0] & 0x7F: _LOGGER.warning( "RX: got %s, expected %s", data[0] & 0x7F, self._rx_segment_cmd_id ) self._disconnect(DisconnectReason.INVALID_COMMAND) return segment_len = 15 self._rx_segment.extend(data[1 : segment_len + 2]) if DEBUG_COMMAND_FRAMING: _LOGGER.debug("RX: appending %s", data[1:segment_len].hex()) # ACK the segment try: await self.send_cmd( cmds.AckRsp(ErrorCode.SUCCESS, self._rx_segment_cmd_id) ) except BLEAK_RETRY_EXCEPTIONS: _LOGGER.warning("Failed to send ACK, disconnecting") self._disconnect(DisconnectReason.TIMEOUT) return if self._rx_segment: if DEBUG_COMMAND_FRAMING: _LOGGER.debug( "RX: segment: %02x: %s", characteristic.handle, data.hex() ) if self._rx_segment_cmd_id != data[0]: _LOGGER.warning( "RX: got %s, expected %s", data[0] & 0x7F, self._rx_segment_cmd_id ) self._disconnect(DisconnectReason.INVALID_COMMAND) return segment_len = data[1] self._rx_segment.extend(data[2 : segment_len + 2]) if DEBUG_COMMAND_FRAMING: _LOGGER.debug("RX: appending %s", data[2:segment_len].hex()) # Assemble the completed package data = data[0:1] + bytes((len(self._rx_segment),)) + self._rx_segment self._rx_segment = None self._rx_segment_cmd_id = None _LOGGER.debug("RX: %02x: %s", characteristic.handle, data.hex()) try: command = parse_command(data) except InvalidCommand as err: _LOGGER.warning("Received invalid command %s", err) self._disconnect(DisconnectReason.INVALID_COMMAND) return _LOGGER.debug("RX: %s (%s)", command, command.cmd_id) if command_handler := self._command_handlers.get(command.cmd_id): command_handler(command) if fut := self._command_handlers_oneshot.pop(command.cmd_id, None): if fut and not fut.done(): fut.set_result(command) def _fragmentize(self, dest: str, cmd_id: int, data: bytes) -> None: """Split a command in fragments and enqueue them.""" mtu = 16 if self._crypto and not self._crypto_delay else 20 size = len(data) pos = 0 while True: if (remain := size - pos) <= mtu: fragment_size = remain fragment_head = bytes((cmd_id, fragment_size)) else: fragment_size = mtu - 1 fragment_head = bytes((cmd_id | 0x80,)) fragment = fragment_head + data[pos : pos + fragment_size] if DEBUG_COMMAND_FRAMING: _LOGGER.debug("TX: enqueue fragment %s", fragment.hex()) self._tx_queue.append((dest, fragment)) pos += fragment_size if pos >= size: break async def send_cmd(self, command: Command) -> None: """Send a command.""" if command.cmd_id >= 0x50: char_specifier = CHARACTERISTIC_UUID_FROM_SERVER else: char_specifier = CHARACTERISTIC_UUID_TO_SERVER data = command.as_bytes _LOGGER.debug("TX: %s", command) _LOGGER.debug("TX: %s: %s", char_specifier, data.hex()) self._fragmentize(char_specifier, data[0], data[2:]) crypto_enabled = self._crypto and not self._crypto_delay if self._crypto and self._crypto_delay: self._crypto_delay -= 1 while self._tx_queue: dest, frag = self._tx_queue.popleft() if DEBUG_COMMAND_FRAMING: _LOGGER.debug("TX: send fragment %s", frag.hex()) got_fragment_ack: asyncio.Future[cmds.AckRsp] | None = None if frag[0] & 0x80: got_fragment_ack = self.receive_once(cmds.AckRsp) if self._crypto and crypto_enabled: frag = self._crypto.encrypt_send(frag) if DEBUG_COMMAND_CRYPT: _LOGGER.debug("TX ct: %s: %s", dest, frag.hex()) self._raise_if_not_connected() assert self._client await self._client.write_gatt_char(dest, frag, True) if got_fragment_ack: if DEBUG_COMMAND_FRAMING: _LOGGER.debug("TX: wait fragment ack") await got_fragment_ack def on_disconnect_req(self) -> None: """Handle disconnect request from the lock.""" asyncio.create_task(self._execute_disconnect(DisconnectReason.LOCK_REQUESTED)) def on_identification(self, identification: cmds.GetIdentificationRsp) -> None: """Handle identification from the lock.""" if identification.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: raise UnsupportedProtocolVersion(identification.protocol_version) self.device_info.sw_version = identification.sw_version self.device_info.device_id = f"{identification.key_holder_id.hex()}" def on_lock_type_name(self, type_name: cmds.DetTypeNameCmd) -> None: """Handle type and name of the lock.""" self.device_info.device_name = type_name.device_name def on_notification(self, notifications: Notifications) -> None: """Handle status notifications from the lock.""" self.state.update(notifications) _LOGGER.debug("Lock state: %s", self.state) self._fire_callbacks(notifications) def receive_notifications( self, cmd: type[_CMD_T], callback: Callable[[_CMD_T], None] ) -> None: """Receive a command or response.""" self._command_handlers[cmd.cmd_id] = cast(Callable[[Command], None], callback) def receive_once(self, cmd: type[_CMD_T]) -> asyncio.Future[_CMD_T]: """Receive a command or response once.""" fut: asyncio.Future[_CMD_T] = asyncio.Future() self._command_handlers_oneshot[cmd.cmd_id] = cast(asyncio.Future[Command], fut) return fut def set_crypto(self, crypto: AESCrypto | TripleAESCrypto, crypto_delay: int): """Set crypto.""" self._crypto = crypto self._crypto_delay = crypto_delay emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/errors.py000066400000000000000000000030071450500624600254700ustar00rootroot00000000000000"""Exceptions.""" from bleak.exc import BleakError from bleak_retry_connector import BLEAK_RETRY_EXCEPTIONS as BLEAK_EXCEPTIONS from .models import DisconnectReason, ErrorCode class DkeyError(Exception): """Base class for exceptions.""" class CommandFailed(DkeyError): """Raised when the lock rejects a command.""" def __init__(self, error: ErrorCode): self.error = error super().__init__(error.name) class Disconnected(DkeyError): """Raised when the connection is lost.""" def __init__(self, reason: DisconnectReason): self.reason = reason super().__init__(reason.name) class InvalidCommand(DkeyError): """Raised when a received command can't be parsed.""" class InvalidActivationCode(DkeyError): """Raised when trying to associate with an invalid activation code.""" class NotAssociated(DkeyError): """Raised when not associated.""" class NotAuthenticated(DkeyError): """Raised when trying to execute a command which requires authentication.""" class NotConnected(DkeyError): """Raised when connection is lost while sending a command.""" class Timeout(BleakError, DkeyError): """Raised when trying to associate with wrong activation code.""" class UnsupportedProtocolVersion(DkeyError): """Unsupported protocol version.""" class WrongActivationCode(DkeyError): """Raised when trying to associate with wrong activation code.""" DKEY_EXCEPTIONS = ( *BLEAK_EXCEPTIONS, CommandFailed, Disconnected, InvalidCommand, ) emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/models.py000066400000000000000000000027021450500624600254400ustar00rootroot00000000000000"""Models.""" from __future__ import annotations from dataclasses import dataclass from enum import Enum, IntEnum, auto from typing import Any @dataclass class AssociationData: """Association data.""" key_holder_id: bytes secret: bytes def to_json(self) -> dict[str, str]: """Return a JSON serializable representation.""" return {"key_holder_id": self.key_holder_id.hex(), "secret": self.secret.hex()} @classmethod def from_json(cls, data: dict[str, Any]) -> AssociationData: """Initialize from a JSON serializable representation.""" return cls( key_holder_id=bytes.fromhex(data["key_holder_id"]), secret=bytes.fromhex(data["secret"]), ) @dataclass class DeviceInfo: """Device info.""" device_id: str | None = None device_name: str | None = None sw_version: str | None = None class DisconnectReason(Enum): """Disconnect reason.""" ERROR = auto() INVALID_COMMAND = auto() LOCK_REQUESTED = auto() TIMEOUT = auto() UNEXPECTED = auto() USER_REQUESTED = auto() class ErrorCode(IntEnum): """Error code.""" SUCCESS = 0 UNKNOWN_CMD = 1 NOT_AUTHENTICATED = 2 AUTHENTICATION_FAILED = 3 WRONG_PIN = 4 NO_AVAILABLE_KEYS = 5 FLASH_WRITE_FAILED = 6 MAX_ADMINS = 7 MAX_PENDING_KEYS = 8 MAX_KEY_FOBS_PENDING = 9 WRONG_STATE = 10 INC_PREPARE = 12 REPEAT = 13 PARAM_NOT_SUPPORTED = 14 emontnemery-py-dormakaba-dkey-efb0cf9/py_dormakaba_dkey/py.typed000066400000000000000000000000001450500624600252670ustar00rootroot00000000000000emontnemery-py-dormakaba-dkey-efb0cf9/pyproject.toml000066400000000000000000000057211450500624600230560ustar00rootroot00000000000000[build-system] requires = ["setuptools~=65.6", "wheel~=0.37.1"] build-backend = "setuptools.build_meta" [project] name = "py-dormakaba-dkey" version = "1.0.5" license = {text = "MIT"} description = "API to interact with a Dormakaba dkey lock via bluetooth" readme = "README.md" requires-python = ">=3.10.0" [project.urls] "Homepage" = "https://github.com/emontnemery/py-dormakaba-dkey" [tool.setuptools] platforms = ["any"] zip-safe = true include-package-data = true [tool.setuptools.packages.find] include = ["py_dormakaba_dkey*"] [tool.setuptools.package-data] "*" = ["py.typed"] [tool.black] target-version = ["py310"] extend-exclude = "/generated/" [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings profile = "black" # will group `import x` and `from x import` of the same module. force_sort_within_sections = true known_first_party = [ "py_dormakaba_dkey", "tests", ] forced_separate = [ "tests", ] combine_as_imports = true [tool.pylint.MAIN] py-version = "3.10" ignore = [ "tests", ] # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 init-hook = """\ from pathlib import Path; \ import sys; \ from pylint.config import find_default_config_files; \ sys.path.append( \ str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) ) \ """ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", ] [tool.pylint.BASIC] class-const-naming-style = "any" good-names = [ "_CMD_T", "IV", "iv", ] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # format - handled by black # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable disable = [ "format", "abstract-method", "cyclic-import", "duplicate-code", "inconsistent-return-statements", "locally-disabled", "not-context-manager", "too-few-public-methods", "too-many-ancestors", "too-many-arguments", "too-many-branches", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-return-statements", "too-many-statements", "too-many-boolean-expressions", "unused-argument", "wrong-import-order", "consider-using-f-string", ] enable = [ "useless-suppression", "use-symbolic-message-instead", ] emontnemery-py-dormakaba-dkey-efb0cf9/requirements-test.txt000066400000000000000000000001231450500624600243720ustar00rootroot00000000000000black==23.9.1 flake8==6.1.0 isort==5.12.0 mypy==1.5.1 pylint==2.17.6 pytest==7.4.0 emontnemery-py-dormakaba-dkey-efb0cf9/requirements.txt000066400000000000000000000000511450500624600234150ustar00rootroot00000000000000bleak bleak-retry-connector cryptography emontnemery-py-dormakaba-dkey-efb0cf9/setup.cfg000066400000000000000000000001461450500624600217570ustar00rootroot00000000000000[flake8] # To work with Black max-line-length = 88 # E203: Whitespace before ':' extend-ignore = E203