swbt-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- swbt/__init__.py +37 -0
- swbt/diagnostics.py +202 -0
- swbt/errors.py +33 -0
- swbt/gamepad/__init__.py +14 -0
- swbt/gamepad/connection.py +222 -0
- swbt/gamepad/core.py +697 -0
- swbt/gamepad/output.py +73 -0
- swbt/gamepad/transport_factory.py +22 -0
- swbt/input.py +550 -0
- swbt/probe.py +150 -0
- swbt/protocol/__init__.py +1 -0
- swbt/protocol/input_report.py +77 -0
- swbt/protocol/output_report.py +58 -0
- swbt/protocol/profile.py +221 -0
- swbt/protocol/rumble.py +21 -0
- swbt/protocol/spi.py +37 -0
- swbt/protocol/subcommand.py +102 -0
- swbt/py.typed +1 -0
- swbt/report_loop.py +115 -0
- swbt/state_store.py +60 -0
- swbt/transport/__init__.py +3 -0
- swbt/transport/_bumble_acl.py +33 -0
- swbt/transport/_bumble_hidp.py +34 -0
- swbt/transport/_bumble_key_store.py +220 -0
- swbt/transport/_bumble_lifecycle.py +311 -0
- swbt/transport/_bumble_sdp.py +203 -0
- swbt/transport/base.py +81 -0
- swbt/transport/bumble.py +709 -0
- swbt/transport/fake.py +315 -0
- swbt_python-0.1.0.dist-info/METADATA +122 -0
- swbt_python-0.1.0.dist-info/RECORD +34 -0
- swbt_python-0.1.0.dist-info/WHEEL +4 -0
- swbt_python-0.1.0.dist-info/entry_points.txt +3 -0
- swbt_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Lifecycle and connection diagnostics helpers for Bumble HID transport."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from typing import Any, Protocol, cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EventRecorder(Protocol):
|
|
9
|
+
def __call__(self, event: str, **fields: object) -> None:
|
|
10
|
+
"""Record one diagnostics event."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionDiagnostics:
|
|
14
|
+
"""Register diagnostics callbacks on a Bumble connection object."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
adapter: str,
|
|
20
|
+
handle_disconnection: Callable[[int | None], None],
|
|
21
|
+
record_event: EventRecorder,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._adapter = adapter
|
|
24
|
+
self._handle_disconnection = handle_disconnection
|
|
25
|
+
self._record_event = record_event
|
|
26
|
+
|
|
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
|
|
32
|
+
on_event(
|
|
33
|
+
getattr(connection, "EVENT_DISCONNECTION", "disconnection"),
|
|
34
|
+
self._handle_disconnection,
|
|
35
|
+
)
|
|
36
|
+
on_event(
|
|
37
|
+
getattr(connection, "EVENT_CLASSIC_PAIRING", "classic_pairing"),
|
|
38
|
+
lambda *_args: self._record_event("classic_pairing", adapter=self._adapter),
|
|
39
|
+
)
|
|
40
|
+
on_event(
|
|
41
|
+
getattr(connection, "EVENT_CLASSIC_PAIRING_FAILURE", "classic_pairing_failure"),
|
|
42
|
+
lambda reason=None, *_args: self._record_event(
|
|
43
|
+
"classic_pairing_failure",
|
|
44
|
+
adapter=self._adapter,
|
|
45
|
+
reason=reason,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
on_event(
|
|
49
|
+
getattr(connection, "EVENT_PAIRING_START", "pairing_start"),
|
|
50
|
+
lambda *_args: self._record_event("pairing_start", adapter=self._adapter),
|
|
51
|
+
)
|
|
52
|
+
on_event(
|
|
53
|
+
getattr(connection, "EVENT_PAIRING", "pairing"),
|
|
54
|
+
lambda keys=None, *_args: self._record_pairing_complete(connection, keys),
|
|
55
|
+
)
|
|
56
|
+
on_event(
|
|
57
|
+
getattr(connection, "EVENT_PAIRING_FAILURE", "pairing_failure"),
|
|
58
|
+
lambda reason=None, *_args: self._record_event(
|
|
59
|
+
"pairing_failure",
|
|
60
|
+
adapter=self._adapter,
|
|
61
|
+
reason=reason,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
on_event(
|
|
65
|
+
getattr(connection, "EVENT_CONNECTION_AUTHENTICATION", "connection_authentication"),
|
|
66
|
+
lambda *_args: self._record_connection_authentication(connection),
|
|
67
|
+
)
|
|
68
|
+
on_event(
|
|
69
|
+
getattr(
|
|
70
|
+
connection,
|
|
71
|
+
"EVENT_CONNECTION_AUTHENTICATION_FAILURE",
|
|
72
|
+
"connection_authentication_failure",
|
|
73
|
+
),
|
|
74
|
+
lambda error=None, *_args: self._record_event(
|
|
75
|
+
"connection_authentication_failure",
|
|
76
|
+
adapter=self._adapter,
|
|
77
|
+
error=safe_event_value(error),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
on_event(
|
|
81
|
+
getattr(
|
|
82
|
+
connection,
|
|
83
|
+
"EVENT_CONNECTION_ENCRYPTION_CHANGE",
|
|
84
|
+
"connection_encryption_change",
|
|
85
|
+
),
|
|
86
|
+
lambda *_args: self._record_connection_encryption_change(connection),
|
|
87
|
+
)
|
|
88
|
+
on_event(
|
|
89
|
+
getattr(
|
|
90
|
+
connection,
|
|
91
|
+
"EVENT_CONNECTION_ENCRYPTION_FAILURE",
|
|
92
|
+
"connection_encryption_failure",
|
|
93
|
+
),
|
|
94
|
+
lambda error=None, *_args: self._record_event(
|
|
95
|
+
"connection_encryption_failure",
|
|
96
|
+
adapter=self._adapter,
|
|
97
|
+
error=safe_event_value(error),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
on_event(
|
|
101
|
+
getattr(
|
|
102
|
+
connection,
|
|
103
|
+
"EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH",
|
|
104
|
+
"connection_encryption_key_refresh",
|
|
105
|
+
),
|
|
106
|
+
lambda *_args: self._record_connection_encryption_refresh(connection),
|
|
107
|
+
)
|
|
108
|
+
on_event(
|
|
109
|
+
getattr(connection, "EVENT_LINK_KEY", "link_key"),
|
|
110
|
+
lambda *_args: self._record_event("link_key_available", adapter=self._adapter),
|
|
111
|
+
)
|
|
112
|
+
on_event(
|
|
113
|
+
getattr(connection, "EVENT_MODE_CHANGE", "mode_change"),
|
|
114
|
+
lambda *_args: self._record_classic_mode_change(connection),
|
|
115
|
+
)
|
|
116
|
+
on_event(
|
|
117
|
+
getattr(connection, "EVENT_MODE_CHANGE_FAILURE", "mode_change_failure"),
|
|
118
|
+
lambda status=None, *_args: self._record_event(
|
|
119
|
+
"classic_mode_change_failure",
|
|
120
|
+
adapter=self._adapter,
|
|
121
|
+
status=status,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _record_pairing_complete(self, connection: object, keys: object | None) -> None:
|
|
126
|
+
fields = connection_security_fields(connection)
|
|
127
|
+
fields["has_link_key"] = keys_include_link_key(keys)
|
|
128
|
+
self._record_event("pairing_complete", adapter=self._adapter, **fields)
|
|
129
|
+
|
|
130
|
+
def _record_connection_authentication(self, connection: object) -> None:
|
|
131
|
+
self._record_event(
|
|
132
|
+
"connection_authentication",
|
|
133
|
+
adapter=self._adapter,
|
|
134
|
+
authenticated=getattr(connection, "authenticated", None),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _record_connection_encryption_change(self, connection: object) -> None:
|
|
138
|
+
self._record_event(
|
|
139
|
+
"connection_encryption_change",
|
|
140
|
+
adapter=self._adapter,
|
|
141
|
+
**connection_security_fields(connection),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _record_connection_encryption_refresh(self, connection: object) -> None:
|
|
145
|
+
self._record_event(
|
|
146
|
+
"connection_encryption_key_refresh",
|
|
147
|
+
adapter=self._adapter,
|
|
148
|
+
**connection_security_fields(connection),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _record_classic_mode_change(self, connection: object) -> None:
|
|
152
|
+
self._record_event(
|
|
153
|
+
"classic_mode_change",
|
|
154
|
+
adapter=self._adapter,
|
|
155
|
+
mode=getattr(connection, "classic_mode", None),
|
|
156
|
+
interval=getattr(connection, "classic_interval", None),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def register_connection_diagnostics(
|
|
161
|
+
*,
|
|
162
|
+
adapter: str,
|
|
163
|
+
connection: object,
|
|
164
|
+
handle_disconnection: Callable[[int | None], None],
|
|
165
|
+
record_event: EventRecorder,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Register connection-level diagnostics callbacks."""
|
|
168
|
+
ConnectionDiagnostics(
|
|
169
|
+
adapter=adapter,
|
|
170
|
+
handle_disconnection=handle_disconnection,
|
|
171
|
+
record_event=record_event,
|
|
172
|
+
).register(connection)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def register_connection_request_bridge(
|
|
176
|
+
*,
|
|
177
|
+
adapter: str,
|
|
178
|
+
device: object,
|
|
179
|
+
record_event: EventRecorder,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Wrap Bumble's connection request callback while avoiding deprecated sync APIs."""
|
|
182
|
+
original_connection_request = cast("Any", device).on_connection_request
|
|
183
|
+
|
|
184
|
+
def on_connection_request(
|
|
185
|
+
bd_addr: object,
|
|
186
|
+
class_of_device: int,
|
|
187
|
+
link_type: int,
|
|
188
|
+
) -> None:
|
|
189
|
+
record_event(
|
|
190
|
+
"connection_request",
|
|
191
|
+
adapter=adapter,
|
|
192
|
+
class_of_device=f"0x{class_of_device:06x}",
|
|
193
|
+
link_type=link_type,
|
|
194
|
+
peer_address=str(bd_addr),
|
|
195
|
+
)
|
|
196
|
+
call_connection_request_without_deprecated_sync_command(
|
|
197
|
+
device,
|
|
198
|
+
original_connection_request,
|
|
199
|
+
bd_addr,
|
|
200
|
+
class_of_device,
|
|
201
|
+
link_type,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
device_with_attrs = cast("Any", device)
|
|
205
|
+
device_with_attrs.on_connection_request = on_connection_request
|
|
206
|
+
replace_host_connection_request_listener(
|
|
207
|
+
device,
|
|
208
|
+
original_connection_request,
|
|
209
|
+
on_connection_request,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def register_l2cap_lifecycle_bridge(
|
|
214
|
+
*,
|
|
215
|
+
hid_device: object,
|
|
216
|
+
notify_connected_if_ready: Callable[[], None],
|
|
217
|
+
notify_disconnected_if_channels_closed: Callable[[], None],
|
|
218
|
+
record_l2cap_channel_event: Callable[[str, object], None],
|
|
219
|
+
set_l2cap_connected_emitted: Callable[[bool], None],
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Wrap HID L2CAP lifecycle callbacks and emit transport diagnostics."""
|
|
222
|
+
hid_device_with_attrs = cast("Any", hid_device)
|
|
223
|
+
original_open = hid_device_with_attrs.on_l2cap_channel_open
|
|
224
|
+
original_close = hid_device_with_attrs.on_l2cap_channel_close
|
|
225
|
+
|
|
226
|
+
def on_l2cap_channel_open(l2cap_channel: object) -> None:
|
|
227
|
+
original_open(l2cap_channel)
|
|
228
|
+
record_l2cap_channel_event("l2cap_channel_open", l2cap_channel)
|
|
229
|
+
notify_connected_if_ready()
|
|
230
|
+
|
|
231
|
+
def on_l2cap_channel_close(l2cap_channel: object) -> None:
|
|
232
|
+
original_close(l2cap_channel)
|
|
233
|
+
record_l2cap_channel_event("l2cap_channel_close", l2cap_channel)
|
|
234
|
+
set_l2cap_connected_emitted(False)
|
|
235
|
+
notify_disconnected_if_channels_closed()
|
|
236
|
+
|
|
237
|
+
hid_device_with_attrs.on_l2cap_channel_open = on_l2cap_channel_open
|
|
238
|
+
hid_device_with_attrs.on_l2cap_channel_close = on_l2cap_channel_close
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def connection_security_fields(connection: object) -> dict[str, object]:
|
|
242
|
+
return {
|
|
243
|
+
"authenticated": getattr(connection, "authenticated", None),
|
|
244
|
+
"encryption": getattr(connection, "encryption", None),
|
|
245
|
+
"encryption_key_size": getattr(connection, "encryption_key_size", None),
|
|
246
|
+
"secure_connections": getattr(connection, "sc", None),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def keys_include_link_key(keys: object | None) -> bool | None:
|
|
251
|
+
if keys is None:
|
|
252
|
+
return None
|
|
253
|
+
return getattr(keys, "link_key", None) is not None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def safe_event_value(value: object) -> object:
|
|
257
|
+
if value is None or isinstance(value, int | str | bool | float):
|
|
258
|
+
return value
|
|
259
|
+
return str(value)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def call_connection_request_without_deprecated_sync_command(
|
|
263
|
+
device: object,
|
|
264
|
+
connection_request: Callable[[object, int, int], None],
|
|
265
|
+
bd_addr: object,
|
|
266
|
+
class_of_device: int,
|
|
267
|
+
link_type: int,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""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
|
|
275
|
+
|
|
276
|
+
from bumble import utils # noqa: PLC0415
|
|
277
|
+
|
|
278
|
+
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
|
+
)
|
|
283
|
+
|
|
284
|
+
def send_command_sync(command: object) -> None:
|
|
285
|
+
utils.AsyncRunner.spawn(send_async_command(command))
|
|
286
|
+
|
|
287
|
+
host_with_attrs = cast("Any", host)
|
|
288
|
+
host_with_attrs.send_command_sync = send_command_sync
|
|
289
|
+
try:
|
|
290
|
+
connection_request(bd_addr, class_of_device, link_type)
|
|
291
|
+
finally:
|
|
292
|
+
if previous_instance_attr is missing:
|
|
293
|
+
del host_with_attrs.send_command_sync
|
|
294
|
+
else:
|
|
295
|
+
host_with_attrs.send_command_sync = previous_instance_attr
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def replace_host_connection_request_listener(
|
|
299
|
+
device: object,
|
|
300
|
+
original_connection_request: Callable[[object, int, int], None],
|
|
301
|
+
replacement_connection_request: Callable[[object, int, int], None],
|
|
302
|
+
) -> 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
|
|
308
|
+
|
|
309
|
+
with suppress(KeyError, ValueError):
|
|
310
|
+
remove_listener("connection_request", original_connection_request)
|
|
311
|
+
on("connection_request", replacement_connection_request)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""SDP service record builder for Bumble HID transport."""
|
|
2
|
+
|
|
3
|
+
from swbt.transport._bumble_hidp import HID_CONTROL_PSM, HID_INTERRUPT_PSM
|
|
4
|
+
|
|
5
|
+
_HID_SERVICE_RECORD_HANDLE = 0x00010001
|
|
6
|
+
_HID_REPORT_DESCRIPTOR_TYPE = 0x22
|
|
7
|
+
_DEFAULT_DEVICE_NAME = "Pro Controller"
|
|
8
|
+
|
|
9
|
+
_SDP_HID_PARSER_VERSION_ATTRIBUTE_ID = 0x0201
|
|
10
|
+
_SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID = 0x0202
|
|
11
|
+
_SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID = 0x0203
|
|
12
|
+
_SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID = 0x0204
|
|
13
|
+
_SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID = 0x0205
|
|
14
|
+
_SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0x0206
|
|
15
|
+
_SDP_HID_LANG_ID_BASE_LIST_ATTRIBUTE_ID = 0x0207
|
|
16
|
+
_SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID = 0x020A
|
|
17
|
+
_SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID = 0x020B
|
|
18
|
+
_SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID = 0x020C
|
|
19
|
+
_SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID = 0x020D
|
|
20
|
+
_SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID = 0x020E
|
|
21
|
+
_SDP_HIDSSR_HOST_MAX_LATENCY_ATTRIBUTE_ID = 0x020F
|
|
22
|
+
_SDP_HIDSSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
|
|
23
|
+
_SDP_PRIMARY_LANGUAGE_SERVICE_NAME_ATTRIBUTE_ID = 0x0100
|
|
24
|
+
|
|
25
|
+
_LANGUAGE_BASE_EN_US = 0x0100
|
|
26
|
+
_SDP_LANGUAGE_CODE_ENGLISH = 0x656E
|
|
27
|
+
_SDP_CHARACTER_ENCODING_UTF8 = 0x006A
|
|
28
|
+
_LANGUAGE_ID_EN_US = 0x0409
|
|
29
|
+
_REFERENCE_HID_COUNTRY_CODE = 0x21
|
|
30
|
+
_REFERENCE_HID_SUPERVISION_TIMEOUT = 0x0C80
|
|
31
|
+
_REFERENCE_HIDSSR_HOST_MAX_LATENCY = 0xFFFF
|
|
32
|
+
_REFERENCE_HIDSSR_HOST_MIN_TIMEOUT = 0xFFFF
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_hid_service_records(
|
|
36
|
+
hid_descriptor: bytes,
|
|
37
|
+
*,
|
|
38
|
+
device_name: str = _DEFAULT_DEVICE_NAME,
|
|
39
|
+
) -> dict[int, list[object]]:
|
|
40
|
+
"""Build the Classic HID SDP service record used by the reference controller."""
|
|
41
|
+
from bumble import core # noqa: PLC0415
|
|
42
|
+
from bumble.sdp import ( # noqa: PLC0415
|
|
43
|
+
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
44
|
+
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
45
|
+
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
|
46
|
+
SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
|
|
47
|
+
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
48
|
+
SDP_PUBLIC_BROWSE_ROOT,
|
|
49
|
+
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
50
|
+
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
51
|
+
DataElement,
|
|
52
|
+
ServiceAttribute,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
_HID_SERVICE_RECORD_HANDLE: [
|
|
57
|
+
ServiceAttribute(
|
|
58
|
+
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
59
|
+
DataElement.unsigned_integer_32(_HID_SERVICE_RECORD_HANDLE),
|
|
60
|
+
),
|
|
61
|
+
ServiceAttribute(
|
|
62
|
+
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
63
|
+
DataElement.sequence([DataElement.uuid(core.BT_HUMAN_INTERFACE_DEVICE_SERVICE)]),
|
|
64
|
+
),
|
|
65
|
+
ServiceAttribute(
|
|
66
|
+
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
67
|
+
DataElement.sequence(
|
|
68
|
+
[
|
|
69
|
+
DataElement.sequence(
|
|
70
|
+
[
|
|
71
|
+
DataElement.uuid(core.BT_L2CAP_PROTOCOL_ID),
|
|
72
|
+
DataElement.unsigned_integer_16(HID_CONTROL_PSM),
|
|
73
|
+
]
|
|
74
|
+
),
|
|
75
|
+
DataElement.sequence([DataElement.uuid(core.BT_HIDP_PROTOCOL_ID)]),
|
|
76
|
+
]
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
ServiceAttribute(
|
|
80
|
+
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
|
81
|
+
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
|
82
|
+
),
|
|
83
|
+
ServiceAttribute(
|
|
84
|
+
SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
|
|
85
|
+
DataElement.sequence(
|
|
86
|
+
[
|
|
87
|
+
DataElement.unsigned_integer_16(_SDP_LANGUAGE_CODE_ENGLISH),
|
|
88
|
+
DataElement.unsigned_integer_16(_SDP_CHARACTER_ENCODING_UTF8),
|
|
89
|
+
DataElement.unsigned_integer_16(_LANGUAGE_BASE_EN_US),
|
|
90
|
+
]
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
ServiceAttribute(
|
|
94
|
+
_SDP_PRIMARY_LANGUAGE_SERVICE_NAME_ATTRIBUTE_ID,
|
|
95
|
+
DataElement.text_string(device_name.encode("utf-8")),
|
|
96
|
+
),
|
|
97
|
+
ServiceAttribute(
|
|
98
|
+
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
99
|
+
DataElement.sequence(
|
|
100
|
+
[
|
|
101
|
+
DataElement.sequence(
|
|
102
|
+
[
|
|
103
|
+
DataElement.uuid(core.BT_HUMAN_INTERFACE_DEVICE_SERVICE),
|
|
104
|
+
DataElement.unsigned_integer_16(0x0101),
|
|
105
|
+
]
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
ServiceAttribute(
|
|
111
|
+
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
112
|
+
DataElement.sequence(
|
|
113
|
+
[
|
|
114
|
+
DataElement.sequence(
|
|
115
|
+
[
|
|
116
|
+
DataElement.sequence(
|
|
117
|
+
[
|
|
118
|
+
DataElement.uuid(core.BT_L2CAP_PROTOCOL_ID),
|
|
119
|
+
DataElement.unsigned_integer_16(HID_INTERRUPT_PSM),
|
|
120
|
+
]
|
|
121
|
+
),
|
|
122
|
+
DataElement.sequence([DataElement.uuid(core.BT_HIDP_PROTOCOL_ID)]),
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
]
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
ServiceAttribute(
|
|
129
|
+
_SDP_HID_PARSER_VERSION_ATTRIBUTE_ID,
|
|
130
|
+
DataElement.unsigned_integer_16(0x0111),
|
|
131
|
+
),
|
|
132
|
+
ServiceAttribute(
|
|
133
|
+
_SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID,
|
|
134
|
+
DataElement.unsigned_integer_8(0x08),
|
|
135
|
+
),
|
|
136
|
+
ServiceAttribute(
|
|
137
|
+
_SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID,
|
|
138
|
+
DataElement.unsigned_integer_8(_REFERENCE_HID_COUNTRY_CODE),
|
|
139
|
+
),
|
|
140
|
+
ServiceAttribute(
|
|
141
|
+
_SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID,
|
|
142
|
+
DataElement.boolean(True),
|
|
143
|
+
),
|
|
144
|
+
ServiceAttribute(
|
|
145
|
+
_SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID,
|
|
146
|
+
DataElement.boolean(True),
|
|
147
|
+
),
|
|
148
|
+
ServiceAttribute(
|
|
149
|
+
_SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
150
|
+
DataElement.sequence(
|
|
151
|
+
[
|
|
152
|
+
DataElement.sequence(
|
|
153
|
+
[
|
|
154
|
+
DataElement.unsigned_integer_8(_HID_REPORT_DESCRIPTOR_TYPE),
|
|
155
|
+
DataElement.text_string(hid_descriptor),
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
]
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
ServiceAttribute(
|
|
162
|
+
_SDP_HID_LANG_ID_BASE_LIST_ATTRIBUTE_ID,
|
|
163
|
+
DataElement.sequence(
|
|
164
|
+
[
|
|
165
|
+
DataElement.sequence(
|
|
166
|
+
[
|
|
167
|
+
DataElement.unsigned_integer_16(_LANGUAGE_ID_EN_US),
|
|
168
|
+
DataElement.unsigned_integer_16(_LANGUAGE_BASE_EN_US),
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
]
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
ServiceAttribute(
|
|
175
|
+
_SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID,
|
|
176
|
+
DataElement.boolean(True),
|
|
177
|
+
),
|
|
178
|
+
ServiceAttribute(
|
|
179
|
+
_SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID,
|
|
180
|
+
DataElement.unsigned_integer_16(0x0101),
|
|
181
|
+
),
|
|
182
|
+
ServiceAttribute(
|
|
183
|
+
_SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID,
|
|
184
|
+
DataElement.unsigned_integer_16(_REFERENCE_HID_SUPERVISION_TIMEOUT),
|
|
185
|
+
),
|
|
186
|
+
ServiceAttribute(
|
|
187
|
+
_SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID,
|
|
188
|
+
DataElement.boolean(True),
|
|
189
|
+
),
|
|
190
|
+
ServiceAttribute(
|
|
191
|
+
_SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID,
|
|
192
|
+
DataElement.boolean(False),
|
|
193
|
+
),
|
|
194
|
+
ServiceAttribute(
|
|
195
|
+
_SDP_HIDSSR_HOST_MAX_LATENCY_ATTRIBUTE_ID,
|
|
196
|
+
DataElement.unsigned_integer_16(_REFERENCE_HIDSSR_HOST_MAX_LATENCY),
|
|
197
|
+
),
|
|
198
|
+
ServiceAttribute(
|
|
199
|
+
_SDP_HIDSSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID,
|
|
200
|
+
DataElement.unsigned_integer_16(_REFERENCE_HIDSSR_HOST_MIN_TIMEOUT),
|
|
201
|
+
),
|
|
202
|
+
]
|
|
203
|
+
}
|
swbt/transport/base.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Transport protocol shared by gamepad and transport implementations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
|
|
7
|
+
InterruptDataCallback = Callable[[bytes], Awaitable[None]]
|
|
8
|
+
ControlDataCallback = Callable[[bytes], Awaitable[None]]
|
|
9
|
+
ConnectedCallback = Callable[[], Awaitable[None]]
|
|
10
|
+
DisconnectedCallback = Callable[[int | None], Awaitable[None]]
|
|
11
|
+
DisconnectRequestStatus = Literal["requested", "unavailable", "failed"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DisconnectRequestResult:
|
|
16
|
+
"""Result of a best-effort remote HID disconnect request."""
|
|
17
|
+
|
|
18
|
+
status: DisconnectRequestStatus
|
|
19
|
+
channels: tuple[str, ...] = ()
|
|
20
|
+
reason: str | None = None
|
|
21
|
+
error_type: str | None = None
|
|
22
|
+
message: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class BondedPeer:
|
|
27
|
+
"""Peer address discovered from a transport key store."""
|
|
28
|
+
|
|
29
|
+
address: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HidDeviceTransport(Protocol):
|
|
33
|
+
"""Abstract HID device transport used by SwitchGamepad."""
|
|
34
|
+
|
|
35
|
+
async def open(self) -> None:
|
|
36
|
+
"""Open transport resources without waiting for a host connection."""
|
|
37
|
+
|
|
38
|
+
async def start_advertising(self) -> None:
|
|
39
|
+
"""Enter the host-discoverable state."""
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
"""Close transport resources."""
|
|
43
|
+
|
|
44
|
+
async def request_disconnect(self) -> DisconnectRequestResult:
|
|
45
|
+
"""Request a remote HID/L2CAP disconnect when the transport supports it."""
|
|
46
|
+
|
|
47
|
+
async def list_bonded_peers(self) -> tuple[BondedPeer, ...]:
|
|
48
|
+
"""Return current reconnect candidates.
|
|
49
|
+
|
|
50
|
+
Implementations must return zero or one peer. A transport that stores
|
|
51
|
+
multiple historical peers must expose only the current reconnect target
|
|
52
|
+
here. Multiple current peers are an invalid transport or key-store state
|
|
53
|
+
and should raise InvalidKeyStoreError rather than returning multiple
|
|
54
|
+
BondedPeer values.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
async def connect_bonded_peer(
|
|
58
|
+
self,
|
|
59
|
+
peer_address: str,
|
|
60
|
+
*,
|
|
61
|
+
connect_timeout: float | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Start an active reconnect attempt to a bonded peer."""
|
|
64
|
+
|
|
65
|
+
async def send_interrupt(self, payload: bytes) -> None:
|
|
66
|
+
"""Send one HID interrupt report."""
|
|
67
|
+
|
|
68
|
+
async def send_control(self, payload: bytes) -> None:
|
|
69
|
+
"""Send one HID control report."""
|
|
70
|
+
|
|
71
|
+
def on_interrupt_data(self, callback: InterruptDataCallback) -> None:
|
|
72
|
+
"""Register a callback for interrupt-channel host data."""
|
|
73
|
+
|
|
74
|
+
def on_control_data(self, callback: ControlDataCallback) -> None:
|
|
75
|
+
"""Register a callback for control-channel host data."""
|
|
76
|
+
|
|
77
|
+
def on_connected(self, callback: ConnectedCallback) -> None:
|
|
78
|
+
"""Register a callback for transport connection completion."""
|
|
79
|
+
|
|
80
|
+
def on_disconnected(self, callback: DisconnectedCallback) -> None:
|
|
81
|
+
"""Register a callback for transport disconnection."""
|