swbt-python 0.5.2__py3-none-any.whl → 0.5.4__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/interface.py CHANGED
@@ -121,11 +121,11 @@ class SwitchGamepad(ABC):
121
121
  await self._runtime.open()
122
122
 
123
123
  async def pair(self, timeout: float | None = None) -> None: # noqa: ASYNC109
124
- """Start pairing advertising and wait until the controller is ready for input.
124
+ """Start pairing and wait until the controller can accept normal input.
125
125
 
126
126
  Args:
127
- timeout: Maximum seconds for link connection and protocol initialization.
128
- ``None`` waits without a deadline.
127
+ timeout: Maximum seconds for link connection, protocol initialization,
128
+ and normal-input readiness. ``None`` waits without a deadline.
129
129
 
130
130
  Raises:
131
131
  ConnectionTimeoutError: The timeout elapsed before a connection completed.
@@ -134,15 +134,15 @@ class SwitchGamepad(ABC):
134
134
  await self._runtime.pair(timeout=timeout)
135
135
 
136
136
  async def reconnect(self, timeout: float | None = None) -> None: # noqa: ASYNC109
137
- """Reconnect with exactly one bonded peer and raise on failure.
137
+ """Reconnect with one bonded peer and wait until normal input can begin.
138
138
 
139
139
  Args:
140
- timeout: Maximum seconds for the active reconnect attempt. ``None`` uses
141
- the transport default.
140
+ timeout: Maximum seconds for the active reconnect attempt and
141
+ normal-input readiness. ``None`` uses the transport default.
142
142
 
143
143
  Raises:
144
144
  ConnectionFailedError: No single bonded peer was available or reconnect failed.
145
- ConnectionTimeoutError: The active reconnect attempt timed out.
145
+ ConnectionTimeoutError: The reconnect or normal-input readiness timed out.
146
146
  InvalidKeyStoreError: The key store cannot identify one current peer.
147
147
  """
148
148
  await self._runtime.reconnect(timeout=timeout)
@@ -154,11 +154,12 @@ class SwitchGamepad(ABC):
154
154
  """Try active reconnect with exactly one bonded peer.
155
155
 
156
156
  Args:
157
- timeout: Maximum seconds for the active reconnect attempt. ``None`` uses
158
- the transport default.
157
+ timeout: Maximum seconds for the active reconnect attempt and
158
+ normal-input readiness. ``None`` uses the transport default.
159
159
 
160
160
  Returns:
161
- Reconnect route, status, selected peer, and peer count.
161
+ Reconnect route, status, selected peer, and peer count. ``connected``
162
+ means that normal input can begin.
162
163
 
163
164
  Raises:
164
165
  InvalidKeyStoreError: The key store cannot identify one current peer.
@@ -171,7 +172,7 @@ class SwitchGamepad(ABC):
171
172
  timeout: float | None = None, # noqa: ASYNC109
172
173
  allow_pairing: bool = False,
173
174
  ) -> None:
174
- """Connect using bonded reconnect first, then optional pairing fallback.
175
+ """Connect by bonded reconnect or pairing and wait for normal-input readiness.
175
176
 
176
177
  Args:
177
178
  timeout: Maximum seconds for each connection attempt. ``None`` uses the
@@ -199,7 +200,8 @@ class SwitchGamepad(ABC):
199
200
  allow_pairing: If ``True``, run pairing when no bonded peer is available.
200
201
 
201
202
  Returns:
202
- Route and status chosen by reconnect or pairing fallback.
203
+ Route and status chosen by reconnect or pairing fallback. ``connected``
204
+ means that normal input can begin.
203
205
 
204
206
  Raises:
205
207
  InvalidKeyStoreError: The key store cannot identify one current peer.
@@ -375,7 +377,8 @@ class PeriodicSwitchGamepad(SwitchGamepad):
375
377
  diagnostics: Optional diagnostics configuration for trace output.
376
378
 
377
379
  Returns:
378
- The protocol-ready periodic controller. The caller owns its lifetime.
380
+ A periodic controller ready to accept normal input. The caller owns
381
+ its lifetime.
379
382
 
380
383
  Raises:
381
384
  ValueError: ``local_address`` is invalid.
@@ -440,7 +443,8 @@ class DirectSwitchGamepad(SwitchGamepad):
440
443
  diagnostics: Optional diagnostics configuration for trace output.
441
444
 
442
445
  Returns:
443
- The protocol-ready direct controller. The caller owns its lifetime.
446
+ A direct controller ready to accept normal input. The caller owns
447
+ its lifetime.
444
448
 
445
449
  Raises:
446
450
  ValueError: ``local_address`` is invalid.
swbt/gamepad/runtime.py CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  import asyncio
4
4
  from collections.abc import Awaitable, Callable
5
+ from contextlib import suppress
5
6
  from dataclasses import replace
7
+ from time import monotonic
6
8
  from typing import Literal
7
9
 
8
10
  import swbt.gamepad as gamepad_module
@@ -48,6 +50,8 @@ class ControllerRuntime:
48
50
  diagnostics: DiagnosticsConfig | None = None,
49
51
  reporting_mode: Literal["periodic", "direct"] = "periodic",
50
52
  transport: HidDeviceTransport | None = None,
53
+ _report_sender_monotonic_time: Callable[[], float] = monotonic,
54
+ _report_sender_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
51
55
  ) -> None:
