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/gamepad/core.py ADDED
@@ -0,0 +1,697 @@
1
+ """Public gamepad API."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from types import TracebackType
6
+
7
+ import swbt.gamepad as gamepad_module
8
+ from swbt.diagnostics import DiagnosticsConfig, DiagnosticsRecorder, GamepadStatus
9
+ from swbt.errors import (
10
+ ClosedError,
11
+ ConnectionTimeoutError,
12
+ InvalidInputError,
13
+ SwbtError,
14
+ )
15
+ from swbt.gamepad.connection import (
16
+ ConnectionResult,
17
+ ConnectionStatus, # noqa: F401
18
+ ConnectionWorkflow,
19
+ raise_if_connection_failed,
20
+ )
21
+ from swbt.gamepad.output import OutputReportDispatcher
22
+ from swbt.gamepad.transport_factory import create_default_transport
23
+ from swbt.input import Button, IMUFrame, InputState, Stick
24
+ from swbt.report_loop import ReportLoop
25
+ from swbt.state_store import InputStateStore
26
+ from swbt.transport.base import DisconnectRequestResult, HidDeviceTransport
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SwitchGamepadConfig:
31
+ """Configuration used to construct a SwitchGamepad.
32
+
33
+ Attributes:
34
+ adapter: Bumble adapter moniker, such as ``"usb:0"``.
35
+ key_store_path: Path used by the default transport to persist pairing keys.
36
+ report_period_us: Periodic input report interval in microseconds.
37
+ device_name: HID device name advertised to the host.
38
+ """
39
+
40
+ adapter: str | None = None
41
+ key_store_path: str | None = None
42
+ report_period_us: int = 8000
43
+ device_name: str = "Pro Controller"
44
+
45
+ def __post_init__(self) -> None:
46
+ """Validate resource configuration."""
47
+ if self.report_period_us <= 0:
48
+ msg = "report_period_us must be positive"
49
+ raise InvalidInputError(msg)
50
+
51
+
52
+ class SwitchGamepad:
53
+ """NX-compatible virtual gamepad API.
54
+
55
+ The object owns the input state, report loop, diagnostics recorder, and HID
56
+ transport lifetime. Entering the async context opens resources only; callers
57
+ choose the connection strategy with ``connect()``, ``pair()``, or ``reconnect()``.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ adapter: str | None = None,
64
+ key_store_path: str | None = None,
65
+ report_period_us: int = 8000,
66
+ device_name: str = "Pro Controller",
67
+ diagnostics: DiagnosticsConfig | None = None,
68
+ transport: HidDeviceTransport | None = None,
69
+ ) -> None:
70
+ """Create a gamepad object.
71
+
72
+ Args:
73
+ adapter: Bumble adapter moniker used when the default transport is created.
74
+ Required unless a custom transport is supplied.
75
+ key_store_path: Optional path used by the default transport to persist keys.
76
+ report_period_us: Periodic input report interval in microseconds.
77
+ device_name: HID device name passed to the default transport.
78
+ diagnostics: Optional diagnostics configuration for trace output.
79
+ transport: Optional HID transport instance. When supplied, no Bumble
80
+ transport is created by the constructor.
81
+
82
+ Raises:
83
+ InvalidInputError: ``adapter`` is omitted for the default transport or
84
+ ``report_period_us`` is not positive.
85
+ """
86
+ if transport is None and adapter is None:
87
+ msg = "adapter is required when no custom transport is supplied"
88
+ raise InvalidInputError(msg)
89
+ self._config = SwitchGamepadConfig(
90
+ adapter=adapter,
91
+ key_store_path=key_store_path,
92
+ report_period_us=report_period_us,
93
+ device_name=device_name,
94
+ )
95
+ self._transport = transport
96
+ self._transport_was_injected = transport is not None
97
+ self._state_store = InputStateStore()
98
+ self._diagnostics = DiagnosticsRecorder(
99
+ trace_writer=diagnostics.trace_writer if diagnostics is not None else None
100
+ )
101
+ self._output_report_dispatcher = OutputReportDispatcher(
102
+ diagnostics=self._diagnostics,
103
+ require_reply_sender=self._require_subcommand_reply_sender,
104
+ send_subcommand_reply=self._send_subcommand_reply,
105
+ state_store=self._state_store,
106
+ )
107
+ self._report_loop: ReportLoop | None = None
108
+ self._lifecycle_lock = asyncio.Lock()
109
+ self._connected_event = asyncio.Event()
110
+ self._disconnect_event = asyncio.Event()
111
+ self._connection_state = "closed"
112
+ self._is_open = False
113
+ self._close_in_progress = False
114
+ self._connection_workflow = ConnectionWorkflow(
115
+ clear_connected=self._connected_event.clear,
116
+ close_neutral=self._close_neutral_for_connection_workflow,
117
+ diagnostics=self._diagnostics,
118
+ ensure_open=self.open,
119
+ get_transport=self._connection_transport,
120
+ key_store_path=self._config.key_store_path,
121
+ pair=self._pair_for_connection_workflow,
122
+ set_connection_state=self._set_connection_state,
123
+ transport_was_injected=self._transport_was_injected,
124
+ wait_for_connected=self._wait_for_reconnect_connected_for_workflow,
125
+ )
126
+
127
+ @classmethod
128
+ def from_config(
129
+ cls,
130
+ config: SwitchGamepadConfig,
131
+ *,
132
+ diagnostics: DiagnosticsConfig | None = None,
133
+ transport: HidDeviceTransport | None = None,
134
+ ) -> "SwitchGamepad":
135
+ """Create a gamepad from an explicit resource configuration.
136
+
137
+ Args:
138
+ config: Resource configuration for the gamepad.
139
+ diagnostics: Optional diagnostics configuration for trace output.
140
+ transport: Optional HID transport instance.
141
+
142
+ Returns:
143
+ SwitchGamepad: A gamepad configured from ``config``.
144
+ """
145
+ return cls(
146
+ adapter=config.adapter,
147
+ key_store_path=config.key_store_path,
148
+ report_period_us=config.report_period_us,
149
+ device_name=config.device_name,
150
+ diagnostics=diagnostics,
151
+ transport=transport,
152
+ )
153
+
154
+ async def __aenter__(self) -> "SwitchGamepad":
155
+ """Open the gamepad for an async context manager.
156
+
157
+ Returns:
158
+ SwitchGamepad: This gamepad after resources have been opened.
159
+ """
160
+ await self.open()
161
+ return self
162
+
163
+ async def __aexit__(
164
+ self,
165
+ exc_type: type[BaseException] | None,
166
+ exc: BaseException | None,
167
+ traceback: TracebackType | None,
168
+ ) -> None:
169
+ """Close the gamepad when leaving an async context manager.
170
+
171
+ Args:
172
+ exc_type: Exception type from the managed block, if one was raised.
173
+ exc: Exception instance from the managed block, if one was raised.
174
+ traceback: Traceback from the managed block, if one was raised.
175
+ """
176
+ _ = (exc_type, exc, traceback)
177
+ await self.close(neutral=True)
178
+
179
+ async def open(self) -> None:
180
+ """Open the configured transport.
181
+
182
+ Opening prepares transport callbacks, diagnostics metadata, and the report
183
+ loop. It does not start HID advertising, pairing, or active reconnect.
184
+
185
+ Raises:
186
+ TransportOpenError: Raised by the transport when the adapter cannot be opened.
187
+ Exception: Propagates unexpected transport open failures after cleanup.
188
+ """
189
+ async with self._lifecycle_lock:
190
+ if self._is_open:
191
+ return
192
+ transport = self._ensure_transport()
193
+ self._record_run_metadata()
194
+ self._connection_state = "opening"
195
+ self._register_transport_callbacks()
196
+ self._connected_event.clear()
197
+ try:
198
+ await transport.open()
199
+ self._report_loop = ReportLoop(
200
+ transport=transport,
201
+ state_store=self._state_store,
202
+ report_period_us=self._config.report_period_us,
203
+ diagnostics=self._diagnostics,
204
+ )
205
+ self._connection_state = "opened"
206
+ self._is_open = True
207
+ except Exception:
208
+ self._connection_state = "failed"
209
+ await transport.close()
210
+ self._report_loop = None
211
+ self._is_open = False
212
+ raise
213
+
214
+ async def pair(self, timeout: float | None = None) -> None: # noqa: ASYNC109
215
+ """Start pairing advertising and wait for a host connection.
216
+
217
+ Args:
218
+ timeout: Maximum seconds to wait for a connection. ``None`` waits until
219
+ the host connects.
220
+
221
+ Raises:
222
+ ConnectionTimeoutError: The timeout elapsed before a connection completed.
223
+ ClosedError: The transport was unavailable after opening.
224
+ """
225
+ if not self._is_open:
226
+ await self.open()
227
+ if self._transport is None:
228
+ msg = "gamepad is not open"
229
+ raise ClosedError(msg)
230
+ self._connection_state = "advertising"
231
+ await self._transport.start_advertising()
232
+ if timeout is None:
233
+ await self._connected_event.wait()
234
+ return
235
+ try:
236
+ async with asyncio.timeout(timeout):
237
+ await self._connected_event.wait()
238
+ except TimeoutError as error:
239
+ msg = "connection timed out"
240
+ connection_error = ConnectionTimeoutError(msg)
241
+ self._diagnostics.record_event(
242
+ "connection_timeout",
243
+ state=self._connection_state,
244
+ timeout=timeout,
245
+ )
246
+ self._diagnostics.record_error(connection_error, recoverable=True)
247
+ raise connection_error from error
248
+
249
+ async def reconnect(self, timeout: float | None = None) -> None: # noqa: ASYNC109
250
+ """Reconnect with exactly one bonded peer and raise on failure.
251
+
252
+ Args:
253
+ timeout: Maximum seconds for the active reconnect attempt. ``None`` uses
254
+ the transport default.
255
+
256
+ Raises:
257
+ ConnectionFailedError: No single bonded peer was available or reconnect failed.
258
+ ConnectionTimeoutError: The active reconnect attempt timed out.
259
+ """
260
+ result = await self.try_reconnect(timeout=timeout)
261
+ raise_if_connection_failed(result)
262
+
263
+ async def try_reconnect(
264
+ self,
265
+ timeout: float | None = None, # noqa: ASYNC109
266
+ ) -> ConnectionResult:
267
+ """Try active reconnect with exactly one bonded peer.
268
+
269
+ Args:
270
+ timeout: Maximum seconds for the active reconnect attempt. ``None`` uses
271
+ the transport default.
272
+
273
+ Returns:
274
+ ConnectionResult: Reconnect route, status, selected peer, and peer count.
275
+ """
276
+ return await self._connection_workflow.try_reconnect(timeout=timeout)
277
+
278
+ async def connect(
279
+ self,
280
+ *,
281
+ timeout: float | None = None, # noqa: ASYNC109
282
+ allow_pairing: bool = False,
283
+ ) -> None:
284
+ """Connect using bonded reconnect first, then optional pairing fallback.
285
+
286
+ Args:
287
+ timeout: Maximum seconds for each connection attempt. ``None`` uses the
288
+ lower layer default.
289
+ allow_pairing: If ``True``, run pairing when no bonded peer is available.
290
+
291
+ Raises:
292
+ ConnectionFailedError: The connection attempt finished without connecting.
293
+ ConnectionTimeoutError: The connection attempt timed out.
294
+ """
295
+ result = await self.try_connect(
296
+ timeout=timeout,
297
+ allow_pairing=allow_pairing,
298
+ )
299
+ raise_if_connection_failed(result)
300
+
301
+ async def try_connect(
302
+ self,
303
+ *,
304
+ timeout: float | None = None, # noqa: ASYNC109
305
+ allow_pairing: bool = False,
306
+ ) -> ConnectionResult:
307
+ """Try bonded reconnect first, then optional pairing fallback.
308
+
309
+ Args:
310
+ timeout: Maximum seconds for each connection attempt. ``None`` uses the
311
+ lower layer default.
312
+ allow_pairing: If ``True``, run pairing when no bonded peer is available.
313
+
314
+ Returns:
315
+ ConnectionResult: Route and status chosen by reconnect or pairing fallback.
316
+ """
317
+ return await self._connection_workflow.try_connect(
318
+ timeout=timeout,
319
+ allow_pairing=allow_pairing,
320
+ )
321
+
322
+ async def close(self, *, neutral: bool = True) -> None:
323
+ """Close the transport and leave the gamepad in a closed state.
324
+
325
+ Args:
326
+ neutral: If ``True``, send a trailing neutral report before disconnect
327
+ when a connection is active.
328
+ """
329
+ async with self._lifecycle_lock:
330
+ if not self._is_open or self._transport is None:
331
+ return
332
+ self._close_in_progress = True
333
+ try:
334
+ self._connection_state = "disconnecting"
335
+ if neutral:
336
+ try:
337
+ await self._send_trailing_neutral_if_connected()
338
+ except Exception as error: # noqa: BLE001
339
+ self._diagnostics.record_error(error, recoverable=True)
340
+ if self._report_loop is not None:
341
+ await self._report_loop.stop()
342
+ self._disconnect_event.clear()
343
+ try:
344
+ disconnect_result = await self._transport.request_disconnect()
345
+ except ClosedError as error:
346
+ disconnect_result = DisconnectRequestResult(
347
+ status="unavailable",
348
+ reason="transport_closed",
349
+ error_type=type(error).__name__,
350
+ message=str(error),
351
+ )
352
+ self._record_disconnect_request_result(disconnect_result)
353
+ if disconnect_result.status == "requested":
354
+ disconnect_closed = await self._wait_for_disconnect_request_closed()
355
+ if disconnect_closed:
356
+ self._diagnostics.record_event(
357
+ "disconnect_request_terminal",
358
+ status="closed",
359
+ )
360
+ else:
361
+ self._diagnostics.record_event(
362
+ "disconnect_request_terminal",
363
+ status="timeout",
364
+ timeout=gamepad_module.DISCONNECT_REQUEST_TIMEOUT_SECONDS,
365
+ )
366
+ await self._transport.close()
367
+ self._report_loop = None
368
+ self._is_open = False
369
+ self._connection_state = "closed"
370
+ finally:
371
+ self._close_in_progress = False
372
+
373
+ async def press(self, *buttons: Button) -> None:
374
+ """Add buttons to the current input state.
375
+
376
+ Args:
377
+ buttons: Buttons to add to the current button set.
378
+
379
+ This updates local state only and does not send an immediate input report.
380
+ """
381
+ await self._state_store.press(*buttons)
382
+
383
+ async def apply(self, state: InputState) -> None:
384
+ """Replace the current input state without immediate transmission.
385
+
386
+ Args:
387
+ state: Complete input state to commit.
388
+
389
+ This updates local state only and does not send an immediate input report.
390
+ """
391
+ await self._state_store.apply(state)
392
+
393
+ async def sticks(self, *, left: Stick | None = None, right: Stick | None = None) -> None:
394
+ """Replace one or both stick positions without immediate transmission.
395
+
396
+ Args:
397
+ left: Optional replacement for the left stick.
398
+ right: Optional replacement for the right stick.
399
+
400
+ Raises:
401
+ InvalidInputError: ``left`` or ``right`` is not a ``Stick``.
402
+
403
+ This updates local state only and does not send an immediate input report.
404
+ """
405
+ self._validate_stick("left", left)
406
+ self._validate_stick("right", right)
407
+ await self._state_store.sticks(left=left, right=right)
408
+
409
+ async def lstick(self, stick: Stick) -> None:
410
+ """Replace the left stick position without immediate transmission.
411
+
412
+ Args:
413
+ stick: Replacement for the left stick.
414
+
415
+ Raises:
416
+ InvalidInputError: ``stick`` is not a ``Stick``.
417
+
418
+ This updates local state only and does not send an immediate input report.
419
+ """
420
+ await self.sticks(left=stick)
421
+
422
+ async def rstick(self, stick: Stick) -> None:
423
+ """Replace the right stick position without immediate transmission.
424
+
425
+ Args:
426
+ stick: Replacement for the right stick.
427
+
428
+ Raises:
429
+ InvalidInputError: ``stick`` is not a ``Stick``.
430
+
431
+ This updates local state only and does not send an immediate input report.
432
+ """
433
+ await self.sticks(right=stick)
434
+
435
+ async def imu(self, *frames: IMUFrame) -> None:
436
+ """Replace IMU frames without immediate transmission.
437
+
438
+ Args:
439
+ frames: One ``IMUFrame`` to repeat across all three IMU slots, or exactly
440
+ three frames to store in order.
441
+
442
+ Raises:
443
+ InvalidInputError: The frame count is not one or three, or any value is
444
+ not an ``IMUFrame``.
445
+
446
+ This updates local IMU state only and does not send an immediate input report.
447
+ """
448
+ await self._state_store.imu(*frames)
449
+
450
+ async def release(self, *buttons: Button) -> None:
451
+ """Remove buttons from the current input state.
452
+
453
+ Args:
454
+ buttons: Buttons to remove from the current button set.
455
+
456
+ This updates local state only and does not send an immediate input report.
457
+ """
458
+ await self._state_store.release(*buttons)
459
+
460
+ async def neutral(self) -> None:
461
+ """Return local input state to ``InputState.neutral()`` without immediate transmission."""
462
+ await self._state_store.neutral()
463
+
464
+ async def tap(self, *buttons: Button, duration: float = 0.08) -> None:
465
+ """Send a short connected button action.
466
+
467
+ Args:
468
+ buttons: Buttons to press for the tap.
469
+ duration: Seconds to keep the buttons pressed before release.
470
+
471
+ Raises:
472
+ ClosedError: The gamepad is not open and cannot send input reports.
473
+
474
+ The tap sends immediate press and release input reports. The release step
475
+ removes only the buttons supplied to this call, preserving other held buttons.
476
+ """
477
+ self._require_connected_for_input()
478
+ await self.press(*buttons)
479
+ primary_error: BaseException | None = None
480
+ try:
481
+ await self._send_current_input()
482
+ if duration > 0:
483
+ await asyncio.sleep(duration)
484
+ except BaseException as error:
485
+ primary_error = error
486
+ raise
487
+ finally:
488
+ await self.release(*buttons)
489
+ try:
490
+ await self._send_current_input()
491
+ except Exception as error:
492
+ self._diagnostics.record_error(error, recoverable=True)
493
+ if primary_error is None:
494
+ raise
495
+
496
+ def status(self) -> GamepadStatus:
497
+ """Return the current gamepad status.
498
+
499
+ Returns:
500
+ GamepadStatus: Connection state, report counters, rumble bytes, and last error.
501
+ """
502
+ return GamepadStatus(
503
+ connection_state=self._connection_state,
504
+ report_counters=self._diagnostics.report_counters,
505
+ last_subcommand_id=self._diagnostics.last_subcommand_id,
506
+ raw_rumble=self._diagnostics.raw_rumble,
507
+ last_error=self._diagnostics.last_error,
508
+ )
509
+
510
+ def snapshot(self) -> InputState:
511
+ """Return the latest committed input state.
512
+
513
+ Returns:
514
+ InputState: Immutable snapshot of the current input state.
515
+ """
516
+ return self._state_store.current
517
+
518
+ def _register_transport_callbacks(self) -> None:
519
+ if self._transport is None:
520
+ return
521
+ self._transport.on_interrupt_data(self._handle_interrupt_data)
522
+ self._transport.on_control_data(self._handle_control_data)
523
+ self._transport.on_connected(self._handle_connected)
524
+ self._transport.on_disconnected(self._handle_disconnected)
525
+
526
+ async def _send_trailing_neutral_if_connected(self) -> None:
527
+ await self._state_store.neutral()
528
+ if self._report_loop is None or not self._connected_event.is_set():
529
+ return
530
+ await self._report_loop.send_current_input()
531
+
532
+ async def _send_current_input(self) -> None:
533
+ if self._report_loop is None:
534
+ msg = "gamepad is not open"
535
+ raise ClosedError(msg)
536
+ await self._report_loop.send_current_input()
537
+
538
+ def _require_subcommand_reply_sender(self) -> None:
539
+ _ = self._subcommand_reply_sender()
540
+
541
+ async def _send_subcommand_reply(self, reply: bytes) -> None:
542
+ await self._subcommand_reply_sender().send_subcommand_reply(reply)
543
+
544
+ def _subcommand_reply_sender(self) -> ReportLoop:
545
+ if self._report_loop is None:
546
+ msg = "gamepad is not open"
547
+ raise ClosedError(msg)
548
+ return self._report_loop
549
+
550
+ def _require_connected_for_input(self) -> None:
551
+ if self._report_loop is None or not self._connected_event.is_set():
552
+ msg = "gamepad is not connected"
553
+ raise ClosedError(msg)
554
+
555
+ @staticmethod
556
+ def _validate_stick(name: str, value: Stick | None) -> None:
557
+ if value is not None and not isinstance(value, Stick):
558
+ msg = f"{name} must be a Stick"
559
+ raise InvalidInputError(msg)
560
+
561
+ def _record_run_metadata(self) -> None:
562
+ key_store_exists: bool | None = None
563
+ key_store_previous_exists: bool | None = None
564
+ if self._config.key_store_path is not None:
565
+ from swbt.transport._bumble_key_store import ( # noqa: PLC0415
566
+ read_key_store_metadata,
567
+ )
568
+
569
+ key_store_metadata = read_key_store_metadata(self._config.key_store_path)
570
+ key_store_exists = key_store_metadata.exists
571
+ key_store_previous_exists = key_store_metadata.previous_exists
572
+ self._diagnostics.record_run_metadata(
573
+ adapter=self._metadata_adapter(),
574
+ key_store_exists=key_store_exists,
575
+ key_store_path=self._config.key_store_path,
576
+ key_store_previous_exists=key_store_previous_exists,
577
+ )
578
+
579
+ def _metadata_adapter(self) -> str:
580
+ if self._config.adapter is not None:
581
+ return self._config.adapter
582
+ return "custom"
583
+
584
+ def _connection_transport(self) -> HidDeviceTransport | None:
585
+ return self._transport
586
+
587
+ async def _close_neutral_for_connection_workflow(self) -> None:
588
+ await self.close(neutral=True)
589
+
590
+ async def _pair_for_connection_workflow(self, timeout: float | None) -> None: # noqa: ASYNC109
591
+ await self.pair(timeout=timeout)
592
+
593
+ def _set_connection_state(self, state: str) -> None:
594
+ self._connection_state = state
595
+
596
+ async def _wait_for_reconnect_connected_for_workflow(
597
+ self,
598
+ timeout: float | None, # noqa: ASYNC109
599
+ ) -> None:
600
+ await self._wait_for_reconnect_connected(max_wait=timeout)
601
+
602
+ async def _wait_for_disconnect_request_closed(self) -> bool:
603
+ try:
604
+ async with asyncio.timeout(gamepad_module.DISCONNECT_REQUEST_TIMEOUT_SECONDS):
605
+ await self._disconnect_event.wait()
606
+ return True
607
+ except TimeoutError:
608
+ return False
609
+
610
+ async def _wait_for_reconnect_connected(self, *, max_wait: float | None) -> None:
611
+ if max_wait is None:
612
+ await self._connected_event.wait()
613
+ return
614
+ try:
615
+ async with asyncio.timeout(max_wait):
616
+ await self._connected_event.wait()
617
+ except TimeoutError as error:
618
+ self._diagnostics.record_event(
619
+ "connection_timeout",
620
+ route="active_reconnect",
621
+ state=self._connection_state,
622
+ timeout=max_wait,
623
+ )
624
+ raise TimeoutError from error
625
+
626
+ def _record_disconnect_request_result(self, result: DisconnectRequestResult) -> None:
627
+ fields: dict[str, object] = {"status": result.status}
628
+ if result.channels:
629
+ fields["channels"] = list(result.channels)
630
+ if result.reason is not None:
631
+ fields["reason"] = result.reason
632
+ if result.error_type is not None:
633
+ fields["error_type"] = result.error_type
634
+ if result.message is not None:
635
+ fields["message"] = result.message
636
+ self._diagnostics.record_event("disconnect_request", **fields)
637
+
638
+ async def _handle_interrupt_data(self, payload: bytes) -> None:
639
+ await self._handle_output_report_data(payload)
640
+
641
+ async def _handle_control_data(self, payload: bytes) -> None:
642
+ await self._handle_output_report_data(payload)
643
+
644
+ async def _handle_output_report_data(self, payload: bytes) -> None:
645
+ try:
646
+ await self._output_report_dispatcher.dispatch(payload)
647
+ except SwbtError as error:
648
+ self._connection_state = "failed"
649
+ self._diagnostics.record_error(error, recoverable=False)
650
+
651
+ async def _handle_connected(self) -> None:
652
+ previous_state = self._connection_state
653
+ if previous_state == "advertising":
654
+ self._diagnostics.record_event(
655
+ "incoming_connection",
656
+ previous_state=previous_state,
657
+ route="incoming",
658
+ )
659
+ self._connection_state = "connected"
660
+ self._connected_event.set()
661
+ if self._report_loop is not None:
662
+ self._report_loop.start()
663
+
664
+ async def _handle_disconnected(self, reason: int | None) -> None:
665
+ self._diagnostics.record_event("disconnected", reason=reason)
666
+ self._connected_event.clear()
667
+ try:
668
+ await self._state_store.neutral()
669
+ if self._report_loop is not None:
670
+ await self._report_loop.stop()
671
+ self._report_loop = None
672
+ if self._close_in_progress:
673
+ return
674
+ if self._transport is not None and self._is_open:
675
+ await self._transport.close()
676
+ self._is_open = False
677
+ self._connection_state = "closed"
678
+ self._diagnostics.record_event(
679
+ "reconnect_disabled",
680
+ next_state=self._connection_state,
681
+ reason=reason,
682
+ )
683
+ finally:
684
+ self._disconnect_event.set()
685
+
686
+ def _ensure_transport(self) -> HidDeviceTransport:
687
+ if self._transport is None:
688
+ if self._config.adapter is None:
689
+ msg = "adapter is required when no custom transport is supplied"
690
+ raise InvalidInputError(msg)
691
+ self._transport = create_default_transport(
692
+ adapter=self._config.adapter,
693
+ device_name=self._config.device_name,
694
+ diagnostics=self._diagnostics,
695
+ key_store_path=self._config.key_store_path,
696
+ )
697
+ return self._transport