swbt-python 0.1.0__py3-none-any.whl → 0.2.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 +19 -6
- swbt/_testing/__init__.py +1 -0
- swbt/_testing/gamepad.py +74 -0
- swbt/adapter_discovery.py +231 -0
- swbt/diagnostics.py +12 -2
- swbt/errors.py +67 -0
- swbt/gamepad/__init__.py +10 -4
- swbt/gamepad/_config.py +101 -0
- swbt/gamepad/connection.py +6 -0
- swbt/gamepad/constants.py +3 -0
- swbt/gamepad/core.py +147 -435
- swbt/gamepad/interface.py +273 -0
- swbt/gamepad/output.py +26 -1
- swbt/gamepad/runtime.py +749 -0
- swbt/gamepad/transport_factory.py +55 -0
- swbt/input.py +20 -0
- swbt/probe.py +79 -4
- swbt/protocol/buttons.py +63 -0
- swbt/protocol/{profile.py → descriptors.py} +1 -13
- swbt/protocol/input_report.py +8 -28
- swbt/protocol/profiles/__init__.py +3 -0
- swbt/protocol/profiles/base.py +242 -0
- swbt/protocol/profiles/joycon.py +84 -0
- swbt/protocol/profiles/pro_controller.py +25 -0
- swbt/protocol/spi.py +13 -3
- swbt/protocol/subcommand.py +100 -7
- swbt/report_loop.py +2 -2
- swbt/state_store.py +15 -0
- swbt/transport/_bumble_sdp.py +98 -61
- swbt/transport/_bumble_usb_devices.py +33 -0
- swbt/transport/base.py +101 -13
- swbt/transport/bumble.py +68 -9
- swbt/transport/fake.py +8 -0
- swbt_python-0.2.0.dist-info/METADATA +151 -0
- swbt_python-0.2.0.dist-info/RECORD +47 -0
- swbt_python-0.1.0.dist-info/METADATA +0 -122
- swbt_python-0.1.0.dist-info/RECORD +0 -34
- {swbt_python-0.1.0.dist-info → swbt_python-0.2.0.dist-info}/WHEEL +0 -0
- {swbt_python-0.1.0.dist-info → swbt_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {swbt_python-0.1.0.dist-info → swbt_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
swbt/__init__.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Python API for virtual NX-compatible input devices."""
|
|
2
2
|
|
|
3
|
+
from swbt.adapter_discovery import AdapterInfo, list_adapters
|
|
3
4
|
from swbt.diagnostics import DiagnosticsConfig, GamepadStatus
|
|
4
5
|
from swbt.errors import (
|
|
6
|
+
AdapterDiscoveryError,
|
|
5
7
|
ClosedError,
|
|
6
8
|
ConnectionFailedError,
|
|
7
9
|
ConnectionTimeoutError,
|
|
@@ -9,29 +11,40 @@ from swbt.errors import (
|
|
|
9
11
|
InvalidKeyStoreError,
|
|
10
12
|
SwbtError,
|
|
11
13
|
TransportOpenError,
|
|
14
|
+
UnsupportedInputError,
|
|
15
|
+
)
|
|
16
|
+
from swbt.gamepad import (
|
|
17
|
+
ConnectionResult,
|
|
18
|
+
JoyConL,
|
|
19
|
+
JoyConR,
|
|
20
|
+
ProController,
|
|
21
|
+
SwitchGamepad,
|
|
12
22
|
)
|
|
13
|
-
from swbt.gamepad import ConnectionResult, SwitchGamepad, SwitchGamepadConfig
|
|
14
23
|
from swbt.input import Button, IMUFrame, InputState, Stick
|
|
15
|
-
from swbt.
|
|
24
|
+
from swbt.protocol.profiles.base import ControllerColors
|
|
16
25
|
|
|
17
26
|
__all__ = (
|
|
18
|
-
"
|
|
27
|
+
"AdapterDiscoveryError",
|
|
28
|
+
"AdapterInfo",
|
|
19
29
|
"Button",
|
|
20
30
|
"ClosedError",
|
|
21
31
|
"ConnectionFailedError",
|
|
22
32
|
"ConnectionResult",
|
|
23
33
|
"ConnectionTimeoutError",
|
|
34
|
+
"ControllerColors",
|
|
24
35
|
"DiagnosticsConfig",
|
|
25
|
-
"DisconnectRequestResult",
|
|
26
36
|
"GamepadStatus",
|
|
27
|
-
"HidDeviceTransport",
|
|
28
37
|
"IMUFrame",
|
|
29
38
|
"InputState",
|
|
30
39
|
"InvalidInputError",
|
|
31
40
|
"InvalidKeyStoreError",
|
|
41
|
+
"JoyConL",
|
|
42
|
+
"JoyConR",
|
|
43
|
+
"ProController",
|
|
32
44
|
"Stick",
|
|
33
45
|
"SwbtError",
|
|
34
46
|
"SwitchGamepad",
|
|
35
|
-
"SwitchGamepadConfig",
|
|
36
47
|
"TransportOpenError",
|
|
48
|
+
"UnsupportedInputError",
|
|
49
|
+
"list_adapters",
|
|
37
50
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal testing helpers for swbt's own test suite."""
|
swbt/_testing/gamepad.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Internal gamepad constructors for tests that inject fake transports."""
|
|
2
|
+
|
|
3
|
+
from swbt import ControllerColors, DiagnosticsConfig, JoyConL, JoyConR, ProController
|
|
4
|
+
from swbt.gamepad._config import _SwitchGamepadConfig
|
|
5
|
+
from swbt.protocol.profiles.joycon import JoyConLeftProfile, JoyConRightProfile
|
|
6
|
+
from swbt.transport.base import HidDeviceTransport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_pro_controller(
|
|
10
|
+
*,
|
|
11
|
+
transport: HidDeviceTransport,
|
|
12
|
+
adapter: str | None = None,
|
|
13
|
+
key_store_path: str | None = None,
|
|
14
|
+
report_period_us: int | None = None,
|
|
15
|
+
controller_colors: ControllerColors | None = None,
|
|
16
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
17
|
+
) -> ProController:
|
|
18
|
+
"""Create a Pro Controller with an injected internal transport for tests."""
|
|
19
|
+
return ProController._from_config(
|
|
20
|
+
_SwitchGamepadConfig(
|
|
21
|
+
adapter=adapter,
|
|
22
|
+
key_store_path=key_store_path,
|
|
23
|
+
report_period_us=report_period_us,
|
|
24
|
+
controller_colors=controller_colors,
|
|
25
|
+
),
|
|
26
|
+
diagnostics=diagnostics,
|
|
27
|
+
transport=transport,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_joycon_l(
|
|
32
|
+
*,
|
|
33
|
+
transport: HidDeviceTransport,
|
|
34
|
+
adapter: str | None = None,
|
|
35
|
+
key_store_path: str | None = None,
|
|
36
|
+
report_period_us: int | None = None,
|
|
37
|
+
controller_colors: ControllerColors | None = None,
|
|
38
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
39
|
+
) -> JoyConL:
|
|
40
|
+
"""Create a Joy-Con L with an injected internal transport for tests."""
|
|
41
|
+
return JoyConL._from_config(
|
|
42
|
+
_SwitchGamepadConfig(
|
|
43
|
+
adapter=adapter,
|
|
44
|
+
key_store_path=key_store_path,
|
|
45
|
+
report_period_us=report_period_us,
|
|
46
|
+
controller_colors=controller_colors,
|
|
47
|
+
profile=JoyConLeftProfile(),
|
|
48
|
+
),
|
|
49
|
+
diagnostics=diagnostics,
|
|
50
|
+
transport=transport,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def make_joycon_r(
|
|
55
|
+
*,
|
|
56
|
+
transport: HidDeviceTransport,
|
|
57
|
+
adapter: str | None = None,
|
|
58
|
+
key_store_path: str | None = None,
|
|
59
|
+
report_period_us: int | None = None,
|
|
60
|
+
controller_colors: ControllerColors | None = None,
|
|
61
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
62
|
+
) -> JoyConR:
|
|
63
|
+
"""Create a Joy-Con R with an injected internal transport for tests."""
|
|
64
|
+
return JoyConR._from_config(
|
|
65
|
+
_SwitchGamepadConfig(
|
|
66
|
+
adapter=adapter,
|
|
67
|
+
key_store_path=key_store_path,
|
|
68
|
+
report_period_us=report_period_us,
|
|
69
|
+
controller_colors=controller_colors,
|
|
70
|
+
profile=JoyConRightProfile(),
|
|
71
|
+
),
|
|
72
|
+
diagnostics=diagnostics,
|
|
73
|
+
transport=transport,
|
|
74
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""No-open USB Bluetooth adapter discovery."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: N802
|
|
4
|
+
|
|
5
|
+
import platform as platform_module
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from typing import Protocol, cast
|
|
10
|
+
|
|
11
|
+
from swbt.errors import AdapterDiscoveryError
|
|
12
|
+
from swbt.transport import _bumble_usb_devices
|
|
13
|
+
|
|
14
|
+
USB_DEVICE_CLASS_DEVICE = 0x00
|
|
15
|
+
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
|
|
16
|
+
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
|
|
17
|
+
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
|
|
18
|
+
|
|
19
|
+
USB_BT_HCI_CLASS_TUPLE = (
|
|
20
|
+
USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
|
|
21
|
+
USB_DEVICE_SUBCLASS_RF_CONTROLLER,
|
|
22
|
+
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _UsbSetting(Protocol):
|
|
27
|
+
"""USB interface setting shape used for HCI class detection."""
|
|
28
|
+
|
|
29
|
+
def getClass(self) -> int: ...
|
|
30
|
+
def getSubClass(self) -> int: ...
|
|
31
|
+
def getProtocol(self) -> int: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _UsbInterface(Protocol):
|
|
35
|
+
"""USB interface shape used for HCI class detection."""
|
|
36
|
+
|
|
37
|
+
def __iter__(self) -> Iterator[_UsbSetting]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _UsbConfiguration(Protocol):
|
|
41
|
+
"""USB configuration shape used for HCI class detection."""
|
|
42
|
+
|
|
43
|
+
def __iter__(self) -> Iterator[_UsbInterface]: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _UsbDevice(Protocol):
|
|
47
|
+
"""USB device shape consumed without opening the device handle."""
|
|
48
|
+
|
|
49
|
+
def getVendorID(self) -> int: ...
|
|
50
|
+
def getProductID(self) -> int: ...
|
|
51
|
+
def getDeviceClass(self) -> int: ...
|
|
52
|
+
def getDeviceSubClass(self) -> int: ...
|
|
53
|
+
def getDeviceProtocol(self) -> int: ...
|
|
54
|
+
def getSerialNumber(self) -> str | None: ...
|
|
55
|
+
def getManufacturer(self) -> str | None: ...
|
|
56
|
+
def getProduct(self) -> str | None: ...
|
|
57
|
+
def getBusNumber(self) -> int: ...
|
|
58
|
+
def getDeviceAddress(self) -> int: ...
|
|
59
|
+
def getPortNumberList(self) -> list[int]: ...
|
|
60
|
+
def __iter__(self) -> Iterator[_UsbConfiguration]: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class AdapterInfo:
|
|
65
|
+
"""USB Bluetooth adapter candidate.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
name: Primary adapter moniker passed to ``ProController(adapter=...)``.
|
|
69
|
+
aliases: Alternative adapter monikers for the same USB device.
|
|
70
|
+
vendor_id: USB vendor ID.
|
|
71
|
+
product_id: USB product ID.
|
|
72
|
+
manufacturer: USB manufacturer string when available.
|
|
73
|
+
product: USB product string when available.
|
|
74
|
+
serial_number: USB serial number when available.
|
|
75
|
+
bus_number: USB bus number when available.
|
|
76
|
+
device_address: USB device address when available.
|
|
77
|
+
port_numbers: USB port path numbers when available.
|
|
78
|
+
is_bluetooth_hci: Whether the USB device is classified as Bluetooth HCI.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
name: Primary adapter moniker passed to ``ProController(adapter=...)``.
|
|
82
|
+
aliases: Alternative adapter monikers for the same USB device.
|
|
83
|
+
vendor_id: USB vendor ID.
|
|
84
|
+
product_id: USB product ID.
|
|
85
|
+
manufacturer: USB manufacturer string when available.
|
|
86
|
+
product: USB product string when available.
|
|
87
|
+
serial_number: USB serial number when available.
|
|
88
|
+
bus_number: USB bus number when available.
|
|
89
|
+
device_address: USB device address when available.
|
|
90
|
+
port_numbers: USB port path numbers when available.
|
|
91
|
+
is_bluetooth_hci: Whether the USB device is classified as Bluetooth HCI.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
aliases: tuple[str, ...] = ()
|
|
96
|
+
vendor_id: int | None = None
|
|
97
|
+
product_id: int | None = None
|
|
98
|
+
manufacturer: str | None = None
|
|
99
|
+
product: str | None = None
|
|
100
|
+
serial_number: str | None = None
|
|
101
|
+
bus_number: int | None = None
|
|
102
|
+
device_address: int | None = None
|
|
103
|
+
port_numbers: tuple[int, ...] = ()
|
|
104
|
+
is_bluetooth_hci: bool = True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_adapters() -> tuple[AdapterInfo, ...]:
|
|
108
|
+
"""Return USB Bluetooth adapter candidates without opening them.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Immutable snapshot of adapter candidates. An empty tuple means no
|
|
112
|
+
Bluetooth HCI candidates were found.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
AdapterDiscoveryError: USB device enumeration cannot be started.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
return tuple(_build_adapter_infos(_iter_usb_devices()))
|
|
119
|
+
except AdapterDiscoveryError:
|
|
120
|
+
raise
|
|
121
|
+
except Exception as error:
|
|
122
|
+
msg = "failed to discover USB Bluetooth adapters without opening them"
|
|
123
|
+
raise _discovery_error(msg) from error
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_adapter_infos(devices: Iterable[_UsbDevice]) -> Iterator[AdapterInfo]:
|
|
127
|
+
seen_serials_by_id: dict[tuple[int, int], list[str | None]] = {}
|
|
128
|
+
hci_index = 0
|
|
129
|
+
|
|
130
|
+
for device in devices:
|
|
131
|
+
if not _is_bluetooth_hci(device):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
vendor_id = device.getVendorID()
|
|
135
|
+
product_id = device.getProductID()
|
|
136
|
+
device_id = (vendor_id, product_id)
|
|
137
|
+
serial_number = _optional_descriptor(device.getSerialNumber)
|
|
138
|
+
manufacturer = _optional_descriptor(device.getManufacturer)
|
|
139
|
+
product = _optional_descriptor(device.getProduct)
|
|
140
|
+
primary_name = f"usb:{hci_index}"
|
|
141
|
+
basic_name = f"usb:{vendor_id:04X}:{product_id:04X}"
|
|
142
|
+
transport_names = [primary_name]
|
|
143
|
+
|
|
144
|
+
if device_id not in seen_serials_by_id:
|
|
145
|
+
transport_names.append(basic_name)
|
|
146
|
+
else:
|
|
147
|
+
transport_names.append(f"{basic_name}#{len(seen_serials_by_id[device_id])}")
|
|
148
|
+
|
|
149
|
+
if serial_number is not None and (
|
|
150
|
+
device_id not in seen_serials_by_id
|
|
151
|
+
or serial_number not in seen_serials_by_id[device_id]
|
|
152
|
+
):
|
|
153
|
+
transport_names.append(f"{basic_name}/{serial_number}")
|
|
154
|
+
|
|
155
|
+
seen_serials_by_id.setdefault(device_id, []).append(serial_number)
|
|
156
|
+
hci_index += 1
|
|
157
|
+
|
|
158
|
+
yield AdapterInfo(
|
|
159
|
+
name=primary_name,
|
|
160
|
+
aliases=tuple(name for name in transport_names if name != primary_name),
|
|
161
|
+
vendor_id=vendor_id,
|
|
162
|
+
product_id=product_id,
|
|
163
|
+
manufacturer=manufacturer,
|
|
164
|
+
product=product,
|
|
165
|
+
serial_number=serial_number,
|
|
166
|
+
bus_number=device.getBusNumber(),
|
|
167
|
+
device_address=device.getDeviceAddress(),
|
|
168
|
+
port_numbers=tuple(device.getPortNumberList()),
|
|
169
|
+
is_bluetooth_hci=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _iter_usb_devices() -> Iterator[_UsbDevice]:
|
|
174
|
+
yield from cast("Iterator[_UsbDevice]", _bumble_usb_devices.iter_usb_devices())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_bluetooth_hci(device: _UsbDevice) -> bool:
|
|
178
|
+
if (
|
|
179
|
+
device.getDeviceClass(),
|
|
180
|
+
device.getDeviceSubClass(),
|
|
181
|
+
device.getDeviceProtocol(),
|
|
182
|
+
) == USB_BT_HCI_CLASS_TUPLE:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
if device.getDeviceClass() != USB_DEVICE_CLASS_DEVICE:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
for configuration in device:
|
|
189
|
+
for interface in configuration:
|
|
190
|
+
for setting in interface:
|
|
191
|
+
if (
|
|
192
|
+
setting.getClass(),
|
|
193
|
+
setting.getSubClass(),
|
|
194
|
+
setting.getProtocol(),
|
|
195
|
+
) == USB_BT_HCI_CLASS_TUPLE:
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _optional_descriptor(getter: Callable[[], str | None]) -> str | None:
|
|
202
|
+
try:
|
|
203
|
+
value = getter()
|
|
204
|
+
except Exception as error: # descriptor access differs by OS and driver
|
|
205
|
+
if _is_usb_error(error):
|
|
206
|
+
return None
|
|
207
|
+
raise
|
|
208
|
+
if value is None:
|
|
209
|
+
return None
|
|
210
|
+
return str(value)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_usb_error(error: Exception) -> bool:
|
|
214
|
+
return _bumble_usb_devices.is_usb_error(error)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _discovery_error(message: str) -> AdapterDiscoveryError:
|
|
218
|
+
return AdapterDiscoveryError(
|
|
219
|
+
message,
|
|
220
|
+
platform=platform_module.platform(),
|
|
221
|
+
backend="bumble-usb",
|
|
222
|
+
libusb_available=None,
|
|
223
|
+
bumble_version=_package_version("bumble"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _package_version(package_name: str) -> str:
|
|
228
|
+
try:
|
|
229
|
+
return version(package_name)
|
|
230
|
+
except PackageNotFoundError:
|
|
231
|
+
return "unknown"
|
swbt/diagnostics.py
CHANGED
|
@@ -9,7 +9,10 @@ from typing import TextIO
|
|
|
9
9
|
|
|
10
10
|
@dataclass(frozen=True)
|
|
11
11
|
class DiagnosticsConfig:
|
|
12
|
-
"""Diagnostics configuration accepted by
|
|
12
|
+
"""Diagnostics configuration accepted by concrete gamepads.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
trace_writer: Text stream that receives one JSON Lines diagnostics event per line.
|
|
13
16
|
|
|
14
17
|
Attributes:
|
|
15
18
|
trace_writer: Text stream that receives one JSON Lines diagnostics event per line.
|
|
@@ -39,7 +42,14 @@ class DiagnosticsEvent:
|
|
|
39
42
|
|
|
40
43
|
@dataclass(frozen=True)
|
|
41
44
|
class GamepadStatus:
|
|
42
|
-
"""Snapshot of gamepad status exposed by SwitchGamepad.status()
|
|
45
|
+
"""Snapshot of gamepad status exposed by ``SwitchGamepad.status()``.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
connection_state: Current lifecycle state name.
|
|
49
|
+
report_counters: Sent report counts keyed by numeric report ID.
|
|
50
|
+
last_subcommand_id: Last observed subcommand ID, if any.
|
|
51
|
+
raw_rumble: Last raw rumble payload received from the host.
|
|
52
|
+
last_error: Latest diagnostics error event, if any.
|
|
43
53
|
|
|
44
54
|
Attributes:
|
|
45
55
|
connection_state: Current lifecycle state name.
|
swbt/errors.py
CHANGED
|
@@ -9,6 +9,48 @@ class TransportOpenError(SwbtError):
|
|
|
9
9
|
"""Raised when a transport cannot be opened."""
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class AdapterDiscoveryError(SwbtError):
|
|
13
|
+
"""Raised when no-open adapter discovery cannot enumerate USB devices.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: Human-readable failure message.
|
|
17
|
+
platform: Host platform string captured at discovery time.
|
|
18
|
+
backend: Discovery backend identifier.
|
|
19
|
+
libusb_available: Whether libusb availability is known at the failure point.
|
|
20
|
+
bumble_version: Installed Bumble package version when available.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
platform: Host platform string captured at discovery time.
|
|
24
|
+
backend: Discovery backend identifier.
|
|
25
|
+
libusb_available: Whether libusb availability is known at the failure point.
|
|
26
|
+
bumble_version: Installed Bumble package version when available.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
platform: str,
|
|
34
|
+
backend: str = "bumble-usb",
|
|
35
|
+
libusb_available: bool | None = None,
|
|
36
|
+
bumble_version: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize adapter discovery failure metadata.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
message: Human-readable failure message.
|
|
42
|
+
platform: Host platform string captured at discovery time.
|
|
43
|
+
backend: Discovery backend identifier.
|
|
44
|
+
libusb_available: Whether libusb availability is known at the failure point.
|
|
45
|
+
bumble_version: Installed Bumble package version when available.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.platform = platform
|
|
49
|
+
self.backend = backend
|
|
50
|
+
self.libusb_available = libusb_available
|
|
51
|
+
self.bumble_version = bumble_version
|
|
52
|
+
|
|
53
|
+
|
|
12
54
|
class ConnectionTimeoutError(SwbtError):
|
|
13
55
|
"""Raised when waiting for a connection times out."""
|
|
14
56
|
|
|
@@ -31,3 +73,28 @@ class ClosedError(SwbtError):
|
|
|
31
73
|
|
|
32
74
|
class InvalidInputError(SwbtError):
|
|
33
75
|
"""Raised when user-provided input values are outside the supported range."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class UnsupportedInputError(InvalidInputError):
|
|
79
|
+
"""Raised when a controller profile does not support a requested input."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
profile_kind: str,
|
|
86
|
+
buttons: tuple[str, ...] = (),
|
|
87
|
+
sticks: tuple[str, ...] = (),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Initialize unsupported input details.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
message: Human-readable failure message.
|
|
93
|
+
profile_kind: Controller profile identity that rejected the input.
|
|
94
|
+
buttons: Unsupported button names.
|
|
95
|
+
sticks: Unsupported stick side names.
|
|
96
|
+
"""
|
|
97
|
+
super().__init__(message)
|
|
98
|
+
self.profile_kind = profile_kind
|
|
99
|
+
self.buttons = buttons
|
|
100
|
+
self.sticks = sticks
|
swbt/gamepad/__init__.py
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
"""Public gamepad facade."""
|
|
2
2
|
|
|
3
3
|
from swbt.gamepad.connection import ConnectionResult, ConnectionStatus
|
|
4
|
-
from swbt.gamepad.
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
from swbt.gamepad.constants import DISCONNECT_REQUEST_TIMEOUT_SECONDS
|
|
5
|
+
from swbt.gamepad.core import (
|
|
6
|
+
JoyConL,
|
|
7
|
+
JoyConR,
|
|
8
|
+
ProController,
|
|
9
|
+
SwitchGamepad,
|
|
10
|
+
)
|
|
7
11
|
|
|
8
12
|
__all__ = (
|
|
9
13
|
"DISCONNECT_REQUEST_TIMEOUT_SECONDS",
|
|
10
14
|
"ConnectionResult",
|
|
11
15
|
"ConnectionStatus",
|
|
16
|
+
"JoyConL",
|
|
17
|
+
"JoyConR",
|
|
18
|
+
"ProController",
|
|
12
19
|
"SwitchGamepad",
|
|
13
|
-
"SwitchGamepadConfig",
|
|
14
20
|
)
|
swbt/gamepad/_config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configuration models for gamepad construction and runtime setup."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from swbt.errors import InvalidInputError
|
|
6
|
+
from swbt.protocol.profiles.base import ControllerColors, ControllerProfile
|
|
7
|
+
from swbt.protocol.profiles.pro_controller import default_controller_profile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class _SwitchGamepadConfig:
|
|
12
|
+
"""Configuration used to construct a concrete gamepad.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
adapter: Bumble adapter moniker, such as ``"usb:0"``.
|
|
16
|
+
key_store_path: Path used by the default transport to persist pairing keys.
|
|
17
|
+
profile: Fixed controller identity and protocol profile.
|
|
18
|
+
report_period_us: Periodic input report interval in microseconds.
|
|
19
|
+
device_name: HID device name advertised to the host.
|
|
20
|
+
controller_colors: Fixed controller body, button, and grip colors for SPI profile data.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
adapter: str | None = None
|
|
24
|
+
key_store_path: str | None = None
|
|
25
|
+
profile: ControllerProfile = field(default_factory=default_controller_profile)
|
|
26
|
+
report_period_us: int | None = None
|
|
27
|
+
device_name: str | None = None
|
|
28
|
+
controller_colors: ControllerColors | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
"""Validate resource configuration."""
|
|
32
|
+
if not isinstance(self.profile, ControllerProfile):
|
|
33
|
+
msg = "profile must be a ControllerProfile"
|
|
34
|
+
raise InvalidInputError(msg)
|
|
35
|
+
if self.report_period_us is None:
|
|
36
|
+
object.__setattr__(
|
|
37
|
+
self,
|
|
38
|
+
"report_period_us",
|
|
39
|
+
self.profile.default_report_period_us,
|
|
40
|
+
)
|
|
41
|
+
elif self.report_period_us <= 0:
|
|
42
|
+
msg = "report_period_us must be positive"
|
|
43
|
+
raise InvalidInputError(msg)
|
|
44
|
+
if self.device_name is None:
|
|
45
|
+
object.__setattr__(self, "device_name", self.profile.device_name)
|
|
46
|
+
if self.controller_colors is not None and not isinstance(
|
|
47
|
+
self.controller_colors, ControllerColors
|
|
48
|
+
):
|
|
49
|
+
msg = "controller_colors must be a ControllerColors"
|
|
50
|
+
raise InvalidInputError(msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _ControllerSpec:
|
|
55
|
+
"""Internal controller identity selected by a concrete controller class."""
|
|
56
|
+
|
|
57
|
+
profile: ControllerProfile
|
|
58
|
+
|
|
59
|
+
def build_config(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
adapter: str | None,
|
|
63
|
+
key_store_path: str | None,
|
|
64
|
+
report_period_us: int | None,
|
|
65
|
+
controller_colors: ControllerColors | None,
|
|
66
|
+
) -> _SwitchGamepadConfig:
|
|
67
|
+
"""Create internal construction config from public constructor options."""
|
|
68
|
+
return _SwitchGamepadConfig(
|
|
69
|
+
adapter=adapter,
|
|
70
|
+
key_store_path=key_store_path,
|
|
71
|
+
profile=self.profile,
|
|
72
|
+
report_period_us=report_period_us,
|
|
73
|
+
controller_colors=controller_colors,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class _RuntimeConfig:
|
|
79
|
+
"""Normalized internal configuration for ControllerRuntime."""
|
|
80
|
+
|
|
81
|
+
adapter: str | None
|
|
82
|
+
key_store_path: str | None
|
|
83
|
+
profile: ControllerProfile
|
|
84
|
+
report_period_us: int
|
|
85
|
+
device_name: str
|
|
86
|
+
controller_colors: ControllerColors | None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_public_config(cls, config: _SwitchGamepadConfig) -> "_RuntimeConfig":
|
|
90
|
+
"""Create normalized runtime configuration from public construction config."""
|
|
91
|
+
if config.report_period_us is None or config.device_name is None:
|
|
92
|
+
msg = "_SwitchGamepadConfig was not normalized"
|
|
93
|
+
raise InvalidInputError(msg)
|
|
94
|
+
return cls(
|
|
95
|
+
adapter=config.adapter,
|
|
96
|
+
key_store_path=config.key_store_path,
|
|
97
|
+
profile=config.profile,
|
|
98
|
+
report_period_us=config.report_period_us,
|
|
99
|
+
device_name=config.device_name,
|
|
100
|
+
controller_colors=config.controller_colors,
|
|
101
|
+
)
|
swbt/gamepad/connection.py
CHANGED
|
@@ -30,6 +30,12 @@ PairWithTimeout = Callable[[float | None], Awaitable[None]]
|
|
|
30
30
|
class ConnectionResult:
|
|
31
31
|
"""Result of an explicit connection strategy.
|
|
32
32
|
|
|
33
|
+
Args:
|
|
34
|
+
route: Connection path that produced the result.
|
|
35
|
+
status: Outcome of the connection attempt.
|
|
36
|
+
peer_address: Address of the bonded peer used for reconnect, when one was selected.
|
|
37
|
+
peer_count: Number of bonded peers observed while selecting a reconnect target.
|
|
38
|
+
|
|
33
39
|
Attributes:
|
|
34
40
|
route: Connection path that produced the result.
|
|
35
41
|
status: Outcome of the connection attempt.
|