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.
@@ -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"