52
56
  """Create a fully initialized gamepad runtime.
53
57
 
@@ -57,6 +61,8 @@ class ControllerRuntime:
57
61
  reporting_mode: Owner of normal input report scheduling.
58
62
  transport: Optional HID transport instance. When supplied, no Bumble
59
63
  transport is created by the constructor.
64
+ _report_sender_monotonic_time: Internal holdoff clock override for tests.
65
+ _report_sender_sleep: Internal holdoff sleep override for tests.
60
66
 
61
67
  Raises:
62
68
  InvalidInputError: ``adapter`` is omitted for the default transport.
@@ -68,6 +74,8 @@ class ControllerRuntime:
68
74
  self._reporting_mode = reporting_mode
69
75
  self._transport = transport
70
76
  self._transport_was_injected = transport is not None
77
+ self._report_sender_monotonic_time = _report_sender_monotonic_time
78
+ self._report_sender_sleep = _report_sender_sleep
71
79
  self._pairing_profile: PairingProfile | None = None
72
80
  self._state_store = InputStateStore()
73
81
  self._diagnostics = DiagnosticsRecorder(
@@ -94,6 +102,7 @@ class ControllerRuntime:
94
102
  self._report_loop: ReportLoop | None = None
95
103
  self._report_sender: ReportSender | None = None
96
104
  self._protocol_handshake: ProtocolHandshake | None = None
105
+ self._input_readiness_task: asyncio.Task[None] | None = None
97
106
  self._input_operation_lock = asyncio.Lock()
98
107
  self._lifecycle_lock = asyncio.Lock()
99
108
  self._connected_event = asyncio.Event()
@@ -139,6 +148,8 @@ class ControllerRuntime:
139
148
  ),
140
149
  session=self._protocol_session,
141
150
  diagnostics=self._diagnostics,
151
+ monotonic_time=self._report_sender_monotonic_time,
152
+ _sleep=self._report_sender_sleep,
142
153
  )
143
154
  self._report_sender = report_sender
144
155
  self._connection_state = "opened"
@@ -161,14 +172,14 @@ class ControllerRuntime:
161
172
  self._configured_device_info_bluetooth_address = None
162
173
 
163
174
  async def pair(self, timeout: float | None = None) -> None: # noqa: ASYNC109
164
- """Start pairing advertising and wait until the controller is ready for input.
175
+ """Start pairing and wait until the controller can accept normal input.
165
176
 
166
177
  Args:
167
- timeout: Maximum seconds for link connection and protocol initialization.
168
- ``None`` waits without a deadline.
178
+ timeout: Maximum seconds for link connection, protocol initialization,
179
+ and normal-input readiness. ``None`` waits without a deadline.
169
180
 
170
181
  Raises:
171
- ConnectionTimeoutError: The deadline elapsed before protocol readiness.
182
+ ConnectionTimeoutError: The deadline elapsed before normal-input readiness.
172
183
  ConnectionFailedError: Protocol initialization or the link failed.
173
184
  ClosedError: The transport was unavailable after opening.
174
185
  """
@@ -186,7 +197,7 @@ class ControllerRuntime:
186
197
  raise ClosedError(msg)
187
198
  await self._transport.start_advertising()
188
199
  self._configure_device_info_bluetooth_address(self._transport)
189
- await self._wait_for_protocol_ready(None)
200
+ await self._wait_for_input_ready(None)
190
201
 
191
202
  try:
192
203
  if timeout is None:
@@ -194,6 +205,9 @@ class ControllerRuntime:
194
205
  else:
195
206
  async with asyncio.timeout(timeout):
196
207
  await advertise_and_wait()
208
+ except asyncio.CancelledError:
209
+ await self._stop_input_readiness_wait()
210
+ raise
197
211
  except TimeoutError as error:
198
212
  msg = "connection timed out"
199
213
  connection_error = ConnectionTimeoutError(msg)
@@ -204,7 +218,7 @@ class ControllerRuntime:
204
218
  ),
205
219
  player_lights=_format_optional_byte(self._protocol_session.state.player_lights),
206
220
  report_mode=_format_optional_byte(self._protocol_session.state.report_mode),
207
- stage="protocol_initialization",
221
+ stage=self._connection_readiness_stage(),
208
222
  state=self._connection_state,
209
223
  timeout=timeout,
210
224
  )
@@ -216,15 +230,15 @@ class ControllerRuntime:
216
230
  raise
217
231
 
218
232
  async def reconnect(self, timeout: float | None = None) -> None: # noqa: ASYNC109
219
- """Reconnect with exactly one bonded peer and raise on failure.
233
+ """Reconnect with one bonded peer and wait until normal input can begin.
220
234
 
221
235
  Args:
222
- timeout: Maximum seconds for the active reconnect attempt. ``None`` uses
223
- the transport default.
236
+ timeout: Maximum seconds for the active reconnect attempt and
237
+ normal-input readiness. ``None`` uses the transport default.
224
238
 
225
239
  Raises:
226
240
  ConnectionFailedError: No single bonded peer was available or reconnect failed.
227
- ConnectionTimeoutError: The active reconnect attempt timed out.
241
+ ConnectionTimeoutError: The reconnect or normal-input readiness timed out.
228
242
  """
229
243
  result = await self.try_reconnect(timeout=timeout)
230
244
  self._raise_if_connection_failed(result)
@@ -236,8 +250,8 @@ class ControllerRuntime:
236
250
  """Try active reconnect with exactly one bonded peer.
237
251
 
238
252
  Args:
239
- timeout: Maximum seconds for the active reconnect attempt. ``None`` uses
240
- the transport default.
253
+ timeout: Maximum seconds for the active reconnect attempt and
254
+ normal-input readiness. ``None`` uses the transport default.
241
255
 
242
256
  Returns:
243
257
  ConnectionResult: Reconnect route, status, selected peer, and peer count.
@@ -308,6 +322,7 @@ class ControllerRuntime:
308
322
  )
309
323
  except asyncio.CancelledError as error:
310
324
  if _current_task_is_cancelling():
325
+ await self._stop_input_readiness_wait()
311
326
  raise
