swbt-python 0.1.1__py3-none-any.whl → 0.3.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.
Files changed (43) hide show
  1. swbt/__init__.py +19 -6
  2. swbt/_testing/__init__.py +1 -0
  3. swbt/_testing/gamepad.py +74 -0
  4. swbt/adapter_discovery.py +231 -0
  5. swbt/diagnostics.py +12 -2
  6. swbt/errors.py +67 -0
  7. swbt/gamepad/__init__.py +10 -4
  8. swbt/gamepad/_config.py +101 -0
  9. swbt/gamepad/connection.py +6 -0
  10. swbt/gamepad/constants.py +3 -0
  11. swbt/gamepad/core.py +147 -435
  12. swbt/gamepad/interface.py +273 -0
  13. swbt/gamepad/output.py +38 -4
  14. swbt/gamepad/runtime.py +768 -0
  15. swbt/gamepad/transport_factory.py +55 -0
  16. swbt/imu.py +116 -0
  17. swbt/input.py +142 -0
  18. swbt/probe.py +79 -4
  19. swbt/protocol/buttons.py +63 -0
  20. swbt/protocol/{profile.py → descriptors.py} +1 -13
  21. swbt/protocol/imu_report.py +186 -0
  22. swbt/protocol/input_report.py +28 -45
  23. swbt/protocol/profiles/__init__.py +3 -0
  24. swbt/protocol/profiles/base.py +250 -0
  25. swbt/protocol/profiles/joycon.py +84 -0
  26. swbt/protocol/profiles/pro_controller.py +25 -0
  27. swbt/protocol/session.py +112 -0
  28. swbt/protocol/spi.py +25 -3
  29. swbt/protocol/subcommand.py +108 -10
  30. swbt/report_loop.py +19 -5
  31. swbt/state_store.py +15 -0
  32. swbt/transport/_bumble_sdp.py +98 -61
  33. swbt/transport/_bumble_usb_devices.py +33 -0
  34. swbt/transport/base.py +101 -13
  35. swbt/transport/bumble.py +68 -9
  36. swbt/transport/fake.py +8 -0
  37. swbt_python-0.3.0.dist-info/METADATA +150 -0
  38. swbt_python-0.3.0.dist-info/RECORD +50 -0
  39. swbt_python-0.1.1.dist-info/METADATA +0 -122
  40. swbt_python-0.1.1.dist-info/RECORD +0 -34
  41. {swbt_python-0.1.1.dist-info → swbt_python-0.3.0.dist-info}/WHEEL +0 -0
  42. {swbt_python-0.1.1.dist-info → swbt_python-0.3.0.dist-info}/entry_points.txt +0 -0
  43. {swbt_python-0.1.1.dist-info → swbt_python-0.3.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.transport.base import BondedPeer, DisconnectRequestResult, HidDeviceTransport
24
+ from swbt.protocol.profiles.base import ControllerColors
16
25
 
17
26
  __all__ = (
18
- "BondedPeer",
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."""
@@ -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 SwitchGamepad.
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.core import SwitchGamepad, SwitchGamepadConfig
5
-
6
- DISCONNECT_REQUEST_TIMEOUT_SECONDS = 0.25
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
  )
@@ -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
+ )
@@ -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.
@@ -0,0 +1,3 @@
1
+ """Constants for the gamepad package."""
2
+
3
+ DISCONNECT_REQUEST_TIMEOUT_SECONDS = 0.25