swbt-python 0.1.0__py3-none-any.whl
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.
- swbt/__init__.py +37 -0
- swbt/diagnostics.py +202 -0
- swbt/errors.py +33 -0
- swbt/gamepad/__init__.py +14 -0
- swbt/gamepad/connection.py +222 -0
- swbt/gamepad/core.py +697 -0
- swbt/gamepad/output.py +73 -0
- swbt/gamepad/transport_factory.py +22 -0
- swbt/input.py +550 -0
- swbt/probe.py +150 -0
- swbt/protocol/__init__.py +1 -0
- swbt/protocol/input_report.py +77 -0
- swbt/protocol/output_report.py +58 -0
- swbt/protocol/profile.py +221 -0
- swbt/protocol/rumble.py +21 -0
- swbt/protocol/spi.py +37 -0
- swbt/protocol/subcommand.py +102 -0
- swbt/py.typed +1 -0
- swbt/report_loop.py +115 -0
- swbt/state_store.py +60 -0
- swbt/transport/__init__.py +3 -0
- swbt/transport/_bumble_acl.py +33 -0
- swbt/transport/_bumble_hidp.py +34 -0
- swbt/transport/_bumble_key_store.py +220 -0
- swbt/transport/_bumble_lifecycle.py +311 -0
- swbt/transport/_bumble_sdp.py +203 -0
- swbt/transport/base.py +81 -0
- swbt/transport/bumble.py +709 -0
- swbt/transport/fake.py +315 -0
- swbt_python-0.1.0.dist-info/METADATA +122 -0
- swbt_python-0.1.0.dist-info/RECORD +34 -0
- swbt_python-0.1.0.dist-info/WHEEL +4 -0
- swbt_python-0.1.0.dist-info/entry_points.txt +3 -0
- swbt_python-0.1.0.dist-info/licenses/LICENSE +21 -0
swbt/transport/bumble.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""Bumble-backed HID transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import platform
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
11
|
+
|
|
12
|
+
from swbt.errors import ClosedError, TransportOpenError
|
|
13
|
+
from swbt.protocol.profile import ProControllerProfile
|
|
14
|
+
from swbt.transport._bumble_acl import drain_bumble_acl_queue
|
|
15
|
+
from swbt.transport._bumble_hidp import (
|
|
16
|
+
HID_GET_SET_SUCCESS,
|
|
17
|
+
HID_GET_SET_UNSUPPORTED_REQUEST,
|
|
18
|
+
HID_OUTPUT_REPORT_TYPE,
|
|
19
|
+
decode_hidp_output_report,
|
|
20
|
+
format_psm,
|
|
21
|
+
hid_channel_name,
|
|
22
|
+
)
|
|
23
|
+
from swbt.transport._bumble_key_store import _CurrentPreviousJsonKeyStore, _DiagnosticKeyStore
|
|
24
|
+
from swbt.transport._bumble_lifecycle import (
|
|
25
|
+
register_connection_diagnostics,
|
|
26
|
+
register_connection_request_bridge,
|
|
27
|
+
register_l2cap_lifecycle_bridge,
|
|
28
|
+
)
|
|
29
|
+
from swbt.transport._bumble_sdp import build_hid_service_records
|
|
30
|
+
from swbt.transport.base import BondedPeer, DisconnectRequestResult
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from bumble.transport.common import TransportSink, TransportSource
|
|
34
|
+
|
|
35
|
+
from swbt.diagnostics import DiagnosticsRecorder
|
|
36
|
+
from swbt.transport.base import (
|
|
37
|
+
ConnectedCallback,
|
|
38
|
+
ControlDataCallback,
|
|
39
|
+
DisconnectedCallback,
|
|
40
|
+
InterruptDataCallback,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_REFERENCE_LINK_POLICY_ENABLE_ROLE_SWITCH = 0x0001
|
|
44
|
+
_REFERENCE_LINK_POLICY_ENABLE_SNIFF_MODE = 0x0004
|
|
45
|
+
_DEFAULT_DEVICE_NAME = "Pro Controller"
|
|
46
|
+
_REFERENCE_CLASS_OF_DEVICE = 0x002508
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class _BumbleGetSetStatus:
|
|
51
|
+
data: bytes = b""
|
|
52
|
+
status: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_REFERENCE_DEFAULT_LINK_POLICY_SETTINGS = (
|
|
56
|
+
_REFERENCE_LINK_POLICY_ENABLE_ROLE_SWITCH | _REFERENCE_LINK_POLICY_ENABLE_SNIFF_MODE
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _BumbleHandle(Protocol):
|
|
61
|
+
source: object
|
|
62
|
+
sink: object
|
|
63
|
+
|
|
64
|
+
async def close(self) -> None:
|
|
65
|
+
"""Close the opened Bumble resource."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_OpenTransport = Callable[[str], Awaitable[_BumbleHandle]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _BumbleConnectionRuntime(Protocol):
|
|
72
|
+
async def authenticate(self) -> None:
|
|
73
|
+
"""Authenticate a Classic connection using stored link keys."""
|
|
74
|
+
|
|
75
|
+
async def encrypt(self, enable: bool = True) -> None:
|
|
76
|
+
"""Enable or disable Classic connection encryption."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _BumbleDeviceRuntime(Protocol):
|
|
80
|
+
EVENT_CONNECTION: str
|
|
81
|
+
EVENT_CONNECTION_FAILURE: str
|
|
82
|
+
keystore: _BumbleKeyStoreRuntime | None
|
|
83
|
+
on_connection_request: Callable[[object, int, int], None]
|
|
84
|
+
powered_on: bool
|
|
85
|
+
|
|
86
|
+
def on(self, event: str, callback: Callable[..., None]) -> None:
|
|
87
|
+
"""Register a Bumble device event callback."""
|
|
88
|
+
|
|
89
|
+
async def power_on(self) -> None:
|
|
90
|
+
"""Power on the Bumble device."""
|
|
91
|
+
|
|
92
|
+
async def power_off(self) -> None:
|
|
93
|
+
"""Power off the Bumble device."""
|
|
94
|
+
|
|
95
|
+
async def set_connectable(self, connectable: bool = True) -> None:
|
|
96
|
+
"""Set Classic connectable state."""
|
|
97
|
+
|
|
98
|
+
async def set_discoverable(self, discoverable: bool = True) -> None:
|
|
99
|
+
"""Set Classic discoverable state."""
|
|
100
|
+
|
|
101
|
+
async def connect(
|
|
102
|
+
self,
|
|
103
|
+
peer_address: str,
|
|
104
|
+
*,
|
|
105
|
+
transport: object,
|
|
106
|
+
timeout: float | None = None, # noqa: ASYNC109
|
|
107
|
+
) -> _BumbleConnectionRuntime:
|
|
108
|
+
"""Connect to a Bluetooth peer."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _BumbleKeyStoreRuntime(Protocol):
|
|
112
|
+
async def get_all(self) -> list[tuple[str, object]]:
|
|
113
|
+
"""Return all key entries keyed by peer address."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _BumbleHidRuntime(Protocol):
|
|
117
|
+
EVENT_INTERRUPT_DATA: str
|
|
118
|
+
EVENT_CONTROL_DATA: str
|
|
119
|
+
l2cap_intr_channel: object | None
|
|
120
|
+
l2cap_ctrl_channel: object | None
|
|
121
|
+
on_l2cap_channel_open: Callable[[object], None]
|
|
122
|
+
on_l2cap_channel_close: Callable[[object], None]
|
|
123
|
+
|
|
124
|
+
def on(self, event: str, callback: Callable[[bytes], None]) -> None:
|
|
125
|
+
"""Register a HID helper event callback."""
|
|
126
|
+
|
|
127
|
+
def send_data(self, data: bytes) -> None:
|
|
128
|
+
"""Send an interrupt-channel HID data message."""
|
|
129
|
+
|
|
130
|
+
def send_control_data(self, report_type: int, data: bytes) -> None:
|
|
131
|
+
"""Send a control-channel HID data message."""
|
|
132
|
+
|
|
133
|
+
async def connect_control_channel(self) -> None:
|
|
134
|
+
"""Request control-channel L2CAP connection."""
|
|
135
|
+
|
|
136
|
+
async def connect_interrupt_channel(self) -> None:
|
|
137
|
+
"""Request interrupt-channel L2CAP connection."""
|
|
138
|
+
|
|
139
|
+
async def disconnect_interrupt_channel(self) -> None:
|
|
140
|
+
"""Request interrupt-channel disconnection."""
|
|
141
|
+
|
|
142
|
+
async def disconnect_control_channel(self) -> None:
|
|
143
|
+
"""Request control-channel disconnection."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class _BumbleRuntime:
|
|
148
|
+
device: _BumbleDeviceRuntime
|
|
149
|
+
hid_device: _BumbleHidRuntime
|
|
150
|
+
service_record_count: int
|
|
151
|
+
hid_descriptor_size: int
|
|
152
|
+
classic_link_policy_settings: int | None = None
|
|
153
|
+
advertising_started: bool = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_InitializeDevice = Callable[[_BumbleHandle], Awaitable[_BumbleRuntime]]
|
|
157
|
+
_StartAdvertising = Callable[[_BumbleRuntime], Awaitable[None]]
|
|
158
|
+
_CloseRuntime = Callable[[_BumbleRuntime], Awaitable[None]]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class BumbleHidTransport:
|
|
162
|
+
"""HID transport boundary that keeps Bumble imports local to this module."""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
adapter: str,
|
|
168
|
+
device_name: str = _DEFAULT_DEVICE_NAME,
|
|
169
|
+
key_store_path: str | None = None,
|
|
170
|
+
diagnostics: DiagnosticsRecorder | None = None,
|
|
171
|
+
_open_transport: _OpenTransport | None = None,
|
|
172
|
+
_initialize_device: _InitializeDevice | None = None,
|
|
173
|
+
_start_advertising: _StartAdvertising | None = None,
|
|
174
|
+
_close_runtime: _CloseRuntime | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Create a Bumble transport for an adapter string."""
|
|
177
|
+
self._adapter = adapter
|
|
178
|
+
self._device_name = device_name
|
|
179
|
+
self._key_store_path = key_store_path
|
|
180
|
+
self._diagnostics = diagnostics
|
|
181
|
+
self._open_transport = _open_transport or _default_open_transport
|
|
182
|
+
if _initialize_device is None:
|
|
183
|
+
|
|
184
|
+
async def initialize_device(handle: _BumbleHandle) -> _BumbleRuntime:
|
|
185
|
+
return await _default_initialize_device(
|
|
186
|
+
handle,
|
|
187
|
+
device_name=self._device_name,
|
|
188
|
+
key_store_path=self._key_store_path,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self._initialize_device = initialize_device
|
|
192
|
+
else:
|
|
193
|
+
self._initialize_device = _initialize_device
|
|
194
|
+
self._start_advertising = _start_advertising or _default_start_advertising
|
|
195
|
+
self._close_runtime = _close_runtime or _default_close_runtime
|
|
196
|
+
self._handle: _BumbleHandle | None = None
|
|
197
|
+
self._runtime: _BumbleRuntime | None = None
|
|
198
|
+
self._interrupt_callback: InterruptDataCallback | None = None
|
|
199
|
+
self._control_callback: ControlDataCallback | None = None
|
|
200
|
+
self._connected_callback: ConnectedCallback | None = None
|
|
201
|
+
self._disconnected_callback: DisconnectedCallback | None = None
|
|
202
|
+
self._l2cap_connected_emitted = False
|
|
203
|
+
self._disconnected_callback_emitted = False
|
|
204
|
+
self._close_lock = asyncio.Lock()
|
|
205
|
+
|
|
206
|
+
async def open(self) -> None:
|
|
207
|
+
"""Open the configured Bumble adapter."""
|
|
208
|
+
if self._handle is not None:
|
|
209
|
+
return
|
|
210
|
+
self._record_event(
|
|
211
|
+
"bumble_runtime",
|
|
212
|
+
bumble_version=_package_version("bumble"),
|
|
213
|
+
os_detail=platform.platform(),
|
|
214
|
+
)
|
|
215
|
+
self._record_event("transport_open_start", adapter=self._adapter)
|
|
216
|
+
try:
|
|
217
|
+
self._handle = await self._open_transport(self._adapter)
|
|
218
|
+
self._runtime = await self._initialize_device(self._handle)
|
|
219
|
+
self._register_device_callbacks(self._runtime.device)
|
|
220
|
+
self._register_hid_callbacks(self._runtime.hid_device)
|
|
221
|
+
self._register_l2cap_lifecycle_bridge(self._runtime.hid_device)
|
|
222
|
+
except Exception as error:
|
|
223
|
+
await self._cleanup_open_failure()
|
|
224
|
+
self._record_error(error)
|
|
225
|
+
msg = f"failed to open Bumble adapter: {self._adapter}"
|
|
226
|
+
raise TransportOpenError(msg) from error
|
|
227
|
+
self._record_event(
|
|
228
|
+
"bumble_device_initialized",
|
|
229
|
+
adapter=self._adapter,
|
|
230
|
+
classic_enabled=True,
|
|
231
|
+
device_name=self._device_name,
|
|
232
|
+
class_of_device=f"0x{_REFERENCE_CLASS_OF_DEVICE:06x}",
|
|
233
|
+
)
|
|
234
|
+
self._record_event(
|
|
235
|
+
"sdp_record_registered",
|
|
236
|
+
adapter=self._adapter,
|
|
237
|
+
service_record_count=self._runtime.service_record_count,
|
|
238
|
+
hid_descriptor_size=self._runtime.hid_descriptor_size,
|
|
239
|
+
)
|
|
240
|
+
self._record_event("hid_device_initialized", adapter=self._adapter)
|
|
241
|
+
self._record_event("transport_open_complete", adapter=self._adapter)
|
|
242
|
+
|
|
243
|
+
async def start_advertising(self) -> None:
|
|
244
|
+
"""Enter Bluetooth Classic discoverable/connectable state."""
|
|
245
|
+
self._require_open()
|
|
246
|
+
if self._runtime is None:
|
|
247
|
+
msg = "Bumble runtime is not initialized"
|
|
248
|
+
raise ClosedError(msg)
|
|
249
|
+
if self._runtime.advertising_started:
|
|
250
|
+
return
|
|
251
|
+
await self._start_advertising(self._runtime)
|
|
252
|
+
self._install_key_store_diagnostics(self._runtime)
|
|
253
|
+
self._runtime.advertising_started = True
|
|
254
|
+
if self._runtime.classic_link_policy_settings is not None:
|
|
255
|
+
self._record_event(
|
|
256
|
+
"classic_link_policy_configured",
|
|
257
|
+
adapter=self._adapter,
|
|
258
|
+
settings=f"0x{self._runtime.classic_link_policy_settings:04x}",
|
|
259
|
+
)
|
|
260
|
+
self._record_event("advertising_start", adapter=self._adapter)
|
|
261
|
+
|
|
262
|
+
async def close(self) -> None:
|
|
263
|
+
"""Close the Bumble adapter if it is open."""
|
|
264
|
+
async with self._close_lock:
|
|
265
|
+
if self._handle is None:
|
|
266
|
+
return
|
|
267
|
+
handle = self._handle
|
|
268
|
+
runtime = self._runtime
|
|
269
|
+
self._handle = None
|
|
270
|
+
self._runtime = None
|
|
271
|
+
self._l2cap_connected_emitted = False
|
|
272
|
+
self._disconnected_callback_emitted = False
|
|
273
|
+
if runtime is not None:
|
|
274
|
+
await self._close_runtime(runtime)
|
|
275
|
+
await handle.close()
|
|
276
|
+
self._record_event("transport_close_complete", adapter=self._adapter)
|
|
277
|
+
|
|
278
|
+
async def request_disconnect(self) -> DisconnectRequestResult:
|
|
279
|
+
"""Request HID channel disconnection through Bumble when channels exist."""
|
|
280
|
+
self._require_open()
|
|
281
|
+
if self._runtime is None:
|
|
282
|
+
return DisconnectRequestResult(
|
|
283
|
+
status="unavailable",
|
|
284
|
+
reason="runtime_not_initialized",
|
|
285
|
+
)
|
|
286
|
+
hid_device = self._runtime.hid_device
|
|
287
|
+
disconnect_steps: list[tuple[str, Callable[[], Awaitable[None]]]] = []
|
|
288
|
+
if hid_device.l2cap_intr_channel is not None:
|
|
289
|
+
disconnect_steps.append(("interrupt", hid_device.disconnect_interrupt_channel))
|
|
290
|
+
if hid_device.l2cap_ctrl_channel is not None:
|
|
291
|
+
disconnect_steps.append(("control", hid_device.disconnect_control_channel))
|
|
292
|
+
if not disconnect_steps:
|
|
293
|
+
return DisconnectRequestResult(
|
|
294
|
+
status="unavailable",
|
|
295
|
+
reason="channels_not_connected",
|
|
296
|
+
)
|
|
297
|
+
requested_channels: list[str] = []
|
|
298
|
+
for channel_name, disconnect_channel in disconnect_steps:
|
|
299
|
+
try:
|
|
300
|
+
await disconnect_channel()
|
|
301
|
+
except Exception as error: # noqa: BLE001
|
|
302
|
+
return DisconnectRequestResult(
|
|
303
|
+
status="failed",
|
|
304
|
+
channels=tuple(requested_channels),
|
|
305
|
+
error_type=type(error).__name__,
|
|
306
|
+
message=str(error),
|
|
307
|
+
)
|
|
308
|
+
requested_channels.append(channel_name)
|
|
309
|
+
return DisconnectRequestResult(
|
|
310
|
+
status="requested",
|
|
311
|
+
channels=tuple(requested_channels),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def list_bonded_peers(self) -> tuple[BondedPeer, ...]:
|
|
315
|
+
"""Return bonded peer addresses from the Bumble key store."""
|
|
316
|
+
self._require_open()
|
|
317
|
+
if self._runtime is None:
|
|
318
|
+
return ()
|
|
319
|
+
await self._ensure_classic_runtime_ready(self._runtime)
|
|
320
|
+
key_store = self._runtime.device.keystore
|
|
321
|
+
if key_store is None:
|
|
322
|
+
return ()
|
|
323
|
+
entries = await key_store.get_all()
|
|
324
|
+
return tuple(BondedPeer(address=address) for address, _keys in entries)
|
|
325
|
+
|
|
326
|
+
async def connect_bonded_peer(
|
|
327
|
+
self,
|
|
328
|
+
peer_address: str,
|
|
329
|
+
*,
|
|
330
|
+
connect_timeout: float | None,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Start an active BR/EDR reconnect attempt with Bumble."""
|
|
333
|
+
self._require_open()
|
|
334
|
+
if self._runtime is None:
|
|
335
|
+
msg = "Bumble runtime is not initialized"
|
|
336
|
+
raise ClosedError(msg)
|
|
337
|
+
await self._ensure_classic_runtime_ready(self._runtime)
|
|
338
|
+
from bumble.core import PhysicalTransport # noqa: PLC0415
|
|
339
|
+
|
|
340
|
+
connection = await self._runtime.device.connect(
|
|
341
|
+
peer_address,
|
|
342
|
+
transport=PhysicalTransport.BR_EDR,
|
|
343
|
+
timeout=connect_timeout,
|
|
344
|
+
)
|
|
345
|
+
await connection.authenticate()
|
|
346
|
+
await connection.encrypt(True)
|
|
347
|
+
await self._runtime.hid_device.connect_control_channel()
|
|
348
|
+
if self._runtime.hid_device.l2cap_ctrl_channel is not None:
|
|
349
|
+
self._record_l2cap_channel_event(
|
|
350
|
+
"l2cap_channel_open",
|
|
351
|
+
self._runtime.hid_device.l2cap_ctrl_channel,
|
|
352
|
+
)
|
|
353
|
+
await self._runtime.hid_device.connect_interrupt_channel()
|
|
354
|
+
if self._runtime.hid_device.l2cap_intr_channel is not None:
|
|
355
|
+
self._record_l2cap_channel_event(
|
|
356
|
+
"l2cap_channel_open",
|
|
357
|
+
self._runtime.hid_device.l2cap_intr_channel,
|
|
358
|
+
)
|
|
359
|
+
self._notify_connected_if_ready()
|
|
360
|
+
|
|
361
|
+
async def send_interrupt(self, payload: bytes) -> None:
|
|
362
|
+
"""Send one interrupt report."""
|
|
363
|
+
self._require_open()
|
|
364
|
+
if self._runtime is None or self._runtime.hid_device.l2cap_intr_channel is None:
|
|
365
|
+
msg = "Bumble interrupt channel is not connected"
|
|
366
|
+
raise ClosedError(msg)
|
|
367
|
+
self._runtime.hid_device.send_data(payload)
|
|
368
|
+
await drain_bumble_acl_queue(self._runtime.hid_device.l2cap_intr_channel)
|
|
369
|
+
|
|
370
|
+
async def send_control(self, payload: bytes) -> None:
|
|
371
|
+
"""Send one control report."""
|
|
372
|
+
self._require_open()
|
|
373
|
+
if self._runtime is None or self._runtime.hid_device.l2cap_ctrl_channel is None:
|
|
374
|
+
msg = "Bumble control channel is not connected"
|
|
375
|
+
raise ClosedError(msg)
|
|
376
|
+
self._runtime.hid_device.send_control_data(HID_OUTPUT_REPORT_TYPE, payload)
|
|
377
|
+
|
|
378
|
+
def on_interrupt_data(self, callback: InterruptDataCallback) -> None:
|
|
379
|
+
"""Register an interrupt data callback."""
|
|
380
|
+
self._interrupt_callback = callback
|
|
381
|
+
|
|
382
|
+
def on_control_data(self, callback: ControlDataCallback) -> None:
|
|
383
|
+
"""Register a control data callback."""
|
|
384
|
+
self._control_callback = callback
|
|
385
|
+
|
|
386
|
+
def on_connected(self, callback: ConnectedCallback) -> None:
|
|
387
|
+
"""Register a connection callback."""
|
|
388
|
+
self._connected_callback = callback
|
|
389
|
+
|
|
390
|
+
def on_disconnected(self, callback: DisconnectedCallback) -> None:
|
|
391
|
+
"""Register a disconnection callback."""
|
|
392
|
+
self._disconnected_callback = callback
|
|
393
|
+
|
|
394
|
+
def _register_hid_callbacks(self, hid_device: _BumbleHidRuntime) -> None:
|
|
395
|
+
hid_device.on(
|
|
396
|
+
hid_device.EVENT_INTERRUPT_DATA,
|
|
397
|
+
self._dispatch_interrupt_data,
|
|
398
|
+
)
|
|
399
|
+
hid_device.on(
|
|
400
|
+
hid_device.EVENT_CONTROL_DATA,
|
|
401
|
+
self._dispatch_control_data,
|
|
402
|
+
)
|
|
403
|
+
self._register_set_report_callback(hid_device)
|
|
404
|
+
|
|
405
|
+
def _register_set_report_callback(self, hid_device: _BumbleHidRuntime) -> None:
|
|
406
|
+
register_set_report = getattr(hid_device, "register_set_report_cb", None)
|
|
407
|
+
if not callable(register_set_report):
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
def on_set_report(
|
|
411
|
+
report_id: int,
|
|
412
|
+
report_type: int,
|
|
413
|
+
report_size: int,
|
|
414
|
+
report_data: bytes,
|
|
415
|
+
) -> _BumbleGetSetStatus:
|
|
416
|
+
_ = report_size
|
|
417
|
+
if report_type != HID_OUTPUT_REPORT_TYPE:
|
|
418
|
+
return _BumbleGetSetStatus(status=HID_GET_SET_UNSUPPORTED_REQUEST)
|
|
419
|
+
if not 0 <= report_id <= 0xFF:
|
|
420
|
+
return _BumbleGetSetStatus(status=HID_GET_SET_UNSUPPORTED_REQUEST)
|
|
421
|
+
report = bytes((report_id,)) + bytes(report_data)
|
|
422
|
+
if not self._dispatch_control_report(report):
|
|
423
|
+
return _BumbleGetSetStatus(status=HID_GET_SET_UNSUPPORTED_REQUEST)
|
|
424
|
+
return _BumbleGetSetStatus(status=HID_GET_SET_SUCCESS)
|
|
425
|
+
|
|
426
|
+
register_set_report(on_set_report)
|
|
427
|
+
|
|
428
|
+
def _register_device_callbacks(self, device: _BumbleDeviceRuntime) -> None:
|
|
429
|
+
device.on(device.EVENT_CONNECTION, self._handle_device_connection)
|
|
430
|
+
device.on(device.EVENT_CONNECTION_FAILURE, self._handle_connection_failure)
|
|
431
|
+
register_connection_request_bridge(
|
|
432
|
+
adapter=self._adapter,
|
|
433
|
+
device=device,
|
|
434
|
+
record_event=self._record_event,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def _register_l2cap_lifecycle_bridge(self, hid_device: _BumbleHidRuntime) -> None:
|
|
438
|
+
register_l2cap_lifecycle_bridge(
|
|
439
|
+
hid_device=hid_device,
|
|
440
|
+
notify_connected_if_ready=self._notify_connected_if_ready,
|
|
441
|
+
notify_disconnected_if_channels_closed=self._notify_disconnected_if_channels_closed,
|
|
442
|
+
record_l2cap_channel_event=self._record_l2cap_channel_event,
|
|
443
|
+
set_l2cap_connected_emitted=self._set_l2cap_connected_emitted,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def _handle_device_connection(self, connection: object) -> None:
|
|
447
|
+
self._l2cap_connected_emitted = False
|
|
448
|
+
self._disconnected_callback_emitted = False
|
|
449
|
+
fields: dict[str, object] = {"adapter": self._adapter}
|
|
450
|
+
connection_handle = getattr(connection, "handle", None)
|
|
451
|
+
peer_address = getattr(connection, "peer_address", None)
|
|
452
|
+
if connection_handle is not None:
|
|
453
|
+
fields["connection_handle"] = connection_handle
|
|
454
|
+
if peer_address is not None:
|
|
455
|
+
fields["peer_address"] = str(peer_address)
|
|
456
|
+
self._record_event("host_connection", **fields)
|
|
457
|
+
register_connection_diagnostics(
|
|
458
|
+
adapter=self._adapter,
|
|
459
|
+
connection=connection,
|
|
460
|
+
handle_disconnection=self._handle_device_disconnection,
|
|
461
|
+
record_event=self._record_event,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _handle_connection_failure(self, error: object) -> None:
|
|
465
|
+
self._record_event(
|
|
466
|
+
"connection_failure",
|
|
467
|
+
adapter=self._adapter,
|
|
468
|
+
error_type=type(error).__name__,
|
|
469
|
+
message=str(error),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def _handle_device_disconnection(self, reason: int | None = None) -> None:
|
|
473
|
+
self._l2cap_connected_emitted = False
|
|
474
|
+
self._record_event("disconnected", adapter=self._adapter, reason=reason)
|
|
475
|
+
self._notify_disconnected_once(reason)
|
|
476
|
+
|
|
477
|
+
def _dispatch_interrupt_data(self, payload: bytes) -> None:
|
|
478
|
+
report = decode_hidp_output_report(payload)
|
|
479
|
+
if report is None:
|
|
480
|
+
return
|
|
481
|
+
self._dispatch_interrupt_report(report)
|
|
482
|
+
|
|
483
|
+
def _dispatch_control_data(self, payload: bytes) -> None:
|
|
484
|
+
report = decode_hidp_output_report(payload)
|
|
485
|
+
if report is None:
|
|
486
|
+
return
|
|
487
|
+
self._dispatch_control_report(report)
|
|
488
|
+
|
|
489
|
+
def _dispatch_interrupt_report(self, payload: bytes) -> bool:
|
|
490
|
+
if self._interrupt_callback is None:
|
|
491
|
+
return False
|
|
492
|
+
self._dispatch_callback(self._interrupt_callback, payload)
|
|
493
|
+
return True
|
|
494
|
+
|
|
495
|
+
def _dispatch_control_report(self, payload: bytes) -> bool:
|
|
496
|
+
if self._control_callback is None:
|
|
497
|
+
return False
|
|
498
|
+
self._dispatch_callback(self._control_callback, payload)
|
|
499
|
+
return True
|
|
500
|
+
|
|
501
|
+
def _dispatch_callback(
|
|
502
|
+
self,
|
|
503
|
+
callback: Callable[[bytes], Awaitable[None]],
|
|
504
|
+
payload: bytes,
|
|
505
|
+
) -> None:
|
|
506
|
+
self._dispatch_awaitable(callback(payload))
|
|
507
|
+
|
|
508
|
+
def _dispatch_connected_callback(self) -> None:
|
|
509
|
+
if self._connected_callback is not None:
|
|
510
|
+
self._dispatch_awaitable(self._connected_callback())
|
|
511
|
+
|
|
512
|
+
def _dispatch_disconnected_callback(self, reason: int | None) -> None:
|
|
513
|
+
if self._disconnected_callback is not None:
|
|
514
|
+
self._dispatch_awaitable(self._disconnected_callback(reason))
|
|
515
|
+
|
|
516
|
+
def _dispatch_awaitable(self, awaitable: Awaitable[None]) -> None:
|
|
517
|
+
try:
|
|
518
|
+
loop = asyncio.get_running_loop()
|
|
519
|
+
except RuntimeError as error:
|
|
520
|
+
self._record_error(error)
|
|
521
|
+
return
|
|
522
|
+
task = asyncio.ensure_future(awaitable, loop=loop)
|
|
523
|
+
task.add_done_callback(self._record_callback_error)
|
|
524
|
+
|
|
525
|
+
def _notify_connected_if_ready(self) -> None:
|
|
526
|
+
if self._runtime is None or self._l2cap_connected_emitted:
|
|
527
|
+
return
|
|
528
|
+
hid_device = self._runtime.hid_device
|
|
529
|
+
if hid_device.l2cap_ctrl_channel is None or hid_device.l2cap_intr_channel is None:
|
|
530
|
+
return
|
|
531
|
+
self._l2cap_connected_emitted = True
|
|
532
|
+
self._record_event("connected", adapter=self._adapter)
|
|
533
|
+
self._dispatch_connected_callback()
|
|
534
|
+
|
|
535
|
+
def _notify_disconnected_if_channels_closed(self) -> None:
|
|
536
|
+
if self._runtime is None:
|
|
537
|
+
return
|
|
538
|
+
hid_device = self._runtime.hid_device
|
|
539
|
+
if hid_device.l2cap_ctrl_channel is not None or hid_device.l2cap_intr_channel is not None:
|
|
540
|
+
return
|
|
541
|
+
self._notify_disconnected_once(None)
|
|
542
|
+
|
|
543
|
+
def _notify_disconnected_once(self, reason: int | None) -> None:
|
|
544
|
+
if self._disconnected_callback_emitted:
|
|
545
|
+
return
|
|
546
|
+
self._disconnected_callback_emitted = True
|
|
547
|
+
if self._disconnected_callback is not None:
|
|
548
|
+
self._dispatch_disconnected_callback(reason)
|
|
549
|
+
|
|
550
|
+
def _set_l2cap_connected_emitted(self, emitted: bool) -> None:
|
|
551
|
+
self._l2cap_connected_emitted = emitted
|
|
552
|
+
|
|
553
|
+
def _record_l2cap_channel_event(self, event: str, l2cap_channel: object) -> None:
|
|
554
|
+
psm = getattr(l2cap_channel, "psm", None)
|
|
555
|
+
self._record_event(
|
|
556
|
+
event,
|
|
557
|
+
adapter=self._adapter,
|
|
558
|
+
channel=hid_channel_name(psm),
|
|
559
|
+
psm=format_psm(psm),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def _record_callback_error(self, task: asyncio.Future[None]) -> None:
|
|
563
|
+
if task.cancelled():
|
|
564
|
+
return
|
|
565
|
+
error = task.exception()
|
|
566
|
+
if isinstance(error, Exception):
|
|
567
|
+
self._record_error(error)
|
|
568
|
+
|
|
569
|
+
def _require_open(self) -> None:
|
|
570
|
+
if self._handle is None:
|
|
571
|
+
msg = "Bumble transport is not open"
|
|
572
|
+
raise ClosedError(msg)
|
|
573
|
+
|
|
574
|
+
def _record_event(self, event: str, **fields: object) -> None:
|
|
575
|
+
if self._diagnostics is not None:
|
|
576
|
+
self._diagnostics.record_event(event, **fields)
|
|
577
|
+
|
|
578
|
+
def _record_error(self, error: Exception) -> None:
|
|
579
|
+
if self._diagnostics is not None:
|
|
580
|
+
self._diagnostics.record_error(error, recoverable=False)
|
|
581
|
+
|
|
582
|
+
def _install_key_store_diagnostics(self, runtime: _BumbleRuntime) -> None:
|
|
583
|
+
if self._diagnostics is None:
|
|
584
|
+
return
|
|
585
|
+
key_store = runtime.device.keystore
|
|
586
|
+
if key_store is None or isinstance(key_store, _DiagnosticKeyStore):
|
|
587
|
+
return
|
|
588
|
+
runtime.device.keystore = _DiagnosticKeyStore(key_store, self._diagnostics)
|
|
589
|
+
|
|
590
|
+
async def _ensure_classic_runtime_ready(self, runtime: _BumbleRuntime) -> None:
|
|
591
|
+
if not runtime.device.powered_on:
|
|
592
|
+
await runtime.device.power_on()
|
|
593
|
+
if runtime.classic_link_policy_settings is None:
|
|
594
|
+
link_policy_settings = await _configure_reference_classic_link_policy(runtime.device)
|
|
595
|
+
if link_policy_settings is not None:
|
|
596
|
+
runtime.classic_link_policy_settings = link_policy_settings
|
|
597
|
+
self._record_event(
|
|
598
|
+
"classic_link_policy_configured",
|
|
599
|
+
adapter=self._adapter,
|
|
600
|
+
settings=f"0x{link_policy_settings:04x}",
|
|
601
|
+
)
|
|
602
|
+
self._install_key_store_diagnostics(runtime)
|
|
603
|
+
|
|
604
|
+
async def _cleanup_open_failure(self) -> None:
|
|
605
|
+
handle = self._handle
|
|
606
|
+
runtime = self._runtime
|
|
607
|
+
self._handle = None
|
|
608
|
+
self._runtime = None
|
|
609
|
+
self._l2cap_connected_emitted = False
|
|
610
|
+
if runtime is not None:
|
|
611
|
+
await self._close_runtime(runtime)
|
|
612
|
+
if handle is not None:
|
|
613
|
+
await handle.close()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def _default_open_transport(adapter: str) -> _BumbleHandle:
|
|
617
|
+
from bumble.transport import open_transport # noqa: PLC0415
|
|
618
|
+
|
|
619
|
+
return cast("_BumbleHandle", await open_transport(adapter))
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
async def _default_initialize_device(
|
|
623
|
+
handle: _BumbleHandle,
|
|
624
|
+
*,
|
|
625
|
+
device_name: str,
|
|
626
|
+
key_store_path: str | None = None,
|
|
627
|
+
) -> _BumbleRuntime:
|
|
628
|
+
from bumble.device import Device, DeviceConfiguration # noqa: PLC0415
|
|
629
|
+
from bumble.hid import Device as HidDevice # noqa: PLC0415
|
|
630
|
+
|
|
631
|
+
profile = ProControllerProfile()
|
|
632
|
+
config = DeviceConfiguration(
|
|
633
|
+
name=device_name,
|
|
634
|
+
class_of_device=_REFERENCE_CLASS_OF_DEVICE,
|
|
635
|
+
le_enabled=False,
|
|
636
|
+
classic_enabled=True,
|
|
637
|
+
keystore=None,
|
|
638
|
+
# Bumble applies these flags during power_on; keep them off until after
|
|
639
|
+
# the Classic link policy command is sent.
|
|
640
|
+
connectable=False,
|
|
641
|
+
discoverable=False,
|
|
642
|
+
)
|
|
643
|
+
device = Device.from_config_with_hci(
|
|
644
|
+
config,
|
|
645
|
+
cast("TransportSource", handle.source),
|
|
646
|
+
cast("TransportSink", handle.sink),
|
|
647
|
+
)
|
|
648
|
+
if key_store_path is not None:
|
|
649
|
+
cast("Any", device).keystore = _CurrentPreviousJsonKeyStore.from_device(
|
|
650
|
+
device,
|
|
651
|
+
filename=key_store_path,
|
|
652
|
+
)
|
|
653
|
+
service_records = build_hid_service_records(
|
|
654
|
+
profile.hid_report_descriptor,
|
|
655
|
+
device_name=device_name,
|
|
656
|
+
)
|
|
657
|
+
device.sdp_service_records = service_records
|
|
658
|
+
hid_device = HidDevice(device)
|
|
659
|
+
return _BumbleRuntime(
|
|
660
|
+
device=cast("_BumbleDeviceRuntime", device),
|
|
661
|
+
hid_device=cast("_BumbleHidRuntime", hid_device),
|
|
662
|
+
service_record_count=len(service_records),
|
|
663
|
+
hid_descriptor_size=len(profile.hid_report_descriptor),
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
async def _default_start_advertising(runtime: _BumbleRuntime) -> None:
|
|
668
|
+
if not runtime.device.powered_on:
|
|
669
|
+
await runtime.device.power_on()
|
|
670
|
+
link_policy_settings = await _configure_reference_classic_link_policy(runtime.device)
|
|
671
|
+
if link_policy_settings is not None:
|
|
672
|
+
runtime.classic_link_policy_settings = link_policy_settings
|
|
673
|
+
await runtime.device.set_connectable(True)
|
|
674
|
+
await runtime.device.set_discoverable(True)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
async def _default_close_runtime(runtime: _BumbleRuntime) -> None:
|
|
678
|
+
if runtime.device.powered_on:
|
|
679
|
+
await runtime.device.set_discoverable(False)
|
|
680
|
+
await runtime.device.set_connectable(False)
|
|
681
|
+
await runtime.device.power_off()
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def _configure_reference_classic_link_policy(device: object) -> int | None:
|
|
685
|
+
"""Set the reference Classic default link policy when Bumble exposes HCI access."""
|
|
686
|
+
from bumble import hci # noqa: PLC0415
|
|
687
|
+
|
|
688
|
+
send_sync_command = getattr(device, "send_sync_command", None)
|
|
689
|
+
if not callable(send_sync_command):
|
|
690
|
+
return None
|
|
691
|
+
host = getattr(device, "host", None)
|
|
692
|
+
supports_command = getattr(host, "supports_command", None)
|
|
693
|
+
if callable(supports_command) and not supports_command(
|
|
694
|
+
hci.HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND
|
|
695
|
+
):
|
|
696
|
+
return None
|
|
697
|
+
await send_sync_command(
|
|
698
|
+
hci.HCI_Write_Default_Link_Policy_Settings_Command(
|
|
699
|
+
default_link_policy_settings=_REFERENCE_DEFAULT_LINK_POLICY_SETTINGS
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
return _REFERENCE_DEFAULT_LINK_POLICY_SETTINGS
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _package_version(package_name: str) -> str:
|
|
706
|
+
try:
|
|
707
|
+
return version(package_name)
|
|
708
|
+
except PackageNotFoundError:
|
|
709
|
+
return "unknown"
|