312
327
  return await self._record_active_reconnect_transport_error(
313
328
  error,
@@ -406,6 +421,7 @@ class ControllerRuntime:
406
421
  except Exception as error: # noqa: BLE001
407
422
  self._diagnostics.record_error(error, recoverable=True)
408
423
  await self._stop_protocol_handshake()
424
+ await self._stop_input_readiness_wait()
409
425
  if self._report_loop is not None:
410
426
  await self._report_loop.stop()
411
427
  self._disconnect_event.clear()
@@ -820,7 +836,7 @@ class ControllerRuntime:
820
836
 
821
837
  async def _wait_for_reconnect_connected(self, *, max_wait: float | None) -> None:
822
838
  try:
823
- await self._wait_for_protocol_ready(max_wait)
839
+ await self._wait_for_input_ready(max_wait)
824
840
  except TimeoutError as error:
825
841
  self._diagnostics.record_event(
826
842
  "connection_timeout",
@@ -830,13 +846,13 @@ class ControllerRuntime:
830
846
  player_lights=_format_optional_byte(self._protocol_session.state.player_lights),
831
847
  report_mode=_format_optional_byte(self._protocol_session.state.report_mode),
832
848
  route="active_reconnect",
833
- stage="protocol_initialization",
849
+ stage=self._connection_readiness_stage(),
834
850
  state=self._connection_state,
835
851
  timeout=max_wait,
836
852
  )
837
853
  raise TimeoutError from error
838
854
 
839
- async def _wait_for_protocol_ready(self, max_wait: float | None) -> None:
855
+ async def _wait_for_input_ready(self, max_wait: float | None) -> None:
840
856
  if max_wait is None:
841
857
  await self._connection_attempt_event.wait()
842
858
  else:
@@ -844,9 +860,14 @@ class ControllerRuntime:
844
860
  await self._connection_attempt_event.wait()
845
861
  if self._connected_event.is_set():
846
862
  return
847
- msg = "protocol initialization failed"
863
+ msg = "connection initialization failed"
848
864
  raise ConnectionFailedError(msg) from self._connection_attempt_error
849
865
 
866
+ def _connection_readiness_stage(self) -> str:
867
+ if self._protocol_session.state.protocol_ready:
868
+ return "input_readiness"
869
+ return "protocol_initialization"
870
+
850
871
  def _record_disconnect_request_result(self, result: DisconnectRequestResult) -> None:
851
872
  fields: dict[str, object] = {"status": result.status}
852
873
  if result.channels:
@@ -917,6 +938,15 @@ class ControllerRuntime:
917
938
  if handshake is not None:
918
939
  await handshake.stop()
919
940
 
941
+ async def _stop_input_readiness_wait(self) -> None:
942
+ task = self._input_readiness_task
943
+ self._input_readiness_task = None
944
+ if task is None or task is asyncio.current_task():
945
+ return
946
+ task.cancel()
947
+ with suppress(asyncio.CancelledError):
948
+ await task
949
+
920
950
  def _start_periodic_report_loop(self) -> None:
921
951
  if self._transport is None or self._report_loop is not None:
922
952
  return
@@ -950,12 +980,7 @@ class ControllerRuntime:
950
980
  return
951
981
  if self._connected_event.is_set():
952
982
  return
953
- self._connection_state = "connected"
954
- self._connected_event.set()
955
- self._connection_attempt_event.set()
956
983
  self._protocol_handshake = None
957
- if self._reporting_mode == "periodic":
958
- self._start_periodic_report_loop()
959
984
  session_state = self._protocol_session.state
960
985
  self._diagnostics.record_event(
961
986
  "protocol_ready",
@@ -965,6 +990,50 @@ class ControllerRuntime:
965
990
  route=self._connection_route,
966
991
  observed_subcommands=_format_subcommands(session_state.observed_subcommands),
967
992
  )
993
+ if self._reporting_mode == "direct":
994
+ self._publish_input_ready()
995
+ return
996
+ if self._input_readiness_task is not None:
997
+ return
998
+ self._input_readiness_task = asyncio.create_task(
999
+ self._complete_periodic_input_readiness(),
1000
+ name="swbt-input-readiness",
1001
+ )
1002
+
1003
+ async def _complete_periodic_input_readiness(self) -> None:
1004
+ task = asyncio.current_task()
1005
+ sender = self._require_report_sender()
1006
+ try:
1007
+ await sender.wait_until_automatic_input_ready()
1008
+ if (
1009
+ self._close_in_progress
1010
+ or self._connection_attempt_event.is_set()
1011
+ or self._report_sender is not sender
1012
+ ):
1013
+ return
1014
+ self._start_periodic_report_loop()
1015
+ self._publish_input_ready()
1016
+ except asyncio.CancelledError:
1017
+ raise
1018
+ except Exception as error: # noqa: BLE001
1019
+ if not self._close_in_progress:
1020
+ self._record_protocol_initialization_failure(error)
1021
+ finally:
1022
+ if self._input_readiness_task is task:
1023
+ self._input_readiness_task = None
1024
+
1025
+ def _publish_input_ready(self) -> None:
1026
+ if self._connected_event.is_set() or self._connection_attempt_event.is_set():
1027
+ return
1028
+ self._connection_state = "connected"
1029
+ self._connected_event.set()
1030
+ self._connection_attempt_event.set()
1031
+ self._diagnostics.record_event(
1032
+ "input_ready",
1033
+ profile_kind=self._controller_profile.kind.value,
1034
+ reporting_mode=self._reporting_mode,
1035
+ route=self._connection_route,
1036
+ )
968
1037
 
969
1038
  async def _fail_protocol_initialization(self, error: Exception) -> None:
970
1039
  if self._protocol_handshake is not None:
@@ -987,7 +1056,7 @@ class ControllerRuntime:
987
1056
  profile_kind=self._controller_profile.kind.value,
988
1057
  report_mode=_format_optional_byte(self._protocol_session.state.report_mode),
989
1058
  route=self._connection_route,
990
- stage="protocol_initialization",
1059
+ stage=self._connection_readiness_stage(),
991
1060
  )
992
1061
  self._diagnostics.record_error(error, recoverable=False)
993
1062
 
