opensb-core 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensb_core-0.0.1/.gitignore +27 -0
- opensb_core-0.0.1/LICENSE +21 -0
- opensb_core-0.0.1/PKG-INFO +26 -0
- opensb_core-0.0.1/README.md +6 -0
- opensb_core-0.0.1/pyproject.toml +32 -0
- opensb_core-0.0.1/src/opensb/ble/__init__.py +52 -0
- opensb_core-0.0.1/src/opensb/ble/const.py +19 -0
- opensb_core-0.0.1/src/opensb/ble/crypto.py +91 -0
- opensb_core-0.0.1/src/opensb/ble/discovery.py +41 -0
- opensb_core-0.0.1/src/opensb/ble/enums.py +28 -0
- opensb_core-0.0.1/src/opensb/ble/errors.py +34 -0
- opensb_core-0.0.1/src/opensb/ble/models.py +57 -0
- opensb_core-0.0.1/src/opensb/ble/py.typed +0 -0
- opensb_core-0.0.1/src/opensb/ble/replies.py +17 -0
- opensb_core-0.0.1/src/opensb/ble/session.py +77 -0
- opensb_core-0.0.1/src/opensb/ble/transport.py +102 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
venv
|
|
12
|
+
|
|
13
|
+
# Tooling caches
|
|
14
|
+
.coverage
|
|
15
|
+
coverage.xml
|
|
16
|
+
htmlcov/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.pyrefly_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Never commit a communication key: it reads every stored passcode back out.
|
|
22
|
+
# Broad on purpose -- the fetch tools name these keypad_key.json, lock_key.json, keypad.json.
|
|
23
|
+
*key*.json
|
|
24
|
+
*.pem
|
|
25
|
+
.env
|
|
26
|
+
*.env
|
|
27
|
+
secrets/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yorsh Siarhei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: opensb-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Shared BLE transport, encryption envelope and session for SwitchBot devices
|
|
5
|
+
Author-email: Yorsh Siarhei <yorsh.srg@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ble,bluetooth,switchbot
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Requires-Dist: cryptography>=42
|
|
16
|
+
Requires-Dist: pydantic>=2.7
|
|
17
|
+
Provides-Extra: ble
|
|
18
|
+
Requires-Dist: bleak>=0.22; extra == 'ble'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# opensb-core
|
|
22
|
+
|
|
23
|
+
Shared BLE transport, encryption envelope and session for SwitchBot devices.
|
|
24
|
+
|
|
25
|
+
See the [opensb](https://pypi.org/project/opensb/) distribution for the command line,
|
|
26
|
+
and `opensb-keypad` / `opensb-lockpro` for device support.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opensb-core"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Shared BLE transport, encryption envelope and session for SwitchBot devices"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Yorsh Siarhei", email = "yorsh.srg@gmail.com" }]
|
|
10
|
+
keywords = ["switchbot", "ble", "bluetooth"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Topic :: Home Automation",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["cryptography>=42", "pydantic>=2.7"]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
ble = ["bleak>=0.22"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version]
|
|
28
|
+
source = "vcs"
|
|
29
|
+
raw-options = { root = "../..", fallback_version = "0.0.0" }
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/opensb"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Transport, encryption and session handling, shared by every SwitchBot device.
|
|
2
|
+
|
|
3
|
+
Device support lives in its own distribution -- `opensb-keypad`, `opensb-lockpro` --
|
|
4
|
+
over this core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from opensb.ble.const import (
|
|
8
|
+
COMPANY_ID,
|
|
9
|
+
GATT_NOTIFY,
|
|
10
|
+
GATT_SERVICE,
|
|
11
|
+
GATT_WRITE,
|
|
12
|
+
SERVICE_UUID,
|
|
13
|
+
)
|
|
14
|
+
from opensb.ble.discovery import Seen, scan
|
|
15
|
+
from opensb.ble.enums import AesMode, ReplyStatus
|
|
16
|
+
from opensb.ble.errors import (
|
|
17
|
+
NotConnectedError,
|
|
18
|
+
OpenSBError,
|
|
19
|
+
ProtocolError,
|
|
20
|
+
ReplyError,
|
|
21
|
+
StaleSessionError,
|
|
22
|
+
)
|
|
23
|
+
from opensb.ble.models import CommunicationKey, Window
|
|
24
|
+
from opensb.ble.replies import check
|
|
25
|
+
from opensb.ble.session import Session
|
|
26
|
+
from opensb.ble.transport import BleakTransport, Transport
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"COMPANY_ID",
|
|
32
|
+
"GATT_NOTIFY",
|
|
33
|
+
"GATT_SERVICE",
|
|
34
|
+
"GATT_WRITE",
|
|
35
|
+
"SERVICE_UUID",
|
|
36
|
+
"AesMode",
|
|
37
|
+
"BleakTransport",
|
|
38
|
+
"CommunicationKey",
|
|
39
|
+
"NotConnectedError",
|
|
40
|
+
"OpenSBError",
|
|
41
|
+
"ProtocolError",
|
|
42
|
+
"ReplyError",
|
|
43
|
+
"ReplyStatus",
|
|
44
|
+
"Seen",
|
|
45
|
+
"Session",
|
|
46
|
+
"StaleSessionError",
|
|
47
|
+
"Transport",
|
|
48
|
+
"Window",
|
|
49
|
+
"__version__",
|
|
50
|
+
"check",
|
|
51
|
+
"scan",
|
|
52
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Wire constants shared by every SwitchBot device on this transport."""
|
|
2
|
+
|
|
3
|
+
# Vendor GATT service, the same across the range.
|
|
4
|
+
GATT_SERVICE = "cba20d00-224d-11e6-9fb8-0002a5d5c51b"
|
|
5
|
+
GATT_WRITE = "cba20002-224d-11e6-9fb8-0002a5d5c51b"
|
|
6
|
+
GATT_NOTIFY = "cba20003-224d-11e6-9fb8-0002a5d5c51b"
|
|
7
|
+
|
|
8
|
+
# Advertisement identifiers.
|
|
9
|
+
COMPANY_ID = 0x0969
|
|
10
|
+
SERVICE_UUID = "0000fd3d-0000-1000-8000-00805f9b34fb"
|
|
11
|
+
|
|
12
|
+
# Every framed command starts with these two bytes.
|
|
13
|
+
FRAME_MAGIC = 0x57
|
|
14
|
+
FRAME_EXT = 0x0F
|
|
15
|
+
|
|
16
|
+
# Reply statuses meaning the key or the negotiated IV went stale: re-handshake, and
|
|
17
|
+
# re-fetch the key if that does not help. Status 5 looks like one of these but is
|
|
18
|
+
# UNSUPPORTED, which no handshake fixes.
|
|
19
|
+
STALE_SESSION_STATUSES = frozenset({4, 9, 13})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The encryption envelope every keypad command travels in.
|
|
2
|
+
|
|
3
|
+
The keypad negotiates a cipher and an IV once per session, then each command is
|
|
4
|
+
written as a four-byte plaintext header followed by the encrypted remainder of the
|
|
5
|
+
command. Replies come back the same way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
11
|
+
from opensb.ble.enums import AesMode
|
|
12
|
+
from opensb.ble.errors import ProtocolError
|
|
13
|
+
|
|
14
|
+
KEY_LENGTH = 16
|
|
15
|
+
IV_LENGTH = {AesMode.CTR: 16, AesMode.GCM: 12}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handshake_frame(key_id: int) -> bytes:
|
|
19
|
+
"""The plaintext frame that opens a session; its reply carries the IV."""
|
|
20
|
+
return bytes([0x57, 0x00, 0x00, 0x00, 0x0F, 0x21, 0x03, key_id & 0xFF])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_handshake_reply(reply: bytes) -> tuple[AesMode, bytes]:
|
|
24
|
+
"""Read the negotiated (mode, iv) out of a handshake reply."""
|
|
25
|
+
if len(reply) < 4:
|
|
26
|
+
raise ProtocolError(f"handshake reply too short: {reply.hex()}")
|
|
27
|
+
if reply[0] != 1:
|
|
28
|
+
raise ProtocolError(f"handshake not acknowledged: {reply.hex()}")
|
|
29
|
+
try:
|
|
30
|
+
mode = AesMode(reply[2])
|
|
31
|
+
except ValueError as err:
|
|
32
|
+
raise ProtocolError(f"unknown cipher mode {reply[2]} in {reply.hex()}") from err
|
|
33
|
+
iv = reply[4:] if mode is AesMode.CTR else reply[4:-4]
|
|
34
|
+
if len(iv) != IV_LENGTH[mode]:
|
|
35
|
+
raise ProtocolError(f"IV is {len(iv)} bytes, expected {IV_LENGTH[mode]} for {mode.name}")
|
|
36
|
+
return mode, iv
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cipher(key: bytes, iv: bytes, mode: AesMode) -> Cipher[Any]:
|
|
40
|
+
algorithm: modes.Mode = modes.CTR(iv) if mode is AesMode.CTR else modes.GCM(iv)
|
|
41
|
+
return Cipher(algorithms.AES(key), algorithm)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check(key: bytes, iv: bytes, mode: AesMode) -> None:
|
|
45
|
+
if len(key) != KEY_LENGTH:
|
|
46
|
+
raise ProtocolError(f"communication key must be {KEY_LENGTH} bytes, got {len(key)}")
|
|
47
|
+
if len(iv) != IV_LENGTH[mode]:
|
|
48
|
+
raise ProtocolError(f"{mode.name} IV must be {IV_LENGTH[mode]} bytes, got {len(iv)}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encrypt(command: bytes, key: bytes, iv: bytes, key_id: int, mode: AesMode) -> bytes:
|
|
52
|
+
"""Wrap a plaintext command into the frame written to the device.
|
|
53
|
+
|
|
54
|
+
command[0] | key_id | nonce[0:2] | AES(command[1:])
|
|
55
|
+
|
|
56
|
+
Byte 0 stays in the clear. The nonce echo heads the IV under CTR and the auth tag
|
|
57
|
+
under GCM.
|
|
58
|
+
"""
|
|
59
|
+
_check(key, iv, mode)
|
|
60
|
+
if not command:
|
|
61
|
+
raise ProtocolError("command is empty")
|
|
62
|
+
encryptor = _cipher(key, iv, mode).encryptor()
|
|
63
|
+
ciphertext: bytes = encryptor.update(command[1:]) + encryptor.finalize()
|
|
64
|
+
nonce = encryptor.tag[:2] if mode is AesMode.GCM else iv[:2]
|
|
65
|
+
return bytes([command[0], key_id & 0xFF, nonce[0], nonce[1]]) + ciphertext
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def decrypt(reply: bytes, key: bytes, iv: bytes, mode: AesMode) -> bytes:
|
|
69
|
+
"""Decode a notification into `status || plaintext payload`.
|
|
70
|
+
|
|
71
|
+
The device sends two bytes of the GCM tag, too few to verify, so a dummy tag lets
|
|
72
|
+
the decryptor run without finalising.
|
|
73
|
+
"""
|
|
74
|
+
_check(key, iv, mode)
|
|
75
|
+
if len(reply) < 4:
|
|
76
|
+
raise ProtocolError(f"reply too short: {reply.hex()}")
|
|
77
|
+
status, ciphertext = reply[0], reply[4:]
|
|
78
|
+
if not ciphertext:
|
|
79
|
+
return bytes([status])
|
|
80
|
+
if mode is AesMode.GCM:
|
|
81
|
+
decryptor = Cipher(algorithms.AES(key), modes.GCM(iv, b"\x00" * 16)).decryptor()
|
|
82
|
+
return bytes([status]) + decryptor.update(ciphertext)
|
|
83
|
+
decryptor = _cipher(key, iv, mode).decryptor()
|
|
84
|
+
return bytes([status]) + decryptor.update(ciphertext) + decryptor.finalize()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def next_gcm_iv(iv: bytes) -> bytes:
|
|
88
|
+
"""The IV for the next command under GCM, which steps once per frame."""
|
|
89
|
+
if len(iv) != IV_LENGTH[AesMode.GCM]:
|
|
90
|
+
raise ProtocolError(f"GCM IV must be {IV_LENGTH[AesMode.GCM]} bytes, got {len(iv)}")
|
|
91
|
+
return ((int.from_bytes(iv, "big") + 1) % (1 << 96)).to_bytes(12, "big")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Listening for advertisements. What one means is the device package's business."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from opensb.ble.errors import OpenSBError
|
|
6
|
+
from opensb.ble.models import Frozen
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
DEFAULT_DISCOVERY_SECONDS = 10.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Seen(Frozen):
|
|
13
|
+
"""One advertiser, as it came off the air."""
|
|
14
|
+
|
|
15
|
+
address: str = Field(description="BLE address")
|
|
16
|
+
name: str | None = Field(default=None, description="Advertised local name")
|
|
17
|
+
rssi: int = Field(description="Signal strength of the last advertisement seen")
|
|
18
|
+
advertisement: object = Field(description="The bleak AdvertisementData, unparsed")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def scan(seconds: float = DEFAULT_DISCOVERY_SECONDS) -> list[Seen]:
|
|
22
|
+
"""Every advertiser in range, latest reading per address, strongest first."""
|
|
23
|
+
try:
|
|
24
|
+
from bleak import BleakScanner
|
|
25
|
+
except ImportError as err: # pragma: no cover - depends on the install extras
|
|
26
|
+
raise OpenSBError("bleak is not installed; install opensb-core[ble] to scan") from err
|
|
27
|
+
|
|
28
|
+
found: dict[str, Seen] = {}
|
|
29
|
+
|
|
30
|
+
def on_advertisement(device: object, data: object) -> None:
|
|
31
|
+
address = getattr(device, "address", "")
|
|
32
|
+
found[address] = Seen(
|
|
33
|
+
address=address,
|
|
34
|
+
name=getattr(data, "local_name", None) or getattr(device, "name", None),
|
|
35
|
+
rssi=getattr(data, "rssi", 0),
|
|
36
|
+
advertisement=data,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async with BleakScanner(on_advertisement):
|
|
40
|
+
await asyncio.sleep(seconds)
|
|
41
|
+
return sorted(found.values(), key=lambda seen: -seen.rssi)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Enumerations shared by every device on this transport."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AesMode(IntEnum):
|
|
7
|
+
"""Cipher the device announces in byte 2 of the handshake reply."""
|
|
8
|
+
|
|
9
|
+
CTR = 0
|
|
10
|
+
GCM = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReplyStatus(IntEnum):
|
|
14
|
+
"""Byte 0 of a decrypted reply."""
|
|
15
|
+
|
|
16
|
+
NULL = 0
|
|
17
|
+
OK = 1
|
|
18
|
+
ERROR = 2
|
|
19
|
+
BUSY = 3
|
|
20
|
+
UNSUPPORTED = 5
|
|
21
|
+
BTL = 6
|
|
22
|
+
ENCRYPTED = 7
|
|
23
|
+
UNENCRYPTED = 8
|
|
24
|
+
PASSWORD_INVALID = 9
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# BTL answers a command that succeeded, so it counts as success alongside OK.
|
|
28
|
+
SUCCESS_STATUSES = frozenset({ReplyStatus.OK, ReplyStatus.BTL})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Exceptions raised by the library."""
|
|
2
|
+
|
|
3
|
+
from opensb.ble.enums import ReplyStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenSBError(Exception):
|
|
7
|
+
"""Base class for every error this library raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtocolError(OpenSBError):
|
|
11
|
+
"""A frame did not have the shape the protocol requires."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReplyError(OpenSBError):
|
|
15
|
+
"""The device answered with a non-success status."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status: int, reply: bytes, context: str = "") -> None:
|
|
18
|
+
self.status = status
|
|
19
|
+
self.reply = reply
|
|
20
|
+
self.context = context
|
|
21
|
+
try:
|
|
22
|
+
name = ReplyStatus(status).name
|
|
23
|
+
except ValueError:
|
|
24
|
+
name = f"UNKNOWN({status})"
|
|
25
|
+
where = f"{context}: " if context else ""
|
|
26
|
+
super().__init__(f"{where}device answered {name} [{reply.hex()}]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StaleSessionError(ReplyError):
|
|
30
|
+
"""The key or the negotiated IV was rejected; handshake again, or re-fetch the key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotConnectedError(OpenSBError):
|
|
34
|
+
"""A command was issued outside a connected session."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Values shared by every device on this transport."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Frozen(BaseModel):
|
|
9
|
+
model_config = ConfigDict(frozen=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommunicationKey(Frozen):
|
|
13
|
+
"""The per-device credential that opens the encrypted channel.
|
|
14
|
+
|
|
15
|
+
Supplied by the caller; this library neither obtains nor stores it. Also accepts
|
|
16
|
+
the shape SwitchBot's key endpoint returns -- `key_id` as a hex string, the key
|
|
17
|
+
under `encryption_key` -- so a payload can go straight to `model_validate`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
mac: str = Field(description="Keypad BLE address the key belongs to")
|
|
21
|
+
key_id: int = Field(description="Key identifier, sent in byte 1 of every frame")
|
|
22
|
+
key: bytes = Field(description="16-byte AES key")
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _accept_wire_shape(cls, payload: object) -> object:
|
|
27
|
+
if not isinstance(payload, dict):
|
|
28
|
+
return payload
|
|
29
|
+
fields = dict(payload)
|
|
30
|
+
if "encryption_key" in fields:
|
|
31
|
+
fields.setdefault("key", fields.pop("encryption_key"))
|
|
32
|
+
if isinstance(fields.get("key"), str):
|
|
33
|
+
fields["key"] = bytes.fromhex(fields["key"])
|
|
34
|
+
if isinstance(fields.get("key_id"), str):
|
|
35
|
+
fields["key_id"] = int(fields["key_id"], 16)
|
|
36
|
+
return fields
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def _check_lengths(self) -> CommunicationKey:
|
|
40
|
+
if len(self.key) != 16:
|
|
41
|
+
raise ValueError(f"communication key must be 16 bytes, got {len(self.key)}")
|
|
42
|
+
if not 0 <= self.key_id <= 0xFF:
|
|
43
|
+
raise ValueError(f"key id must fit in one byte, got {self.key_id}")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Window(Frozen):
|
|
48
|
+
"""A credential validity window, in unix seconds."""
|
|
49
|
+
|
|
50
|
+
starts_at: int = Field(description="First moment the credential works")
|
|
51
|
+
ends_at: int = Field(description="Last moment the credential works")
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def for_minutes(cls, minutes: int, starts_at: int | None = None) -> Window:
|
|
55
|
+
"""A window of `minutes` starting now, or at `starts_at`."""
|
|
56
|
+
start = int(time.time()) if starts_at is None else starts_at
|
|
57
|
+
return cls(starts_at=start, ends_at=start + minutes * 60)
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Judging the status byte a device puts in front of every reply."""
|
|
2
|
+
|
|
3
|
+
from opensb.ble.const import STALE_SESSION_STATUSES
|
|
4
|
+
from opensb.ble.enums import SUCCESS_STATUSES
|
|
5
|
+
from opensb.ble.errors import ProtocolError, ReplyError, StaleSessionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check(reply: bytes, context: str, minimum: int = 1) -> bytes:
|
|
9
|
+
"""Raise unless the keypad accepted the command and answered at length."""
|
|
10
|
+
if not reply:
|
|
11
|
+
raise ProtocolError(f"{context}: empty reply")
|
|
12
|
+
if reply[0] not in SUCCESS_STATUSES:
|
|
13
|
+
error = StaleSessionError if reply[0] in STALE_SESSION_STATUSES else ReplyError
|
|
14
|
+
raise error(reply[0], reply, context)
|
|
15
|
+
if len(reply) < minimum:
|
|
16
|
+
raise ProtocolError(f"{context}: reply too short [{reply.hex()}]")
|
|
17
|
+
return reply
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The encrypted session on top of a transport.
|
|
2
|
+
|
|
3
|
+
The cipher and IV are negotiated once and kept for the life of the session, and
|
|
4
|
+
renegotiated when the device says they went stale.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from opensb.ble import crypto
|
|
8
|
+
from opensb.ble.const import STALE_SESSION_STATUSES
|
|
9
|
+
from opensb.ble.enums import AesMode
|
|
10
|
+
from opensb.ble.errors import StaleSessionError
|
|
11
|
+
from opensb.ble.models import CommunicationKey
|
|
12
|
+
from opensb.ble.replies import check
|
|
13
|
+
from opensb.ble.transport import Transport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Session:
|
|
17
|
+
"""One encrypted conversation with a device."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, transport: Transport, key: CommunicationKey) -> None:
|
|
20
|
+
self.transport = transport
|
|
21
|
+
self.key = key
|
|
22
|
+
self._mode: AesMode | None = None
|
|
23
|
+
self._iv: bytes | None = None
|
|
24
|
+
|
|
25
|
+
async def handshake(self) -> tuple[AesMode, bytes]:
|
|
26
|
+
"""Negotiate the cipher and IV, replacing whatever was in force."""
|
|
27
|
+
reply = await self.transport.request(crypto.handshake_frame(self.key.key_id))
|
|
28
|
+
self._mode, self._iv = crypto.parse_handshake_reply(reply)
|
|
29
|
+
return self._mode, self._iv
|
|
30
|
+
|
|
31
|
+
async def _ensure(self) -> tuple[AesMode, bytes]:
|
|
32
|
+
if self._mode is None or self._iv is None:
|
|
33
|
+
return await self.handshake()
|
|
34
|
+
return self._mode, self._iv
|
|
35
|
+
|
|
36
|
+
def invalidate(self) -> None:
|
|
37
|
+
"""Forget the negotiated session, so the next command handshakes again."""
|
|
38
|
+
self._mode = self._iv = None
|
|
39
|
+
|
|
40
|
+
async def send(self, command: bytes) -> bytes:
|
|
41
|
+
"""Send one command and return its decrypted reply.
|
|
42
|
+
|
|
43
|
+
A stale-session status is retried once against a fresh handshake.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
return await self._send_once(command)
|
|
47
|
+
except StaleSessionError:
|
|
48
|
+
self.invalidate()
|
|
49
|
+
return await self._send_once(command)
|
|
50
|
+
|
|
51
|
+
async def _send_once(self, command: bytes) -> bytes:
|
|
52
|
+
"""One encrypted round trip.
|
|
53
|
+
|
|
54
|
+
Only a stale-session status is judged here; every other status means
|
|
55
|
+
something command-specific, so it is left to the parser.
|
|
56
|
+
"""
|
|
57
|
+
mode, iv = await self._ensure()
|
|
58
|
+
frame = crypto.encrypt(command, self.key.key, iv, self.key.key_id, mode)
|
|
59
|
+
reply = crypto.decrypt(await self.transport.request(frame), self.key.key, iv, mode)
|
|
60
|
+
if reply and reply[0] in STALE_SESSION_STATUSES:
|
|
61
|
+
raise StaleSessionError(reply[0], reply, f"command {command.hex()}")
|
|
62
|
+
return reply
|
|
63
|
+
|
|
64
|
+
async def send_chunked(self, commands: list[bytes]) -> bytes:
|
|
65
|
+
"""Send a multi-frame command as one sequence and return the last reply.
|
|
66
|
+
|
|
67
|
+
The chunks share a handshake so the keypad reads them as one write.
|
|
68
|
+
"""
|
|
69
|
+
mode, iv = await self._ensure()
|
|
70
|
+
reply = b""
|
|
71
|
+
for index, command in enumerate(commands):
|
|
72
|
+
frame = crypto.encrypt(command, self.key.key, iv, self.key.key_id, mode)
|
|
73
|
+
reply = crypto.decrypt(await self.transport.request(frame), self.key.key, iv, mode)
|
|
74
|
+
check(reply, f"chunk {index + 1} of {len(commands)}")
|
|
75
|
+
if mode is AesMode.GCM:
|
|
76
|
+
iv = crypto.next_gcm_iv(iv)
|
|
77
|
+
return reply
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Getting frames to the keypad and notifications back.
|
|
2
|
+
|
|
3
|
+
The protocol layers above take a Transport, so they can be driven by bleak, by an
|
|
4
|
+
ESP bridge, or by a recorded fixture in a test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from opensb.ble.const import GATT_NOTIFY, GATT_WRITE
|
|
11
|
+
from opensb.ble.errors import NotConnectedError, OpenSBError
|
|
12
|
+
|
|
13
|
+
DEFAULT_REQUEST_TIMEOUT = 8.0
|
|
14
|
+
DEFAULT_SCAN_TIMEOUT = 15.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Transport(Protocol):
|
|
18
|
+
"""A connection that can carry one request at a time and answer it."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_connected(self) -> bool: ...
|
|
22
|
+
|
|
23
|
+
async def connect(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def disconnect(self) -> None: ...
|
|
26
|
+
|
|
27
|
+
async def request(self, frame: bytes) -> bytes:
|
|
28
|
+
"""Write one frame and return the notification it produces."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BleakTransport:
|
|
33
|
+
"""A GATT connection to the keypad, over bleak.
|
|
34
|
+
|
|
35
|
+
The keypad sleeps between uses and does not advertise continuously, so it is
|
|
36
|
+
looked up by a scan on every connect rather than addressed directly.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
address: str,
|
|
42
|
+
*,
|
|
43
|
+
adapter: str | None = None,
|
|
44
|
+
scan_timeout: float = DEFAULT_SCAN_TIMEOUT,
|
|
45
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.address = address
|
|
48
|
+
self.adapter = adapter
|
|
49
|
+
self.scan_timeout = scan_timeout
|
|
50
|
+
self.request_timeout = request_timeout
|
|
51
|
+
self._client: Any = None
|
|
52
|
+
self._replies: asyncio.Queue[bytes] = asyncio.Queue()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_connected(self) -> bool:
|
|
56
|
+
return bool(self._client) and bool(getattr(self._client, "is_connected", False))
|
|
57
|
+
|
|
58
|
+
async def connect(self) -> None:
|
|
59
|
+
if self.is_connected:
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
from bleak import BleakClient, BleakScanner
|
|
63
|
+
except ImportError as err: # pragma: no cover - depends on the install extras
|
|
64
|
+
raise OpenSBError(
|
|
65
|
+
"bleak is not installed; install opensb-core[ble] to talk to a device"
|
|
66
|
+
) from err
|
|
67
|
+
|
|
68
|
+
kwargs: dict[str, Any] = {"adapter": self.adapter} if self.adapter else {}
|
|
69
|
+
device = await BleakScanner.find_device_by_address(
|
|
70
|
+
self.address, timeout=self.scan_timeout, **kwargs
|
|
71
|
+
)
|
|
72
|
+
if device is None:
|
|
73
|
+
raise OpenSBError(
|
|
74
|
+
f"{self.address} did not answer a {self.scan_timeout:.0f}s scan; "
|
|
75
|
+
"the keypad sleeps, so press a key to wake it and retry"
|
|
76
|
+
)
|
|
77
|
+
client = BleakClient(device, **kwargs)
|
|
78
|
+
await client.connect()
|
|
79
|
+
self._replies = asyncio.Queue()
|
|
80
|
+
await client.start_notify(GATT_NOTIFY, self._on_notify)
|
|
81
|
+
self._client = client
|
|
82
|
+
|
|
83
|
+
def _on_notify(self, _characteristic: object, data: bytearray) -> None:
|
|
84
|
+
self._replies.put_nowait(bytes(data))
|
|
85
|
+
|
|
86
|
+
async def disconnect(self) -> None:
|
|
87
|
+
client, self._client = self._client, None
|
|
88
|
+
if client is not None:
|
|
89
|
+
await client.disconnect()
|
|
90
|
+
|
|
91
|
+
async def request(self, frame: bytes) -> bytes:
|
|
92
|
+
if not self.is_connected:
|
|
93
|
+
raise NotConnectedError("no BLE connection to the keypad")
|
|
94
|
+
while not self._replies.empty(): # drop anything left over from an earlier command
|
|
95
|
+
self._replies.get_nowait()
|
|
96
|
+
await self._client.write_gatt_char(GATT_WRITE, frame, response=False)
|
|
97
|
+
try:
|
|
98
|
+
return await asyncio.wait_for(self._replies.get(), timeout=self.request_timeout)
|
|
99
|
+
except TimeoutError as err:
|
|
100
|
+
raise OpenSBError(
|
|
101
|
+
f"the keypad did not answer within {self.request_timeout:.0f}s"
|
|
102
|
+
) from err
|