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.
@@ -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,6 @@
1
+ # opensb-core
2
+
3
+ Shared BLE transport, encryption envelope and session for SwitchBot devices.
4
+
5
+ See the [opensb](https://pypi.org/project/opensb/) distribution for the command line,
6
+ 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