@@ -999,7 +1068,7 @@ class ControllerRuntime:
999
1068
  and not self._connection_attempt_event.is_set()
1000
1069
  and not self._close_in_progress
1001
1070
  ):
1002
- msg = "disconnected before protocol initialization completed"
1071
+ msg = "disconnected before connection initialization completed"
1003
1072
  handshake_failure = ConnectionFailedError(msg)
1004
1073
  if self._protocol_handshake is not None:
1005
1074
  self._protocol_handshake.fail(handshake_failure)
@@ -1014,6 +1083,7 @@ class ControllerRuntime:
1014
1083
  self._protocol_handshake = None
1015
1084
  else:
1016
1085
  await self._stop_protocol_handshake()
1086
+ await self._stop_input_readiness_wait()
1017
1087
  if self._report_loop is not None:
1018
1088
  await self._report_loop.stop()
1019
1089
  self._report_loop = None
swbt/report_loop.py CHANGED
@@ -34,6 +34,7 @@ class ReportSender:
34
34
  diagnostics: DiagnosticsRecorder | None = None,
35
35
  clock_ns: Callable[[], int] = monotonic_ns,
36
36
  monotonic_time: Callable[[], float] = monotonic,
37
+ _sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
37
38
  ) -> None:
38
39
  """Create a session-scoped serialized report sender."""
39
40
  self._transport = transport
@@ -42,6 +43,7 @@ class ReportSender:
42
43
  self._diagnostics = diagnostics
43
44
  self._clock_ns = clock_ns
44
45
  self._monotonic_time = monotonic_time
46
+ self._sleep = _sleep
45
47
  self._timer = 0
46
48
  self._send_lock = asyncio.Lock()
47
49
  self._automatic_input_holdoff_until = 0.0
@@ -80,6 +82,15 @@ class ReportSender:
80
82
  await self._send_input_locked(state, reason=reason)
81
83
  return True
82
84
 
85
+ async def wait_until_automatic_input_ready(self) -> None:
86
+ """Wait until automatic input is no longer held off by an accepted reply."""
87
+ while True:
88
+ async with self._send_lock:
89
+ remaining = self._automatic_input_holdoff_until - self._monotonic_time()
90
+ if remaining <= 0:
91
+ return
92
+ await self._sleep(remaining)
93
+
83
94
  async def send_subcommand_reply(self, build_report: ReplyBuilder) -> bytes:
84
95
  """Build and send a subcommand reply under the shared send lock."""
85
96
  async with self._send_lock:
@@ -1,33 +1,17 @@
1
- """ACL queue drain helper for Bumble HID transport."""
1
+ """ACL queue drain helper for the pinned Bumble HID transport."""
2
2
 
3
- from collections.abc import Awaitable
3
+ from typing import Any, cast
4
4
 
5
5
 
6
6
  async def drain_bumble_acl_queue(l2cap_channel: object) -> None:
7
- """Wait until Bumble reports no pending ACL packets for the channel."""
8
- connection = getattr(l2cap_channel, "connection", None)
9
- connection_handle = getattr(connection, "handle", None)
10
- acl_packet_queue = getattr(connection, "acl_packet_queue", None)
11
- if acl_packet_queue is None and isinstance(connection_handle, int):
12
- device = getattr(connection, "device", None)
13
- host = getattr(device, "host", None)
14
- get_data_packet_queue = getattr(host, "get_data_packet_queue", None)
15
- if callable(get_data_packet_queue):
16
- acl_packet_queue = get_data_packet_queue(connection_handle)
17
- drain = getattr(acl_packet_queue, "drain", None)
18
- if not isinstance(connection_handle, int) or not callable(drain):
7
+ """Wait for the pinned Bumble connection's pending ACL packets to complete."""
8
+ channel = cast("Any", l2cap_channel)
9
+ connection = channel.connection
10
+ acl_packet_queue = connection.device.host.get_data_packet_queue(connection.handle)
11
+ if acl_packet_queue is None:
19
12
  return
20
13
  try:
21
- last_pending: int | None = None
22
- while True:
23
- pending = getattr(acl_packet_queue, "pending", 0)
24
- if not isinstance(pending, int) or pending <= 0 or pending == last_pending:
25
- return
26
- last_pending = pending
27
- drain_result = drain(connection_handle)
28
- if isinstance(drain_result, Awaitable):
29
- await drain_result
30
- continue
31
- return
14
+ await acl_packet_queue.drain(connection.handle)
32
15
  except ValueError:
16
+ # Bumble raises when the queue has no state for a channel that was already drained.
33
17
  return
@@ -9,11 +9,9 @@ from swbt.diagnostics import DiagnosticsRecorder
9
9
  from swbt.errors import InvalidKeyStoreError
10
10
  from swbt.transport._pairing_profile import KeyStoreNamespaces, PairingProfile
11
11
 
12
- PREVIOUS_NAMESPACE_PREFIX = "swbt.previous::"
13
-
14
12
 
15
13
  class _PairingProfileKeyStore:
16
- """Bumble-compatible profile key store with one previous generation."""
14
+ """Bumble-compatible profile key store for the current namespace."""
17
15
 
18
16
  def __init__(
19
17
  self,
@@ -24,7 +22,6 @@ class _PairingProfileKeyStore:
24
22
  self._profile_path = Path(profile_path)
25
23
  self._namespace = namespace if isinstance(namespace, str) else None
26
24
  self._namespace_resolver = None if isinstance(namespace, str) else namespace
27
- self.last_update_previous_saved = False
28
25
 
29
26
  def _resolve_namespace(self) -> str:
30
27
  namespace = (
@@ -36,16 +33,9 @@ class _PairingProfileKeyStore:
36
33
  return namespace
37
34
 
38
35
  async def update(self, name: str, keys: object) -> None:
39
- """Write current keys and keep the overwritten current value as previous."""
36
+ """Replace the current namespace with one peer's keys."""
40
37
  current_store = self._current_store()
41
38
  db, current_key_map = await current_store.load()
42
- previous_namespace = self._previous_namespace(current_store)
43
- previous_key_map = copy.deepcopy(current_key_map)
44
- self.last_update_previous_saved = bool(previous_key_map)
45
- if previous_key_map:
46
- db[previous_namespace] = previous_key_map
47
- else:
48
- db.pop(previous_namespace, None)
49
39
  current_key_map.clear()
50
40
  current_key_map[name] = cast("Any", keys).to_dict()
51
41
  await current_store.save(db)
@@ -84,10 +74,6 @@ class _PairingProfileKeyStore:
84
74
  adapter_default=self._namespace_resolver is not None,
85
75
  )
86
76
 
87
- @staticmethod
88
- def _previous_namespace(current_store: "_PairingProfileNamespaceStore") -> str:
89
- return f"{PREVIOUS_NAMESPACE_PREFIX}{current_store.namespace}"
90
-
91
77
 
92
78
  class _PairingProfileNamespaceStore:
93
79
  """JsonKeyStore-compatible view over one profile namespace map."""
@@ -169,20 +155,16 @@ class _DiagnosticKeyStore:
169
155
  try:
170
156
  await cast("Any", self._key_store).update(name, keys)
171
157
  except Exception as error:
172
- fields = self._generation_fields()
173
158
  self._diagnostics.record_event(
174
159
  "key_store_update",
175
- **fields,
176
160
  error_type=type(error).__name__,
177
161
  message=str(error),
178
162
  peer_address=name,
179
163
  status="failed",
180
164
  )
181
165
  raise
182
- fields = self._generation_fields()
183
166
  self._diagnostics.record_event(
184
167
  "key_store_update",
185
- **fields,
186
168
  peer_address=name,
187
169
  status="succeeded",
188
170
  )
@@ -209,11 +191,3 @@ class _DiagnosticKeyStore:
209
191
 
210
192
  def __getattr__(self, name: str) -> object:
211
193
  return getattr(self._key_store, name)
212
-
213
- def _generation_fields(self) -> dict[str, object]:
214
- if not isinstance(self._key_store, _PairingProfileKeyStore):
215
- return {}
216
- return {
217
- "generation": "current",
218
- "previous_saved": self._key_store.last_update_previous_saved,
219
- }
@@ -25,20 +25,19 @@ class ConnectionDiagnostics:
25
25
  self._record_event = record_event
26
26
 
27
27
  def register(self, connection: object) -> None:
28
- """Register all connection diagnostics callbacks supported by the connection."""
29
- on_event = getattr(connection, "on", None)
30
- if not callable(on_event):
31
- return
28
+ """Register all diagnostics callbacks provided by Bumble 0.0.233."""
29
+ connection_with_attrs = cast("Any", connection)
30
+ on_event = connection_with_attrs.on
32
31
  on_event(
33
- getattr(connection, "EVENT_DISCONNECTION", "disconnection"),
32
+ connection_with_attrs.EVENT_DISCONNECTION,
34
33
  self._handle_disconnection,
35
34
  )
36
35
  on_event(
37
- getattr(connection, "EVENT_CLASSIC_PAIRING", "classic_pairing"),
36
+ connection_with_attrs.EVENT_CLASSIC_PAIRING,
38
37
  lambda *_args: self._record_event("classic_pairing", adapter=self._adapter),
39
38
  )
40
39
  on_event(
41
- getattr(connection, "EVENT_CLASSIC_PAIRING_FAILURE", "classic_pairing_failure"),
40
+ connection_with_attrs.EVENT_CLASSIC_PAIRING_FAILURE,
42
41
  lambda reason=None, *_args: self._record_event(
43
42
  "classic_pairing_failure",
44
43
  adapter=self._adapter,
@@ -46,15 +45,15 @@ class ConnectionDiagnostics:
46
45
  ),
47
46
  )
48
47
  on_event(
49
- getattr(connection, "EVENT_PAIRING_START", "pairing_start"),
48
+ connection_with_attrs.EVENT_PAIRING_START,
50
49
  lambda *_args: self._record_event("pairing_start", adapter=self._adapter),
51
50
  )
52
51
  on_event(
53
- getattr(connection, "EVENT_PAIRING", "pairing"),
52
+ connection_with_attrs.EVENT_PAIRING,
54
53
  lambda keys=None, *_args: self._record_pairing_complete(connection, keys),
55
54
  )
56
55
  on_event(
57
- getattr(connection, "EVENT_PAIRING_FAILURE", "pairing_failure"),
56
+ connection_with_attrs.EVENT_PAIRING_FAILURE,
58
57
  lambda reason=None, *_args: self._record_event(
59
58
  "pairing_failure",
60
59
  adapter=self._adapter,
@@ -62,15 +61,11 @@ class ConnectionDiagnostics:
62
61
  ),
63
62
  )
64
63
  on_event(
65
- getattr(connection, "EVENT_CONNECTION_AUTHENTICATION", "connection_authentication"),
64
+ connection_with_attrs.EVENT_CONNECTION_AUTHENTICATION,
66
65
  lambda *_args: self._record_connection_authentication(connection),
67
66
  )
68
67
  on_event(
69
- getattr(
70
- connection,
71
- "EVENT_CONNECTION_AUTHENTICATION_FAILURE",
72
- "connection_authentication_failure",
73
- ),
68
+ connection_with_attrs.EVENT_CONNECTION_AUTHENTICATION_FAILURE,
74
69
  lambda error=None, *_args: self._record_event(
75
70
  "connection_authentication_failure",
76
71
  adapter=self._adapter,
@@ -78,19 +73,11 @@ class ConnectionDiagnostics:
78
73
  ),
79
74
  )
80
75
  on_event(
81
- getattr(
82
- connection,
83
- "EVENT_CONNECTION_ENCRYPTION_CHANGE",
84
- "connection_encryption_change",
85
- ),
76
+ connection_with_attrs.EVENT_CONNECTION_ENCRYPTION_CHANGE,
86
77
  lambda *_args: self._record_connection_encryption_change(connection),
87
78
  )
88
79
  on_event(
89
- getattr(
90
- connection,
91
- "EVENT_CONNECTION_ENCRYPTION_FAILURE",
92
- "connection_encryption_failure",
93
- ),
80
+ connection_with_attrs.EVENT_CONNECTION_ENCRYPTION_FAILURE,
94
81
  lambda error=None, *_args: self._record_event(
95
82
  "connection_encryption_failure",
96
83
  adapter=self._adapter,
@@ -98,23 +85,19 @@ class ConnectionDiagnostics:
98
85
  ),
99
86
  )
100
87
  on_event(
101
- getattr(
102
- connection,
103
- "EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH",
104
- "connection_encryption_key_refresh",
105
- ),
88
+ connection_with_attrs.EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH,
106
89
  lambda *_args: self._record_connection_encryption_refresh(connection),
107
90
  )
108
91
  on_event(
109
- getattr(connection, "EVENT_LINK_KEY", "link_key"),
92
+ connection_with_attrs.EVENT_LINK_KEY,
110
93
  lambda *_args: self._record_event("link_key_available", adapter=self._adapter),
111
94
  )
112
95
  on_event(
113
- getattr(connection, "EVENT_MODE_CHANGE", "mode_change"),
96
+ connection_with_attrs.EVENT_MODE_CHANGE,
114
97
  lambda *_args: self._record_classic_mode_change(connection),
115
98
  )
116
99
  on_event(
117
- getattr(connection, "EVENT_MODE_CHANGE_FAILURE", "mode_change_failure"),
100
+ connection_with_attrs.EVENT_MODE_CHANGE_FAILURE,
118
101
  lambda status=None, *_args: self._record_event(
119
102
  "classic_mode_change_failure",
120
103
  adapter=self._adapter,
@@ -179,7 +162,8 @@ def register_connection_request_bridge(
179
162
  record_event: EventRecorder,
180
163
  ) -> None:
181
164
  """Wrap Bumble's connection request callback while avoiding deprecated sync APIs."""
182
- original_connection_request = cast("Any", device).on_connection_request
165
+ device_with_attrs = cast("Any", device)
166
+ original_connection_request = device_with_attrs.on_connection_request
183
167
 
184
168
  def on_connection_request(
185
169
  bd_addr: object,
@@ -201,7 +185,6 @@ def register_connection_request_bridge(
201
185
  link_type,
202
186
  )
203
187
 
204
- device_with_attrs = cast("Any", device)
205
188
  device_with_attrs.on_connection_request = on_connection_request
206
189
  replace_host_connection_request_listener(
207
190
  device,
@@ -267,24 +250,18 @@ def call_connection_request_without_deprecated_sync_command(
267
250
  link_type: int,
268
251
  ) -> None:
269
252
  """Run Bumble's connection request handler without its deprecated sync helper."""
270
- host = getattr(device, "host", None)
271
- send_async_command = getattr(host, "send_async_command", None)
272
- if host is None or not callable(send_async_command):
273
- connection_request(bd_addr, class_of_device, link_type)
274
- return
253
+ device_with_attrs = cast("Any", device)
254
+ host = device_with_attrs.host
275
255
 
276
256
  from bumble import utils # noqa: PLC0415
277
257
 
278
258
  missing = object()
279
- host_dict = getattr(host, "__dict__", None)
280
- previous_instance_attr = (
281
- host_dict.get("send_command_sync", missing) if isinstance(host_dict, dict) else missing
282
- )
259
+ previous_instance_attr = host.__dict__.get("send_command_sync", missing)
283
260
 
284
261
  def send_command_sync(command: object) -> None:
285
- utils.AsyncRunner.spawn(send_async_command(command))
262
+ utils.AsyncRunner.spawn(host.send_async_command(command))
286
263
 
287
- host_with_attrs = cast("Any", host)
264
+ host_with_attrs = host
288
265
  host_with_attrs.send_command_sync = send_command_sync
289
266
  try:
290
267
  connection_request(bd_addr, class_of_device, link_type)
@@ -300,12 +277,9 @@ def replace_host_connection_request_listener(
300
277
  original_connection_request: Callable[[object, int, int], None],
301
278
  replacement_connection_request: Callable[[object, int, int], None],
302
279
  ) -> None:
303
- host = getattr(device, "host", None)
304
- remove_listener = getattr(host, "remove_listener", None)
305
- on = getattr(host, "on", None)
306
- if not callable(remove_listener) or not callable(on):
307
- return
280
+ device_with_attrs = cast("Any", device)
281
+ host = device_with_attrs.host
308
282
 
309
283
  with suppress(KeyError, ValueError):
310
- remove_listener("connection_request", original_connection_request)
311
- on("connection_request", replacement_connection_request)
284
+ host.remove_listener("connection_request", original_connection_request)
285
+ host.on("connection_request", replacement_connection_request)
@@ -16,7 +16,7 @@ _ADDRESS_PATTERN = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
16
16
  _RESERVED_INQUIRY_LAP_MIN = 0x9E8B00
17
17
  _RESERVED_INQUIRY_LAP_MAX = 0x9E8B3F
18
18
  _PROFILE_FORMAT = "swbt.profile"
19
- _PROFILE_SCHEMA_VERSION = 1
19
+ _PROFILE_SCHEMA_VERSION = 2
20
20
  _PROFILE_LOCAL_ADDRESS_IDENTITY_KIND = "exp-local-address"
21
21
  _PROFILE_ADAPTER_DEFAULT_IDENTITY_KIND = "adapter-default"
22
22
  _CONTROLLER_KINDS_BY_PROFILE_VALUE: dict[str, ControllerKind] = {
@@ -79,7 +79,7 @@ class LocalAddress:
79
79
 
80
80
  @dataclass(frozen=True)
81
81
  class PairingProfile:
82
- """Validated version 1 envelope for one controller shape and its pairing data."""
82
+ """Validated version 2 envelope for one controller shape and its pairing data."""
83
83
 
84
84
  local_address: LocalAddress | None
85
85
  key_store_namespaces: KeyStoreNamespaces
@@ -97,14 +97,7 @@ class PairingProfile:
97
97
  address = None if local_address is None else str(local_address)
98
98
  profile = cls(
99
99
  local_address=local_address,
100
- key_store_namespaces=(
101
- {}
102
- if address is None
103
- else {
104
- address: {},
105
- f"swbt.previous::{address}": {},
106
- }
107
- ),
100
+ key_store_namespaces=({} if address is None else {address: {}}),
108
101
  controller_kind=controller_kind,
109
102
  )
110
103
  target = Path(path)
@@ -200,7 +193,10 @@ class PairingProfile:
200
193
  if payload.get("format") != _PROFILE_FORMAT:
201
194
  _invalid_profile("profile format is unsupported")
202
195
  if payload.get("schema_version") != _PROFILE_SCHEMA_VERSION:
203
- _invalid_profile("profile schema_version is unsupported")
196
+ _invalid_profile(
197
+ "profile schema_version is unsupported; create a new schema v2 profile "
198
+ "and pair again"
199
+ )
204
200
  controller_kind_value = payload.get("controller_kind")
205
201
  if not isinstance(controller_kind_value, str):
206
202
  _invalid_profile("profile controller_kind is unsupported")
swbt/transport/bumble.py CHANGED
@@ -132,6 +132,12 @@ class _BumbleHidRuntime(Protocol):
132
132
  def send_data(self, data: bytes) -> None:
133
133
  """Send an interrupt-channel HID data message."""
134
134
 
135
+ def register_set_report_cb(
136
+ self,
137
+ callback: Callable[[int, int, int, bytes], _BumbleGetSetStatus],
138
+ ) -> None:
139
+ """Register a HID SET_REPORT callback."""
140
+
135
141
  async def connect_control_channel(self) -> None:
136
142
  """Request control-channel L2CAP connection."""
137
143
 
@@ -455,10 +461,6 @@ class BumbleHidTransport:
455
461
  self._register_set_report_callback(hid_device)
456
462
 
457
463
  def _register_set_report_callback(self, hid_device: _BumbleHidRuntime) -> None:
458
- register_set_report = getattr(hid_device, "register_set_report_cb", None)
459
- if not callable(register_set_report):
460
- return
461
-
462
464
  def on_set_report(
463
465
  report_id: int,
464
466
  report_type: int,
@@ -475,7 +477,7 @@ class BumbleHidTransport:
475
477
  return _BumbleGetSetStatus(status=HID_GET_SET_UNSUPPORTED_REQUEST)
476
478
  return _BumbleGetSetStatus(status=HID_GET_SET_SUCCESS)
477
479
 
478
- register_set_report(on_set_report)
480
+ hid_device.register_set_report_cb(on_set_report)
479
481
 
480
482
  def _register_device_callbacks(self, device: _BumbleDeviceRuntime) -> None:
481
483
  device.on(device.EVENT_CONNECTION, self._handle_device_connection)
@@ -840,8 +842,7 @@ def _device_info_bluetooth_address_from_bumble_address(address: object) -> bytes
840
842
  """Return Device Info address bytes from a Bumble address object."""
841
843
  if address is None:
842
844
  return None
843
- to_string = getattr(address, "to_string", None)
844
- address_text = str(to_string(False)) if callable(to_string) else str(address)
845
+ address_text = str(cast("Any", address).to_string(False))
845
846
  address_text = address_text.split("/", 1)[0]
846
847
  parts = address_text.split(":")
847
848
  if len(parts) != 6 or any(len(part) != 2 for part in parts):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swbt-python
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Python library for presenting an NX-compatible virtual Bluetooth HID input device.
5
5
  Keywords: bluetooth,bluetooth-hid,controller,gamepad,hid,nx
6
6
  Author: niart120
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Libraries
20
20
  Classifier: Typing :: Typed
21
- Requires-Dist: bumble>=0.0.230,<0.0.231
21
+ Requires-Dist: bumble==0.0.233
22
22
  Maintainer: niart120
23
23
  Maintainer-email: niart120 <38847256+niart120@users.noreply.github.com>
24
24
  Requires-Python: >=3.12
@@ -7,10 +7,10 @@ swbt/gamepad/_config.py,sha256=VjYrR4erPhEohjZ1Pw5ElxryNEa8dRfsKhz2EsjpEUs,2050
7
7
  swbt/gamepad/connection.py,sha256=8EUeKYvdV_n1OIq11MFAVNhKmIbJHUoijaK6xUx6Y4g,753
8
8
  swbt/gamepad/constants.py,sha256=gDesRFvVxZ3SMSiP4qzd42em_Dt9M5hIu3-2exgKEus,84
9
9
  swbt/gamepad/controllers.py,sha256=G6EMi82F_36wSi2pm5QMFLJSZSakRogVcDeygqJJ8gI,7487
10
- swbt/gamepad/interface.py,sha256=quHOOj5nWzBz_W3qYMBEWglo-3zR_I9zxNqlA7HznGI,17525
10
+ swbt/gamepad/interface.py,sha256=HWABzdzrVPAumTZPOrUjiku-mKvAPIhqTE-pvqAIgSU,17793
11
11
  swbt/gamepad/output.py,sha256=NeOp-NwYshEkuelge_POwk_i7orqOQc4SXm9t9Njs1s,5361
12
12
  swbt/gamepad/protocol_handshake.py,sha256=9vAdE0PIpSpWk9oXhStOTq0XxDlE8DejE1SLBh21rx4,6217
13
- swbt/gamepad/runtime.py,sha256=7QrhoqyHx5jy9cjnlNUlJ4xxnQp5_vxfE2SVGZ1Dvtk,44102
13
+ swbt/gamepad/runtime.py,sha256=HVuhQxPtpVpcyxVo2RnWOM5Vkmn2kc9NjhJ9PPWwG4o,47170
14
14
  swbt/gamepad/transport_factory.py,sha256=0eiC-HqtNG0CJuHR8TlSPWtpInW03A4ojnBccqpryN0,921
15
15
  swbt/imu.py,sha256=sT1_MsYECQlQ-pSfayD8gojD9l3w9zEtOI3xP1IovQM,4237
16
16
  swbt/input.py,sha256=7gv75lzrcmoV8Sijz7f1pvsheRrmUi_TCIMW3DjUVjw,22275
@@ -30,24 +30,24 @@ swbt/protocol/session.py,sha256=SVkF0jaJPbnKCAWswRQsrdoCxZwjHPEMz4lktVBcXc4,4811
30
30
  swbt/protocol/spi.py,sha256=buKyNYYOHOSYbpVh3Wa2968FL4VIO_43QcSXNowNfJs,2594
31
31
  swbt/protocol/subcommand.py,sha256=NRtp9-lB8IBimC0kO8oyJnel_qSSPx65XlmGWTARB0E,8662
32
32
  swbt/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
33
- swbt/report_loop.py,sha256=jEnwlxdWQgPwbIXvJfyhgMaJdrGYLNYzlZNotOMKiKw,7853
33
+ swbt/report_loop.py,sha256=zGaKStCsupzkmSTF8YZOL9H2DbH1Nf15yHtI3POJiTY,8350
34
34
  swbt/state_store.py,sha256=jE1TeNpqynbVpxFp0Xt3m1Dfl1LUKR-uqV4f14qntuM,1402
35
35
  swbt/transport/__init__.py,sha256=b_Vqsg0k_g7XclN9XHQrcJxqCwGIklxG5NqzISal4_I,68
36
36
  swbt/transport/_adapter_identity.py,sha256=xOECQBlGAZJoyKrfxrIPt88kXoksHWmWzzVo1-x5XmA,6621
37
- swbt/transport/_bumble_acl.py,sha256=RnueTwEgAd0w3YgxnXkw0AKQXMeECIRope96W-xQ1K8,1425
37
+ swbt/transport/_bumble_acl.py,sha256=tZalNxNSKEsTB6WD4wEOyqEY-Td6s5wg2s_3Ve5UOqI,653
38
38
  swbt/transport/_bumble_hidp.py,sha256=mL_eT-otopHzg5G0p7uhqTYsqSuDwdXAiAwakQiHbCY,835
39
- swbt/transport/_bumble_key_store.py,sha256=j5fQtmnxlVk6DBPvdu7LIqu2-QCyGrvs1WoZZIj1Z-E,7986
40
- swbt/transport/_bumble_lifecycle.py,sha256=DHEMbZOfRLHD9jF62c2zLAn-TuFLk-8WzE1OdTSxoKI,11187
39
+ swbt/transport/_bumble_key_store.py,sha256=d7IJ9537QRruvy4aOyVtK7RsOOnjuJ7hKmiYTt7gC_I,6938
40
+ swbt/transport/_bumble_lifecycle.py,sha256=9NgXzyW3g2s91gPcsDuyzS9-rAp64ahDKk9fb3VJLDg,10174
41
41
  swbt/transport/_bumble_sdp.py,sha256=RtVgNI3EFi0-cIbiNqrLUi0A2Ju513kF-JWmFlkuvi4,9254
42
42
  swbt/transport/_bumble_usb_devices.py,sha256=soAKiHTzeeTYdb7M357yaac-RxG2c_BiKWH9NK9zJFY,894
43
43
  swbt/transport/_csr_bd_addr.py,sha256=FNc7y9SvEXhHyChZ8I4zgC-LyduiddLqt-HM_Ux2QXA,7949
44
44
  swbt/transport/_csr_bd_addr_harness.py,sha256=aSuFCVbneRENveN_b3CuamD7R9Wqf3CKRNU6Zcdxvpc,10297
45
- swbt/transport/_pairing_profile.py,sha256=ure-PK8gCnAtW_lRFjZgQyuKxYwP0fAOJpryHZR6pnM,9400
45
+ swbt/transport/_pairing_profile.py,sha256=95tISeYHnllHfFpNs0oBsaYb5uAffXfi9eFkjb-EeFQ,9342
46
46
  swbt/transport/base.py,sha256=eI0hs1wTwJf2ppzqda4ok-tUXF1hE9dwpb0p-lpvwmo,5163
47
- swbt/transport/bumble.py,sha256=ntbaXuWRB5_PJMlN52pVWrgIMg4JK8S9jhv65T9dHmo,33435
47
+ swbt/transport/bumble.py,sha256=VufUEo4aUeVebSFvZPJaVe_G7nNz5fKQMCbjgqmBbRs,33414
48
48
  swbt/transport/fake.py,sha256=4O5zlsg1TO61Ai54FGCn_xM8TEA9yi938Fso-XHdh1w,12891
49
- swbt_python-0.5.2.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
50
- swbt_python-0.5.2.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
51
- swbt_python-0.5.2.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
52
- swbt_python-0.5.2.dist-info/METADATA,sha256=w2O422wacUqZ5_ImxW4aSNfEeMKSPW2CetEbau6LlKU,5911
53
- swbt_python-0.5.2.dist-info/RECORD,,
49
+ swbt_python-0.5.4.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
50
+ swbt_python-0.5.4.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
51
+ swbt_python-0.5.4.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
52
+ swbt_python-0.5.4.dist-info/METADATA,sha256=Av__75M0-iYeeo-cBzL2FB2zO1ktEN4CCw6YZHA_ufA,5902
53
+ swbt_python-0.5.4.dist-info/RECORD,,