swbt-python 0.3.0__py3-none-any.whl → 0.4.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 +10 -0
- swbt/_testing/gamepad.py +72 -1
- swbt/gamepad/__init__.py +12 -0
- swbt/gamepad/core.py +105 -28
- swbt/gamepad/interface.py +55 -26
- swbt/gamepad/output.py +7 -5
- swbt/gamepad/runtime.py +141 -19
- swbt/report_loop.py +123 -52
- {swbt_python-0.3.0.dist-info → swbt_python-0.4.0.dist-info}/METADATA +1 -1
- {swbt_python-0.3.0.dist-info → swbt_python-0.4.0.dist-info}/RECORD +13 -13
- {swbt_python-0.3.0.dist-info → swbt_python-0.4.0.dist-info}/WHEEL +0 -0
- {swbt_python-0.3.0.dist-info → swbt_python-0.4.0.dist-info}/entry_points.txt +0 -0
- {swbt_python-0.3.0.dist-info → swbt_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
swbt/__init__.py
CHANGED
|
@@ -15,8 +15,13 @@ from swbt.errors import (
|
|
|
15
15
|
)
|
|
16
16
|
from swbt.gamepad import (
|
|
17
17
|
ConnectionResult,
|
|
18
|
+
DirectJoyConL,
|
|
19
|
+
DirectJoyConR,
|
|
20
|
+
DirectProController,
|
|
21
|
+
DirectSwitchGamepad,
|
|
18
22
|
JoyConL,
|
|
19
23
|
JoyConR,
|
|
24
|
+
PeriodicSwitchGamepad,
|
|
20
25
|
ProController,
|
|
21
26
|
SwitchGamepad,
|
|
22
27
|
)
|
|
@@ -33,6 +38,10 @@ __all__ = (
|
|
|
33
38
|
"ConnectionTimeoutError",
|
|
34
39
|
"ControllerColors",
|
|
35
40
|
"DiagnosticsConfig",
|
|
41
|
+
"DirectJoyConL",
|
|
42
|
+
"DirectJoyConR",
|
|
43
|
+
"DirectProController",
|
|
44
|
+
"DirectSwitchGamepad",
|
|
36
45
|
"GamepadStatus",
|
|
37
46
|
"IMUFrame",
|
|
38
47
|
"InputState",
|
|
@@ -40,6 +49,7 @@ __all__ = (
|
|
|
40
49
|
"InvalidKeyStoreError",
|
|
41
50
|
"JoyConL",
|
|
42
51
|
"JoyConR",
|
|
52
|
+
"PeriodicSwitchGamepad",
|
|
43
53
|
"ProController",
|
|
44
54
|
"Stick",
|
|
45
55
|
"SwbtError",
|
swbt/_testing/gamepad.py
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
"""Internal gamepad constructors for tests that inject fake transports."""
|
|
2
2
|
|
|
3
|
-
from swbt import
|
|
3
|
+
from swbt import (
|
|
4
|
+
ControllerColors,
|
|
5
|
+
DiagnosticsConfig,
|
|
6
|
+
DirectJoyConL,
|
|
7
|
+
DirectJoyConR,
|
|
8
|
+
DirectProController,
|
|
9
|
+
JoyConL,
|
|
10
|
+
JoyConR,
|
|
11
|
+
ProController,
|
|
12
|
+
)
|
|
4
13
|
from swbt.gamepad._config import _SwitchGamepadConfig
|
|
5
14
|
from swbt.protocol.profiles.joycon import JoyConLeftProfile, JoyConRightProfile
|
|
6
15
|
from swbt.transport.base import HidDeviceTransport
|
|
@@ -72,3 +81,65 @@ def make_joycon_r(
|
|
|
72
81
|
diagnostics=diagnostics,
|
|
73
82
|
transport=transport,
|
|
74
83
|
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_direct_pro_controller(
|
|
87
|
+
*,
|
|
88
|
+
transport: HidDeviceTransport,
|
|
89
|
+
adapter: str | None = None,
|
|
90
|
+
key_store_path: str | None = None,
|
|
91
|
+
controller_colors: ControllerColors | None = None,
|
|
92
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
93
|
+
) -> DirectProController:
|
|
94
|
+
"""Create a Direct Pro Controller with an injected transport for tests."""
|
|
95
|
+
return DirectProController._from_config(
|
|
96
|
+
_SwitchGamepadConfig(
|
|
97
|
+
adapter=adapter,
|
|
98
|
+
key_store_path=key_store_path,
|
|
99
|
+
controller_colors=controller_colors,
|
|
100
|
+
),
|
|
101
|
+
diagnostics=diagnostics,
|
|
102
|
+
transport=transport,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def make_direct_joycon_l(
|
|
107
|
+
*,
|
|
108
|
+
transport: HidDeviceTransport,
|
|
109
|
+
adapter: str | None = None,
|
|
110
|
+
key_store_path: str | None = None,
|
|
111
|
+
controller_colors: ControllerColors | None = None,
|
|
112
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
113
|
+
) -> DirectJoyConL:
|
|
114
|
+
"""Create a Direct Joy-Con L with an injected transport for tests."""
|
|
115
|
+
return DirectJoyConL._from_config(
|
|
116
|
+
_SwitchGamepadConfig(
|
|
117
|
+
adapter=adapter,
|
|
118
|
+
key_store_path=key_store_path,
|
|
119
|
+
controller_colors=controller_colors,
|
|
120
|
+
profile=JoyConLeftProfile(),
|
|
121
|
+
),
|
|
122
|
+
diagnostics=diagnostics,
|
|
123
|
+
transport=transport,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def make_direct_joycon_r(
|
|
128
|
+
*,
|
|
129
|
+
transport: HidDeviceTransport,
|
|
130
|
+
adapter: str | None = None,
|
|
131
|
+
key_store_path: str | None = None,
|
|
132
|
+
controller_colors: ControllerColors | None = None,
|
|
133
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
134
|
+
) -> DirectJoyConR:
|
|
135
|
+
"""Create a Direct Joy-Con R with an injected transport for tests."""
|
|
136
|
+
return DirectJoyConR._from_config(
|
|
137
|
+
_SwitchGamepadConfig(
|
|
138
|
+
adapter=adapter,
|
|
139
|
+
key_store_path=key_store_path,
|
|
140
|
+
controller_colors=controller_colors,
|
|
141
|
+
profile=JoyConRightProfile(),
|
|
142
|
+
),
|
|
143
|
+
diagnostics=diagnostics,
|
|
144
|
+
transport=transport,
|
|
145
|
+
)
|
swbt/gamepad/__init__.py
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
from swbt.gamepad.connection import ConnectionResult, ConnectionStatus
|
|
4
4
|
from swbt.gamepad.constants import DISCONNECT_REQUEST_TIMEOUT_SECONDS
|
|
5
5
|
from swbt.gamepad.core import (
|
|
6
|
+
DirectJoyConL,
|
|
7
|
+
DirectJoyConR,
|
|
8
|
+
DirectProController,
|
|
6
9
|
JoyConL,
|
|
7
10
|
JoyConR,
|
|
8
11
|
ProController,
|
|
12
|
+
)
|
|
13
|
+
from swbt.gamepad.interface import (
|
|
14
|
+
DirectSwitchGamepad,
|
|
15
|
+
PeriodicSwitchGamepad,
|
|
9
16
|
SwitchGamepad,
|
|
10
17
|
)
|
|
11
18
|
|
|
@@ -13,8 +20,13 @@ __all__ = (
|
|
|
13
20
|
"DISCONNECT_REQUEST_TIMEOUT_SECONDS",
|
|
14
21
|
"ConnectionResult",
|
|
15
22
|
"ConnectionStatus",
|
|
23
|
+
"DirectJoyConL",
|
|
24
|
+
"DirectJoyConR",
|
|
25
|
+
"DirectProController",
|
|
26
|
+
"DirectSwitchGamepad",
|
|
16
27
|
"JoyConL",
|
|
17
28
|
"JoyConR",
|
|
29
|
+
"PeriodicSwitchGamepad",
|
|
18
30
|
"ProController",
|
|
19
31
|
"SwitchGamepad",
|
|
20
32
|
)
|
swbt/gamepad/core.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Public gamepad API."""
|
|
2
2
|
|
|
3
|
-
from typing import Self
|
|
3
|
+
from typing import ClassVar, Literal, Self
|
|
4
4
|
|
|
5
5
|
from swbt.diagnostics import DiagnosticsConfig, GamepadStatus
|
|
6
6
|
from swbt.errors import InvalidInputError
|
|
7
7
|
from swbt.gamepad._config import _ControllerSpec, _SwitchGamepadConfig
|
|
8
8
|
from swbt.gamepad.connection import ConnectionResult
|
|
9
|
-
from swbt.gamepad.interface import
|
|
9
|
+
from swbt.gamepad.interface import (
|
|
10
|
+
DirectSwitchGamepad,
|
|
11
|
+
PeriodicSwitchGamepad,
|
|
12
|
+
)
|
|
10
13
|
from swbt.gamepad.output import OutputReportDispatcher
|
|
11
14
|
from swbt.gamepad.runtime import ControllerRuntime
|
|
12
15
|
from swbt.input import Button, IMUFrame, InputState, Stick
|
|
@@ -17,7 +20,7 @@ from swbt.state_store import InputStateStore
|
|
|
17
20
|
from swbt.transport.base import HidDeviceTransport
|
|
18
21
|
|
|
19
22
|
|
|
20
|
-
class _RuntimeBackedGamepad
|
|
23
|
+
class _RuntimeBackedGamepad:
|
|
21
24
|
"""Runtime-backed concrete gamepad base.
|
|
22
25
|
|
|
23
26
|
The object owns the public API surface and delegates stateful
|
|
@@ -25,6 +28,7 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
25
28
|
"""
|
|
26
29
|
|
|
27
30
|
_controller_spec = _ControllerSpec(profile=default_controller_profile())
|
|
31
|
+
_reporting_mode: ClassVar[Literal["periodic", "direct"]] = "periodic"
|
|
28
32
|
|
|
29
33
|
def __init__(
|
|
30
34
|
self,
|
|
@@ -65,6 +69,7 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
65
69
|
self._runtime = ControllerRuntime.from_config(
|
|
66
70
|
config,
|
|
67
71
|
diagnostics=diagnostics,
|
|
72
|
+
reporting_mode=self._reporting_mode,
|
|
68
73
|
transport=transport,
|
|
69
74
|
)
|
|
70
75
|
|
|
@@ -113,8 +118,9 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
113
118
|
async def open(self) -> None:
|
|
114
119
|
"""Open the configured transport.
|
|
115
120
|
|
|
116
|
-
Opening prepares transport callbacks, diagnostics metadata, and the
|
|
117
|
-
|
|
121
|
+
Opening prepares transport callbacks, diagnostics metadata, and the
|
|
122
|
+
reporting-type resources. It does not start HID advertising, pairing,
|
|
123
|
+
or active reconnect.
|
|
118
124
|
|
|
119
125
|
Raises:
|
|
120
126
|
TransportOpenError: Raised by the transport when the adapter cannot be opened.
|
|
@@ -218,22 +224,13 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
218
224
|
Args:
|
|
219
225
|
buttons: Buttons to add to the current button set.
|
|
220
226
|
|
|
221
|
-
|
|
227
|
+
A periodic controller commits local state. A direct controller sends one
|
|
228
|
+
input report and commits only after transmission succeeds.
|
|
222
229
|
"""
|
|
223
230
|
await self._runtime.press(*buttons)
|
|
224
231
|
|
|
225
|
-
async def apply(self, state: InputState) -> None:
|
|
226
|
-
"""Replace the current input state without immediate transmission.
|
|
227
|
-
|
|
228
|
-
Args:
|
|
229
|
-
state: Complete input state to commit.
|
|
230
|
-
|
|
231
|
-
This updates local state only and does not send an immediate input report.
|
|
232
|
-
"""
|
|
233
|
-
await self._runtime.apply(state)
|
|
234
|
-
|
|
235
232
|
async def sticks(self, *, left: Stick | None = None, right: Stick | None = None) -> None:
|
|
236
|
-
"""Replace one or both stick positions
|
|
233
|
+
"""Replace one or both stick positions according to the reporting type.
|
|
237
234
|
|
|
238
235
|
Args:
|
|
239
236
|
left: Optional replacement for the left stick.
|
|
@@ -242,12 +239,13 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
242
239
|
Raises:
|
|
243
240
|
InvalidInputError: ``left`` or ``right`` is not a ``Stick``.
|
|
244
241
|
|
|
245
|
-
|
|
242
|
+
A periodic controller commits local state. A direct controller sends one
|
|
243
|
+
input report and commits only after transmission succeeds.
|
|
246
244
|
"""
|
|
247
245
|
await self._runtime.sticks(left=left, right=right)
|
|
248
246
|
|
|
249
247
|
async def lstick(self, stick: Stick) -> None:
|
|
250
|
-
"""Replace the left stick position
|
|
248
|
+
"""Replace the left stick position according to the reporting type.
|
|
251
249
|
|
|
252
250
|
Args:
|
|
253
251
|
stick: Replacement for the left stick.
|
|
@@ -255,12 +253,13 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
255
253
|
Raises:
|
|
256
254
|
InvalidInputError: ``stick`` is not a ``Stick``.
|
|
257
255
|
|
|
258
|
-
|
|
256
|
+
A periodic controller commits local state. A direct controller sends one
|
|
257
|
+
input report and commits only after transmission succeeds.
|
|
259
258
|
"""
|
|
260
259
|
await self._runtime.lstick(stick)
|
|
261
260
|
|
|
262
261
|
async def rstick(self, stick: Stick) -> None:
|
|
263
|
-
"""Replace the right stick position
|
|
262
|
+
"""Replace the right stick position according to the reporting type.
|
|
264
263
|
|
|
265
264
|
Args:
|
|
266
265
|
stick: Replacement for the right stick.
|
|
@@ -268,12 +267,13 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
268
267
|
Raises:
|
|
269
268
|
InvalidInputError: ``stick`` is not a ``Stick``.
|
|
270
269
|
|
|
271
|
-
|
|
270
|
+
A periodic controller commits local state. A direct controller sends one
|
|
271
|
+
input report and commits only after transmission succeeds.
|
|
272
272
|
"""
|
|
273
273
|
await self._runtime.rstick(stick)
|
|
274
274
|
|
|
275
275
|
async def imu(self, *frames: IMUFrame) -> None:
|
|
276
|
-
"""Replace IMU frames
|
|
276
|
+
"""Replace IMU frames according to the reporting type.
|
|
277
277
|
|
|
278
278
|
Args:
|
|
279
279
|
frames: One ``IMUFrame`` to repeat across all three IMU slots, or exactly
|
|
@@ -293,12 +293,13 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
293
293
|
Args:
|
|
294
294
|
buttons: Buttons to remove from the current button set.
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
A periodic controller commits local state. A direct controller sends one
|
|
297
|
+
input report and commits only after transmission succeeds.
|
|
297
298
|
"""
|
|
298
299
|
await self._runtime.release(*buttons)
|
|
299
300
|
|
|
300
301
|
async def neutral(self) -> None:
|
|
301
|
-
"""
|
|
302
|
+
"""Apply ``InputState.neutral()`` according to the reporting type."""
|
|
302
303
|
await self._runtime.neutral()
|
|
303
304
|
|
|
304
305
|
async def tap(self, *buttons: Button, duration: float = 0.08) -> None:
|
|
@@ -327,19 +328,77 @@ class _RuntimeBackedGamepad(SwitchGamepad):
|
|
|
327
328
|
def snapshot(self) -> InputState:
|
|
328
329
|
"""Return the latest committed input state.
|
|
329
330
|
|
|
331
|
+
A periodic controller returns its latest local state. A direct
|
|
332
|
+
controller returns the last state sent successfully.
|
|
333
|
+
|
|
330
334
|
Returns:
|
|
331
335
|
InputState: Immutable snapshot of the current input state.
|
|
332
336
|
"""
|
|
333
337
|
return self._runtime.snapshot()
|
|
334
338
|
|
|
335
339
|
|
|
336
|
-
class
|
|
340
|
+
class _PeriodicRuntimeBackedGamepad(_RuntimeBackedGamepad, PeriodicSwitchGamepad):
|
|
341
|
+
"""Runtime-backed gamepad with library-owned periodic input transmission."""
|
|
342
|
+
|
|
343
|
+
async def apply(self, state: InputState) -> None:
|
|
344
|
+
"""Replace the current input state without immediate transmission.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
state: Complete input state to commit.
|
|
348
|
+
|
|
349
|
+
This updates local state only and does not send an immediate input report.
|
|
350
|
+
"""
|
|
351
|
+
await self._runtime.apply(state)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class _DirectRuntimeBackedGamepad(_RuntimeBackedGamepad, DirectSwitchGamepad):
|
|
355
|
+
"""Runtime-backed gamepad with caller-owned input transmission."""
|
|
356
|
+
|
|
357
|
+
_reporting_mode = "direct"
|
|
358
|
+
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
*,
|
|
362
|
+
adapter: str | None = None,
|
|
363
|
+
key_store_path: str | None = None,
|
|
364
|
+
controller_colors: ControllerColors | None = None,
|
|
365
|
+
diagnostics: DiagnosticsConfig | None = None,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Create a direct-reporting gamepad object.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
adapter: Bumble adapter moniker used for the Bluetooth backend.
|
|
371
|
+
key_store_path: Optional path used by the Bluetooth backend to persist keys.
|
|
372
|
+
controller_colors: Optional fixed controller body, button, and grip colors.
|
|
373
|
+
diagnostics: Optional diagnostics configuration for trace output.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
InvalidInputError: ``adapter`` is omitted.
|
|
377
|
+
"""
|
|
378
|
+
config = self._controller_spec.build_config(
|
|
379
|
+
adapter=adapter,
|
|
380
|
+
key_store_path=key_store_path,
|
|
381
|
+
report_period_us=None,
|
|
382
|
+
controller_colors=controller_colors,
|
|
383
|
+
)
|
|
384
|
+
self._init_from_config(config, diagnostics=diagnostics, transport=None)
|
|
385
|
+
|
|
386
|
+
async def send(self, state: InputState) -> None:
|
|
387
|
+
"""Send one complete input state and commit it after transmission.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
state: Complete input state to send.
|
|
391
|
+
"""
|
|
392
|
+
await self._runtime.send(state)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class ProController(_PeriodicRuntimeBackedGamepad):
|
|
337
396
|
"""Runtime-backed Pro Controller-compatible gamepad."""
|
|
338
397
|
|
|
339
398
|
_controller_spec = _ControllerSpec(profile=default_controller_profile())
|
|
340
399
|
|
|
341
400
|
|
|
342
|
-
class JoyConL(
|
|
401
|
+
class JoyConL(_PeriodicRuntimeBackedGamepad):
|
|
343
402
|
"""Runtime-backed Joy-Con L-compatible gamepad."""
|
|
344
403
|
|
|
345
404
|
_controller_spec = _ControllerSpec(profile=JoyConLeftProfile())
|
|
@@ -374,7 +433,7 @@ class JoyConL(_RuntimeBackedGamepad):
|
|
|
374
433
|
self._init_from_config(config, diagnostics=diagnostics, transport=None)
|
|
375
434
|
|
|
376
435
|
|
|
377
|
-
class JoyConR(
|
|
436
|
+
class JoyConR(_PeriodicRuntimeBackedGamepad):
|
|
378
437
|
"""Runtime-backed Joy-Con R-compatible gamepad."""
|
|
379
438
|
|
|
380
439
|
_controller_spec = _ControllerSpec(profile=JoyConRightProfile())
|
|
@@ -407,3 +466,21 @@ class JoyConR(_RuntimeBackedGamepad):
|
|
|
407
466
|
controller_colors=controller_colors,
|
|
408
467
|
)
|
|
409
468
|
self._init_from_config(config, diagnostics=diagnostics, transport=None)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class DirectProController(_DirectRuntimeBackedGamepad):
|
|
472
|
+
"""Direct-reporting Pro Controller-compatible gamepad."""
|
|
473
|
+
|
|
474
|
+
_controller_spec = _ControllerSpec(profile=default_controller_profile())
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class DirectJoyConL(_DirectRuntimeBackedGamepad):
|
|
478
|
+
"""Direct-reporting Joy-Con L-compatible gamepad."""
|
|
479
|
+
|
|
480
|
+
_controller_spec = _ControllerSpec(profile=JoyConLeftProfile())
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class DirectJoyConR(_DirectRuntimeBackedGamepad):
|
|
484
|
+
"""Direct-reporting Joy-Con R-compatible gamepad."""
|
|
485
|
+
|
|
486
|
+
_controller_spec = _ControllerSpec(profile=JoyConRightProfile())
|
swbt/gamepad/interface.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from types import TracebackType
|
|
5
|
+
from typing import Self
|
|
5
6
|
|
|
6
7
|
from swbt.diagnostics import GamepadStatus
|
|
7
8
|
from swbt.gamepad.connection import ConnectionResult
|
|
@@ -9,14 +10,15 @@ from swbt.input import Button, IMUFrame, InputState, Stick
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class SwitchGamepad(ABC):
|
|
12
|
-
"""Shared public interface for NX-compatible virtual gamepads.
|
|
13
|
+
"""Shared abstract public interface for NX-compatible virtual gamepads.
|
|
13
14
|
|
|
14
|
-
Use
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
Use ``PeriodicSwitchGamepad`` or ``DirectSwitchGamepad`` when a type
|
|
16
|
+
annotation must express who owns the input-report schedule. Common input
|
|
17
|
+
operations commit local state on periodic gamepads; on direct gamepads they
|
|
18
|
+
send one input report and commit only after transmission succeeds.
|
|
17
19
|
"""
|
|
18
20
|
|
|
19
|
-
async def __aenter__(self) ->
|
|
21
|
+
async def __aenter__(self) -> Self:
|
|
20
22
|
"""Open the gamepad for an async context manager.
|
|
21
23
|
|
|
22
24
|
Returns:
|
|
@@ -45,8 +47,9 @@ class SwitchGamepad(ABC):
|
|
|
45
47
|
async def open(self) -> None:
|
|
46
48
|
"""Open the configured transport.
|
|
47
49
|
|
|
48
|
-
Opening prepares transport callbacks, diagnostics metadata, and the
|
|
49
|
-
|
|
50
|
+
Opening prepares transport callbacks, diagnostics metadata, and the
|
|
51
|
+
reporting-type resources. It does not start HID advertising, pairing,
|
|
52
|
+
or active reconnect.
|
|
50
53
|
|
|
51
54
|
Raises:
|
|
52
55
|
TransportOpenError: The configured transport cannot be opened.
|
|
@@ -160,25 +163,13 @@ class SwitchGamepad(ABC):
|
|
|
160
163
|
InvalidInputError: Any value is not a ``Button``.
|
|
161
164
|
UnsupportedInputError: The controller profile does not support a button.
|
|
162
165
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@abstractmethod
|
|
167
|
-
async def apply(self, state: InputState) -> None:
|
|
168
|
-
"""Replace the current input state without immediate transmission.
|
|
169
|
-
|
|
170
|
-
Args:
|
|
171
|
-
state: Complete input state to commit.
|
|
172
|
-
|
|
173
|
-
Raises:
|
|
174
|
-
InvalidInputError: ``state`` is not an ``InputState``.
|
|
175
|
-
UnsupportedInputError: The controller profile does not support part of
|
|
176
|
-
the supplied state.
|
|
166
|
+
Completion follows the reporting type: periodic gamepads commit local
|
|
167
|
+
state, while direct gamepads send one input report and then commit.
|
|
177
168
|
"""
|
|
178
169
|
|
|
179
170
|
@abstractmethod
|
|
180
171
|
async def sticks(self, *, left: Stick | None = None, right: Stick | None = None) -> None:
|
|
181
|
-
"""Replace one or both stick positions
|
|
172
|
+
"""Replace one or both stick positions according to the reporting type.
|
|
182
173
|
|
|
183
174
|
Args:
|
|
184
175
|
left: Optional replacement for the left stick.
|
|
@@ -191,7 +182,7 @@ class SwitchGamepad(ABC):
|
|
|
191
182
|
|
|
192
183
|
@abstractmethod
|
|
193
184
|
async def lstick(self, stick: Stick) -> None:
|
|
194
|
-
"""Replace the left stick position
|
|
185
|
+
"""Replace the left stick position according to the reporting type.
|
|
195
186
|
|
|
196
187
|
Args:
|
|
197
188
|
stick: Replacement for the left stick.
|
|
@@ -203,7 +194,7 @@ class SwitchGamepad(ABC):
|
|
|
203
194
|
|
|
204
195
|
@abstractmethod
|
|
205
196
|
async def rstick(self, stick: Stick) -> None:
|
|
206
|
-
"""Replace the right stick position
|
|
197
|
+
"""Replace the right stick position according to the reporting type.
|
|
207
198
|
|
|
208
199
|
Args:
|
|
209
200
|
stick: Replacement for the right stick.
|
|
@@ -215,7 +206,7 @@ class SwitchGamepad(ABC):
|
|
|
215
206
|
|
|
216
207
|
@abstractmethod
|
|
217
208
|
async def imu(self, *frames: IMUFrame) -> None:
|
|
218
|
-
"""Replace IMU frames
|
|
209
|
+
"""Replace IMU frames according to the reporting type.
|
|
219
210
|
|
|
220
211
|
Args:
|
|
221
212
|
frames: One ``IMUFrame`` to repeat across all three IMU slots, or exactly
|
|
@@ -240,7 +231,7 @@ class SwitchGamepad(ABC):
|
|
|
240
231
|
|
|
241
232
|
@abstractmethod
|
|
242
233
|
async def neutral(self) -> None:
|
|
243
|
-
"""
|
|
234
|
+
"""Apply ``InputState.neutral()`` according to the reporting type."""
|
|
244
235
|
|
|
245
236
|
@abstractmethod
|
|
246
237
|
async def tap(self, *buttons: Button, duration: float = 0.08) -> None:
|
|
@@ -268,6 +259,44 @@ class SwitchGamepad(ABC):
|
|
|
268
259
|
def snapshot(self) -> InputState:
|
|
269
260
|
"""Return the latest committed input state.
|
|
270
261
|
|
|
262
|
+
A periodic gamepad returns its latest local state. A direct gamepad
|
|
263
|
+
returns the last state whose input report was sent successfully.
|
|
264
|
+
|
|
271
265
|
Returns:
|
|
272
266
|
InputState: Immutable snapshot of the current input state.
|
|
273
267
|
"""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class PeriodicSwitchGamepad(SwitchGamepad):
|
|
271
|
+
"""Abstract gamepad whose input report schedule is owned by the library."""
|
|
272
|
+
|
|
273
|
+
@abstractmethod
|
|
274
|
+
async def apply(self, state: InputState) -> None:
|
|
275
|
+
"""Replace the current local input state without immediate transmission.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
state: Complete input state to commit.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
InvalidInputError: ``state`` is not an ``InputState``.
|
|
282
|
+
UnsupportedInputError: The controller profile does not support part of
|
|
283
|
+
the supplied state.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class DirectSwitchGamepad(SwitchGamepad):
|
|
288
|
+
"""Abstract gamepad whose input report schedule is owned by the caller."""
|
|
289
|
+
|
|
290
|
+
@abstractmethod
|
|
291
|
+
async def send(self, state: InputState) -> None:
|
|
292
|
+
"""Send one complete input state and commit it after transmission.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
state: Complete input state to send.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ClosedError: The gamepad is not connected.
|
|
299
|
+
InvalidInputError: ``state`` is not an ``InputState``.
|
|
300
|
+
UnsupportedInputError: The controller profile does not support part of
|
|
301
|
+
the supplied state.
|
|
302
|
+
"""
|
swbt/gamepad/output.py
CHANGED
|
@@ -13,7 +13,7 @@ from swbt.protocol.subcommand import (
|
|
|
13
13
|
)
|
|
14
14
|
from swbt.state_store import InputStateStore
|
|
15
15
|
|
|
16
|
-
ReplyBuilder = Callable[[], bytes]
|
|
16
|
+
ReplyBuilder = Callable[[], bytes | Awaitable[bytes]]
|
|
17
17
|
ReplySender = Callable[[ReplyBuilder], Awaitable[bytes]]
|
|
18
18
|
ReplySenderRequirement = Callable[[], None]
|
|
19
19
|
|
|
@@ -50,15 +50,17 @@ class OutputReportDispatcher:
|
|
|
50
50
|
subcommand_id=output_report.subcommand_id,
|
|
51
51
|
)
|
|
52
52
|
self.require_reply_sender()
|
|
53
|
-
state = await self.state_store.snapshot()
|
|
54
53
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
|
|
55
|
+
async def build_reply() -> bytes:
|
|
56
|
+
state = await self.state_store.snapshot()
|
|
57
|
+
return self.subcommand_responder.respond(
|
|
57
58
|
output_report,
|
|
58
59
|
state=state,
|
|
59
60
|
session=self.session,
|
|
60
61
|
)
|
|
61
|
-
|
|
62
|
+
|
|
63
|
+
reply = await self.send_subcommand_reply(build_reply)
|
|
62
64
|
except UnsupportedSubcommandError:
|
|
63
65
|
self.diagnostics.record_event(
|
|
64
66
|
"unsupported_subcommand",
|
swbt/gamepad/runtime.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Stateful runtime for gamepad lifecycle and input behavior."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
from collections.abc import Callable
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
5
|
from dataclasses import replace
|
|
6
6
|
from types import TracebackType
|
|
7
|
+
from typing import Literal
|
|
7
8
|
|
|
8
9
|
import swbt.gamepad as gamepad_module
|
|
9
10
|
from swbt.diagnostics import DiagnosticsConfig, DiagnosticsRecorder, GamepadStatus
|
|
@@ -29,7 +30,7 @@ from swbt.protocol.input_report import InputReportBuilder
|
|
|
29
30
|
from swbt.protocol.profiles.base import ControllerColors
|
|
30
31
|
from swbt.protocol.session import SwitchHidSession
|
|
31
32
|
from swbt.protocol.subcommand import SubcommandResponder
|
|
32
|
-
from swbt.report_loop import ReportLoop
|
|
33
|
+
from swbt.report_loop import ReportLoop, ReportSender
|
|
33
34
|
from swbt.state_store import InputStateStore
|
|
34
35
|
from swbt.transport.base import DisconnectRequestResult, HidDeviceTransport
|
|
35
36
|
|
|
@@ -83,6 +84,7 @@ class ControllerRuntime:
|
|
|
83
84
|
config: _SwitchGamepadConfig,
|
|
84
85
|
*,
|
|
85
86
|
diagnostics: DiagnosticsConfig | None,
|
|
87
|
+
reporting_mode: Literal["periodic", "direct"] = "periodic",
|
|
86
88
|
transport: HidDeviceTransport | None,
|
|
87
89
|
) -> None:
|
|
88
90
|
runtime_config = _RuntimeConfig.from_public_config(config)
|
|
@@ -90,6 +92,7 @@ class ControllerRuntime:
|
|
|
90
92
|
msg = "adapter is required when no custom transport is supplied"
|
|
91
93
|
raise InvalidInputError(msg)
|
|
92
94
|
self._config = runtime_config
|
|
95
|
+
self._reporting_mode = reporting_mode
|
|
93
96
|
self._transport = transport
|
|
94
97
|
self._transport_was_injected = transport is not None
|
|
95
98
|
if transport is None:
|
|
@@ -117,6 +120,8 @@ class ControllerRuntime:
|
|
|
117
120
|
),
|
|
118
121
|
)
|
|
119
122
|
self._report_loop: ReportLoop | None = None
|
|
123
|
+
self._report_sender: ReportSender | None = None
|
|
124
|
+
self._input_operation_lock = asyncio.Lock()
|
|
120
125
|
self._lifecycle_lock = asyncio.Lock()
|
|
121
126
|
self._connected_event = asyncio.Event()
|
|
122
127
|
self._disconnect_event = asyncio.Event()
|
|
@@ -143,6 +148,7 @@ class ControllerRuntime:
|
|
|
143
148
|
config: _SwitchGamepadConfig,
|
|
144
149
|
*,
|
|
145
150
|
diagnostics: DiagnosticsConfig | None = None,
|
|
151
|
+
reporting_mode: Literal["periodic", "direct"] = "periodic",
|
|
146
152
|
transport: HidDeviceTransport | None = None,
|
|
147
153
|
) -> "ControllerRuntime":
|
|
148
154
|
"""Create a runtime from an explicit resource configuration.
|
|
@@ -150,6 +156,7 @@ class ControllerRuntime:
|
|
|
150
156
|
Args:
|
|
151
157
|
config: Resource configuration for the runtime.
|
|
152
158
|
diagnostics: Optional diagnostics configuration for trace output.
|
|
159
|
+
reporting_mode: Internal owner of normal input report scheduling.
|
|
153
160
|
transport: Optional HID transport instance.
|
|
154
161
|
|
|
155
162
|
Returns:
|
|
@@ -159,6 +166,7 @@ class ControllerRuntime:
|
|
|
159
166
|
runtime._init_from_config(
|
|
160
167
|
config,
|
|
161
168
|
diagnostics=diagnostics,
|
|
169
|
+
reporting_mode=reporting_mode,
|
|
162
170
|
transport=transport,
|
|
163
171
|
)
|
|
164
172
|
return runtime
|
|
@@ -210,22 +218,36 @@ class ControllerRuntime:
|
|
|
210
218
|
try:
|
|
211
219
|
await transport.open()
|
|
212
220
|
self._configure_device_info_bluetooth_address(transport)
|
|
213
|
-
|
|
221
|
+
report_sender = ReportSender(
|
|
214
222
|
transport=transport,
|
|
215
|
-
state_store=self._state_store,
|
|
216
|
-
report_period_us=self._config.report_period_us,
|
|
217
223
|
input_report_builder=InputReportBuilder(
|
|
218
224
|
self._controller_profile,
|
|
219
225
|
),
|
|
220
226
|
session=self._protocol_session,
|
|
221
227
|
diagnostics=self._diagnostics,
|
|
222
228
|
)
|
|
229
|
+
self._report_sender = report_sender
|
|
230
|
+
if self._reporting_mode == "periodic":
|
|
231
|
+
self._report_loop = ReportLoop(
|
|
232
|
+
transport=transport,
|
|
233
|
+
state_store=self._state_store,
|
|
234
|
+
report_period_us=self._config.report_period_us,
|
|
235
|
+
input_report_builder=InputReportBuilder(
|
|
236
|
+
self._controller_profile,
|
|
237
|
+
),
|
|
238
|
+
session=self._protocol_session,
|
|
239
|
+
diagnostics=self._diagnostics,
|
|
240
|
+
sender=report_sender,
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
self._report_loop = None
|
|
223
244
|
self._connection_state = "opened"
|
|
224
245
|
self._is_open = True
|
|
225
246
|
except Exception:
|
|
226
247
|
self._connection_state = "failed"
|
|
227
248
|
await transport.close()
|
|
228
249
|
self._report_loop = None
|
|
250
|
+
self._report_sender = None
|
|
229
251
|
self._is_open = False
|
|
230
252
|
raise
|
|
231
253
|
|
|
@@ -393,6 +415,7 @@ class ControllerRuntime:
|
|
|
393
415
|
)
|
|
394
416
|
await self._transport.close()
|
|
395
417
|
self._report_loop = None
|
|
418
|
+
self._report_sender = None
|
|
396
419
|
self._is_open = False
|
|
397
420
|
self._connection_state = "closed"
|
|
398
421
|
finally:
|
|
@@ -406,8 +429,15 @@ class ControllerRuntime:
|
|
|
406
429
|
|
|
407
430
|
This updates local state only and does not send an immediate input report.
|
|
408
431
|
"""
|
|
432
|
+
|
|
433
|
+
def transform(current: InputState) -> InputState:
|
|
434
|
+
return current.with_buttons((*current.buttons, *buttons))
|
|
435
|
+
|
|
436
|
+
if self._reporting_mode == "direct":
|
|
437
|
+
await self._send_direct_update(transform)
|
|
438
|
+
return
|
|
409
439
|
await self._state_store.update(
|
|
410
|
-
|
|
440
|
+
transform,
|
|
411
441
|
validate=self._controller_profile.validate_input_state,
|
|
412
442
|
)
|
|
413
443
|
|
|
@@ -422,6 +452,14 @@ class ControllerRuntime:
|
|
|
422
452
|
self._controller_profile.validate_input_state(state)
|
|
423
453
|
await self._state_store.apply(state)
|
|
424
454
|
|
|
455
|
+
async def send(self, state: InputState) -> None:
|
|
456
|
+
"""Send one complete state through a direct-reporting runtime."""
|
|
457
|
+
if self._reporting_mode != "direct":
|
|
458
|
+
msg = "send is only available for direct reporting"
|
|
459
|
+
raise InvalidInputError(msg)
|
|
460
|
+
self._validate_input_state(state)
|
|
461
|
+
await self._send_direct_update(lambda _current: state)
|
|
462
|
+
|
|
425
463
|
async def sticks(self, *, left: Stick | None = None, right: Stick | None = None) -> None:
|
|
426
464
|
"""Replace one or both stick positions without immediate transmission.
|
|
427
465
|
|
|
@@ -440,8 +478,15 @@ class ControllerRuntime:
|
|
|
440
478
|
left=left is not None,
|
|
441
479
|
right=right is not None,
|
|
442
480
|
)
|
|
481
|
+
|
|
482
|
+
def transform(current: InputState) -> InputState:
|
|
483
|
+
return current.with_sticks(left_stick=left, right_stick=right)
|
|
484
|
+
|
|
485
|
+
if self._reporting_mode == "direct":
|
|
486
|
+
await self._send_direct_update(transform)
|
|
487
|
+
return
|
|
443
488
|
await self._state_store.update(
|
|
444
|
-
|
|
489
|
+
transform,
|
|
445
490
|
validate=self._controller_profile.validate_input_state,
|
|
446
491
|
)
|
|
447
492
|
|
|
@@ -484,6 +529,9 @@ class ControllerRuntime:
|
|
|
484
529
|
|
|
485
530
|
This updates local IMU state only and does not send an immediate input report.
|
|
486
531
|
"""
|
|
532
|
+
if self._reporting_mode == "direct":
|
|
533
|
+
await self._send_direct_update(lambda current: current.with_imu(*frames))
|
|
534
|
+
return
|
|
487
535
|
await self._state_store.imu(*frames)
|
|
488
536
|
|
|
489
537
|
async def release(self, *buttons: Button) -> None:
|
|
@@ -495,13 +543,23 @@ class ControllerRuntime:
|
|
|
495
543
|
This updates local state only and does not send an immediate input report.
|
|
496
544
|
"""
|
|
497
545
|
self._controller_profile.validate_buttons(buttons)
|
|
546
|
+
|
|
547
|
+
def transform(current: InputState) -> InputState:
|
|
548
|
+
return current.with_buttons(current.buttons.difference(buttons))
|
|
549
|
+
|
|
550
|
+
if self._reporting_mode == "direct":
|
|
551
|
+
await self._send_direct_update(transform)
|
|
552
|
+
return
|
|
498
553
|
await self._state_store.update(
|
|
499
|
-
|
|
554
|
+
transform,
|
|
500
555
|
validate=self._controller_profile.validate_input_state,
|
|
501
556
|
)
|
|
502
557
|
|
|
503
558
|
async def neutral(self) -> None:
|
|
504
559
|
"""Return local input state to ``InputState.neutral()`` without immediate transmission."""
|
|
560
|
+
if self._reporting_mode == "direct":
|
|
561
|
+
await self._send_direct_update(lambda _current: InputState.neutral())
|
|
562
|
+
return
|
|
505
563
|
await self._state_store.neutral()
|
|
506
564
|
|
|
507
565
|
async def tap(self, *buttons: Button, duration: float = 0.08) -> None:
|
|
@@ -517,6 +575,9 @@ class ControllerRuntime:
|
|
|
517
575
|
The tap sends immediate press and release input reports. The release step
|
|
518
576
|
removes only the buttons supplied to this call, preserving other held buttons.
|
|
519
577
|
"""
|
|
578
|
+
if self._reporting_mode == "direct":
|
|
579
|
+
await self._tap_direct(*buttons, duration=duration)
|
|
580
|
+
return
|
|
520
581
|
self._require_connected_for_input()
|
|
521
582
|
await self.press(*buttons)
|
|
522
583
|
primary_error: BaseException | None = None
|
|
@@ -588,34 +649,94 @@ class ControllerRuntime:
|
|
|
588
649
|
)
|
|
589
650
|
|
|
590
651
|
async def _send_trailing_neutral_if_connected(self) -> None:
|
|
652
|
+
if self._reporting_mode == "direct":
|
|
653
|
+
async with self._input_operation_lock:
|
|
654
|
+
if self._report_sender is None or not self._connected_event.is_set():
|
|
655
|
+
return
|
|
656
|
+
await self._send_direct_candidate(InputState.neutral())
|
|
657
|
+
return
|
|
591
658
|
await self._state_store.neutral()
|
|
592
659
|
if self._report_loop is None or not self._connected_event.is_set():
|
|
593
660
|
return
|
|
594
661
|
await self._report_loop.send_current_input()
|
|
595
662
|
|
|
596
663
|
async def _send_current_input(self) -> None:
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
raise ClosedError(msg)
|
|
600
|
-
await self._report_loop.send_current_input()
|
|
664
|
+
state = await self._state_store.snapshot()
|
|
665
|
+
await self._require_report_sender().send_input(state)
|
|
601
666
|
|
|
602
667
|
def _require_subcommand_reply_sender(self) -> None:
|
|
603
|
-
_ = self.
|
|
668
|
+
_ = self._require_report_sender()
|
|
604
669
|
|
|
605
|
-
async def _send_subcommand_reply(
|
|
606
|
-
|
|
670
|
+
async def _send_subcommand_reply(
|
|
671
|
+
self,
|
|
672
|
+
build_reply: Callable[[], bytes | Awaitable[bytes]],
|
|
673
|
+
) -> bytes:
|
|
674
|
+
if self._report_loop is not None:
|
|
675
|
+
return await self._report_loop.send_subcommand_reply(build_reply)
|
|
676
|
+
return await self._require_report_sender().send_subcommand_reply(build_reply)
|
|
607
677
|
|
|
608
|
-
def
|
|
609
|
-
if self.
|
|
678
|
+
def _require_report_sender(self) -> ReportSender:
|
|
679
|
+
if self._report_sender is None:
|
|
610
680
|
msg = "gamepad is not open"
|
|
611
681
|
raise ClosedError(msg)
|
|
612
|
-
return self.
|
|
682
|
+
return self._report_sender
|
|
613
683
|
|
|
614
684
|
def _require_connected_for_input(self) -> None:
|
|
615
|
-
if self.
|
|
685
|
+
if self._report_sender is None or not self._connected_event.is_set():
|
|
616
686
|
msg = "gamepad is not connected"
|
|
617
687
|
raise ClosedError(msg)
|
|
618
688
|
|
|
689
|
+
async def _send_direct_update(
|
|
690
|
+
self,
|
|
691
|
+
transform: Callable[[InputState], InputState],
|
|
692
|
+
) -> None:
|
|
693
|
+
async with self._input_operation_lock:
|
|
694
|
+
self._require_connected_for_input()
|
|
695
|
+
candidate = transform(self._state_store.current)
|
|
696
|
+
await self._send_direct_candidate(candidate)
|
|
697
|
+
|
|
698
|
+
async def _send_direct_candidate(self, candidate: InputState) -> None:
|
|
699
|
+
self._controller_profile.validate_input_state(candidate)
|
|
700
|
+
await self._require_report_sender().send_input(
|
|
701
|
+
candidate,
|
|
702
|
+
reason="direct",
|
|
703
|
+
commit_state_store=self._state_store,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
async def _tap_direct(self, *buttons: Button, duration: float) -> None:
|
|
707
|
+
async with self._input_operation_lock:
|
|
708
|
+
self._require_connected_for_input()
|
|
709
|
+
pressed = False
|
|
710
|
+
primary_error: BaseException | None = None
|
|
711
|
+
try:
|
|
712
|
+
press_state = self._state_store.current.with_buttons(
|
|
713
|
+
(*self._state_store.current.buttons, *buttons)
|
|
714
|
+
)
|
|
715
|
+
await self._send_direct_candidate(press_state)
|
|
716
|
+
pressed = True
|
|
717
|
+
if duration > 0:
|
|
718
|
+
await asyncio.sleep(duration)
|
|
719
|
+
except BaseException as error:
|
|
720
|
+
primary_error = error
|
|
721
|
+
raise
|
|
722
|
+
finally:
|
|
723
|
+
if pressed:
|
|
724
|
+
release_state = self._state_store.current.with_buttons(
|
|
725
|
+
self._state_store.current.buttons.difference(buttons)
|
|
726
|
+
)
|
|
727
|
+
try:
|
|
728
|
+
await self._send_direct_candidate(release_state)
|
|
729
|
+
except Exception as error:
|
|
730
|
+
self._diagnostics.record_error(error, recoverable=True)
|
|
731
|
+
if primary_error is None:
|
|
732
|
+
raise
|
|
733
|
+
|
|
734
|
+
@staticmethod
|
|
735
|
+
def _validate_input_state(state: InputState) -> None:
|
|
736
|
+
if not isinstance(state, InputState):
|
|
737
|
+
msg = "state must be an InputState"
|
|
738
|
+
raise InvalidInputError(msg)
|
|
739
|
+
|
|
619
740
|
@staticmethod
|
|
620
741
|
def _validate_stick(name: str, value: Stick | None) -> None:
|
|
621
742
|
if value is not None and not isinstance(value, Stick):
|
|
@@ -735,6 +856,7 @@ class ControllerRuntime:
|
|
|
735
856
|
if self._report_loop is not None:
|
|
736
857
|
await self._report_loop.stop()
|
|
737
858
|
self._report_loop = None
|
|
859
|
+
self._report_sender = None
|
|
738
860
|
if self._close_in_progress:
|
|
739
861
|
return
|
|
740
862
|
if self._transport is not None and self._is_open:
|
swbt/report_loop.py
CHANGED
|
@@ -2,17 +2,119 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from collections import deque
|
|
5
|
-
from collections.abc import Callable
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
6
|
from contextlib import suppress
|
|
7
|
+
from inspect import isawaitable
|
|
7
8
|
from time import monotonic_ns
|
|
9
|
+
from typing import cast
|
|
8
10
|
|
|
9
11
|
from swbt.diagnostics import DiagnosticsRecorder
|
|
12
|
+
from swbt.input import InputState
|
|
10
13
|
from swbt.protocol.input_report import InputReportBuilder
|
|
11
14
|
from swbt.protocol.session import SwitchHidSession
|
|
12
15
|
from swbt.state_store import InputStateStore
|
|
13
16
|
from swbt.transport.base import HidDeviceTransport
|
|
14
17
|
|
|
15
18
|
REPLY_PERIODIC_HOLDOFF_SECONDS = 0.3
|
|
19
|
+
ReplyBuilder = Callable[[], bytes | Awaitable[bytes]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReportSender:
|
|
23
|
+
"""Serialize input reports and subcommand replies for one HID session."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
transport: HidDeviceTransport,
|
|
29
|
+
input_report_builder: InputReportBuilder,
|
|
30
|
+
session: SwitchHidSession,
|
|
31
|
+
diagnostics: DiagnosticsRecorder | None = None,
|
|
32
|
+
clock_ns: Callable[[], int] = monotonic_ns,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Create a session-scoped serialized report sender."""
|
|
35
|
+
self._transport = transport
|
|
36
|
+
self._input_report_builder = input_report_builder
|
|
37
|
+
self._session = session
|
|
38
|
+
self._diagnostics = diagnostics
|
|
39
|
+
self._clock_ns = clock_ns
|
|
40
|
+
self._timer = 0
|
|
41
|
+
self._send_lock = asyncio.Lock()
|
|
42
|
+
|
|
43
|
+
async def send_input(
|
|
44
|
+
self,
|
|
45
|
+
state: InputState,
|
|
46
|
+
*,
|
|
47
|
+
reason: str = "input",
|
|
48
|
+
commit_state_store: InputStateStore | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Send one input state and optionally commit it before releasing the send lock."""
|
|
51
|
+
async with self._send_lock:
|
|
52
|
+
await self._send_input_locked(
|
|
53
|
+
state,
|
|
54
|
+
reason=reason,
|
|
55
|
+
commit_state_store=commit_state_store,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def send_current_input(
|
|
59
|
+
self,
|
|
60
|
+
state_store: InputStateStore,
|
|
61
|
+
*,
|
|
62
|
+
reason: str = "input",
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Snapshot and send the current input state under the shared send lock."""
|
|
65
|
+
async with self._send_lock:
|
|
66
|
+
state = await state_store.snapshot()
|
|
67
|
+
await self._send_input_locked(state, reason=reason)
|
|
68
|
+
|
|
69
|
+
async def send_subcommand_reply(self, build_report: ReplyBuilder) -> bytes:
|
|
70
|
+
"""Build and send a subcommand reply under the shared send lock."""
|
|
71
|
+
async with self._send_lock:
|
|
72
|
+
built = build_report()
|
|
73
|
+
if isawaitable(built):
|
|
74
|
+
report = await cast("Awaitable[bytes]", built)
|
|
75
|
+
else:
|
|
76
|
+
report = built
|
|
77
|
+
await self._send_subcommand_report_locked(report)
|
|
78
|
+
return report
|
|
79
|
+
|
|
80
|
+
async def send_subcommand_report(self, report: bytes) -> None:
|
|
81
|
+
"""Send an already-built subcommand reply under the shared send lock."""
|
|
82
|
+
async with self._send_lock:
|
|
83
|
+
await self._send_subcommand_report_locked(report)
|
|
84
|
+
|
|
85
|
+
async def _send_subcommand_report_locked(self, report: bytes) -> None:
|
|
86
|
+
reply = bytearray(report)
|
|
87
|
+
if reply and reply[0] == 0x21:
|
|
88
|
+
reply[1] = self._timer
|
|
89
|
+
await self._send_report(bytes(reply), reason="subcommand_reply")
|
|
90
|
+
if reply and reply[0] == 0x21:
|
|
91
|
+
self._advance_timer()
|
|
92
|
+
|
|
93
|
+
async def _send_input_locked(
|
|
94
|
+
self,
|
|
95
|
+
state: InputState,
|
|
96
|
+
*,
|
|
97
|
+
reason: str,
|
|
98
|
+
commit_state_store: InputStateStore | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
imu_block = self._session.encode_imu(state.imu_frames, now_ns=self._clock_ns())
|
|
101
|
+
report = self._input_report_builder.build_0x30(
|
|
102
|
+
state,
|
|
103
|
+
timer=self._timer,
|
|
104
|
+
imu_block=imu_block,
|
|
105
|
+
)
|
|
106
|
+
await self._send_report(report, reason=reason)
|
|
107
|
+
if commit_state_store is not None:
|
|
108
|
+
await commit_state_store.apply(state)
|
|
109
|
+
self._advance_timer()
|
|
110
|
+
|
|
111
|
+
async def _send_report(self, report: bytes, *, reason: str) -> None:
|
|
112
|
+
await self._transport.send_interrupt(report)
|
|
113
|
+
if self._diagnostics is not None:
|
|
114
|
+
self._diagnostics.record_report_tx(report_id=report[0], reason=reason)
|
|
115
|
+
|
|
116
|
+
def _advance_timer(self) -> None:
|
|
117
|
+
self._timer = (self._timer + 1) & 0xFF
|
|
16
118
|
|
|
17
119
|
|
|
18
120
|
class ReportLoop:
|
|
@@ -28,19 +130,20 @@ class ReportLoop:
|
|
|
28
130
|
report_period_us: int = 8000,
|
|
29
131
|
diagnostics: DiagnosticsRecorder | None = None,
|
|
30
132
|
clock_ns: Callable[[], int] = monotonic_ns,
|
|
133
|
+
sender: ReportSender | None = None,
|
|
31
134
|
) -> None:
|
|
32
135
|
"""Create a report loop helper."""
|
|
33
|
-
self._transport = transport
|
|
34
136
|
self._state_store = state_store
|
|
35
137
|
self._report_period_seconds = report_period_us / 1_000_000
|
|
36
|
-
self.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
138
|
+
self._sender = sender or ReportSender(
|
|
139
|
+
transport=transport,
|
|
140
|
+
input_report_builder=input_report_builder,
|
|
141
|
+
session=session,
|
|
142
|
+
diagnostics=diagnostics,
|
|
143
|
+
clock_ns=clock_ns,
|
|
144
|
+
)
|
|
40
145
|
self._reply_queue: deque[bytes] = deque()
|
|
41
|
-
self._timer = 0
|
|
42
146
|
self._periodic_holdoff_until = 0.0
|
|
43
|
-
self._send_lock = asyncio.Lock()
|
|
44
147
|
self._task: asyncio.Task[None] | None = None
|
|
45
148
|
|
|
46
149
|
def start(self) -> None:
|
|
@@ -61,16 +164,13 @@ class ReportLoop:
|
|
|
61
164
|
|
|
62
165
|
async def send_current_input(self, *, reason: str = "input") -> None:
|
|
63
166
|
"""Send one 0x30 input report for the current state."""
|
|
64
|
-
|
|
65
|
-
await self._send_current_input_locked(reason=reason)
|
|
167
|
+
await self._sender.send_current_input(self._state_store, reason=reason)
|
|
66
168
|
|
|
67
|
-
async def send_subcommand_reply(self, build_report:
|
|
169
|
+
async def send_subcommand_reply(self, build_report: ReplyBuilder) -> bytes:
|
|
68
170
|
"""Apply a subcommand transition and send its reply under the send lock."""
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
self._holdoff_periodic_after_reply()
|
|
73
|
-
return report
|
|
171
|
+
report = await self._sender.send_subcommand_reply(build_report)
|
|
172
|
+
self._holdoff_periodic_after_reply()
|
|
173
|
+
return report
|
|
74
174
|
|
|
75
175
|
def queue_reply(self, report: bytes) -> None:
|
|
76
176
|
"""Queue one subcommand reply for priority transmission."""
|
|
@@ -78,48 +178,19 @@ class ReportLoop:
|
|
|
78
178
|
|
|
79
179
|
async def send_next_report(self) -> None:
|
|
80
180
|
"""Send the next queued reply or current input report."""
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await self._send_current_input_locked(reason="periodic")
|
|
89
|
-
|
|
90
|
-
async def _send_current_input_locked(self, *, reason: str) -> None:
|
|
91
|
-
state = await self._state_store.snapshot()
|
|
92
|
-
imu_block = self._session.encode_imu(state.imu_frames, now_ns=self._clock_ns())
|
|
93
|
-
report = self._input_report_builder.build_0x30(
|
|
94
|
-
state,
|
|
95
|
-
timer=self._timer,
|
|
96
|
-
imu_block=imu_block,
|
|
97
|
-
)
|
|
98
|
-
await self._send_report(report, reason=reason)
|
|
99
|
-
self._advance_timer()
|
|
100
|
-
|
|
101
|
-
async def _send_subcommand_reply_locked(self, report: bytes) -> None:
|
|
102
|
-
reply = bytearray(report)
|
|
103
|
-
if reply and reply[0] == 0x21:
|
|
104
|
-
reply[1] = self._timer
|
|
105
|
-
await self._send_report(bytes(reply), reason="subcommand_reply")
|
|
106
|
-
if reply and reply[0] == 0x21:
|
|
107
|
-
self._advance_timer()
|
|
181
|
+
if self._reply_queue:
|
|
182
|
+
await self._sender.send_subcommand_report(self._reply_queue.popleft())
|
|
183
|
+
self._holdoff_periodic_after_reply()
|
|
184
|
+
return
|
|
185
|
+
if self._is_periodic_held_off():
|
|
186
|
+
return
|
|
187
|
+
await self.send_current_input(reason="periodic")
|
|
108
188
|
|
|
109
189
|
async def _run(self) -> None:
|
|
110
190
|
while True:
|
|
111
191
|
await asyncio.sleep(self._report_period_seconds)
|
|
112
192
|
await self.send_next_report()
|
|
113
193
|
|
|
114
|
-
async def _send_report(self, report: bytes, *, reason: str) -> None:
|
|
115
|
-
await self._transport.send_interrupt(report)
|
|
116
|
-
report_id = report[0]
|
|
117
|
-
if self._diagnostics is not None:
|
|
118
|
-
self._diagnostics.record_report_tx(report_id=report_id, reason=reason)
|
|
119
|
-
|
|
120
|
-
def _advance_timer(self) -> None:
|
|
121
|
-
self._timer = (self._timer + 1) & 0xFF
|
|
122
|
-
|
|
123
194
|
def _holdoff_periodic_after_reply(self) -> None:
|
|
124
195
|
self._periodic_holdoff_until = (
|
|
125
196
|
asyncio.get_running_loop().time() + REPLY_PERIODIC_HOLDOFF_SECONDS
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
swbt/__init__.py,sha256=
|
|
1
|
+
swbt/__init__.py,sha256=2IR5hiqRDZCUgDDcnXpzSX8lsGWcEX9gqOGgz1GUmUY,1410
|
|
2
2
|
swbt/_testing/__init__.py,sha256=RtJg6CXFY42HTLehYp33Ilrliqhp4wOSN6cjIGoRE8A,58
|
|
3
|
-
swbt/_testing/gamepad.py,sha256=
|
|
3
|
+
swbt/_testing/gamepad.py,sha256=xibVgsA_Un0C1WZt6pIHOZXefoR27XOEMZxwLbufPJY,4514
|
|
4
4
|
swbt/adapter_discovery.py,sha256=cvlMQgmvmR-wsB0jPe9bipBBxcIVbhO6NRkTAKnPBYA,7717
|
|
5
5
|
swbt/diagnostics.py,sha256=HdOiBpZ8eAlESmibgsR_HLq5V0-OEDUpVUwJ1D9eN28,7281
|
|
6
6
|
swbt/errors.py,sha256=rKQZs-EO_5BQPtrZSRpirc-6_ymyxt1Q04VZi8y29cI,3196
|
|
7
|
-
swbt/gamepad/__init__.py,sha256=
|
|
7
|
+
swbt/gamepad/__init__.py,sha256=4EQM1jG8Ev2UVjb9Zk3idlyYFZ1eLM5RqzHsurkV6z8,725
|
|
8
8
|
swbt/gamepad/_config.py,sha256=4Tq3Jlzv9IoyhQ0sDEBmIZ-Fz1Upc4koE4vnTQHDgrg,3793
|
|
9
9
|
swbt/gamepad/connection.py,sha256=My3deMw4L64UoEXjeyRNBs1D6UvrvkeC2P7yf_PLPYQ,7776
|
|
10
10
|
swbt/gamepad/constants.py,sha256=gDesRFvVxZ3SMSiP4qzd42em_Dt9M5hIu3-2exgKEus,84
|
|
11
|
-
swbt/gamepad/core.py,sha256=
|
|
12
|
-
swbt/gamepad/interface.py,sha256=
|
|
13
|
-
swbt/gamepad/output.py,sha256=
|
|
14
|
-
swbt/gamepad/runtime.py,sha256=
|
|
11
|
+
swbt/gamepad/core.py,sha256=eJei4mccT7cTLmMkR7TnAb4_gVj6aDqgG9tiJp-vTQU,17880
|
|
12
|
+
swbt/gamepad/interface.py,sha256=Q_iU-te54mXumg7LypchVpmJb5qcn8hxGLMwVhKNNaE,10562
|
|
13
|
+
swbt/gamepad/output.py,sha256=dx7xCBL83fs_jjgsdBVfji3pbalGqi92CwlbB7qzNxM,4173
|
|
14
|
+
swbt/gamepad/runtime.py,sha256=1OD9xyPhWg4lFRjM21PAqjJC-hk--eBkHz9PIQRLY68,35584
|
|
15
15
|
swbt/gamepad/transport_factory.py,sha256=2LLGIF3Bc4ufBNjp1AcdqNkHNP8ZBDFU2PNt898vo8o,2059
|
|
16
16
|
swbt/imu.py,sha256=sT1_MsYECQlQ-pSfayD8gojD9l3w9zEtOI3xP1IovQM,4237
|
|
17
17
|
swbt/input.py,sha256=7gv75lzrcmoV8Sijz7f1pvsheRrmUi_TCIMW3DjUVjw,22275
|
|
@@ -31,7 +31,7 @@ swbt/protocol/session.py,sha256=TtnnFySalCXsEUwVv0DX9w1E2xq1yFIfmgCrA6wGHzs,3758
|
|
|
31
31
|
swbt/protocol/spi.py,sha256=buKyNYYOHOSYbpVh3Wa2968FL4VIO_43QcSXNowNfJs,2594
|
|
32
32
|
swbt/protocol/subcommand.py,sha256=1Z3Xf8xo7AydhyTa3bHgHM24hN2YS37yp7eioOHTu0A,7633
|
|
33
33
|
swbt/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
34
|
-
swbt/report_loop.py,sha256=
|
|
34
|
+
swbt/report_loop.py,sha256=HFAPdm9Wx8zBT-sqUn_zmLf6boahl_BsmoxfkifbmYE,7318
|
|
35
35
|
swbt/state_store.py,sha256=Mf8uKR5CoQdzyMmqflNE_2KUWE_ZgmLaHcGXUcVThW4,2693
|
|
36
36
|
swbt/transport/__init__.py,sha256=b_Vqsg0k_g7XclN9XHQrcJxqCwGIklxG5NqzISal4_I,68
|
|
37
37
|
swbt/transport/_bumble_acl.py,sha256=RnueTwEgAd0w3YgxnXkw0AKQXMeECIRope96W-xQ1K8,1425
|
|
@@ -43,8 +43,8 @@ swbt/transport/_bumble_usb_devices.py,sha256=soAKiHTzeeTYdb7M357yaac-RxG2c_BiKWH
|
|
|
43
43
|
swbt/transport/base.py,sha256=lAGAJjGMg5CwtdAKmefCZglWqT3mWzm7CFOOCGGKul4,5619
|
|
44
44
|
swbt/transport/bumble.py,sha256=gB0vy1GW-1Mbg_EZuYSrdOKVClKyNEKXMy1jju41Bts,30147
|
|
45
45
|
swbt/transport/fake.py,sha256=Pmr-9a3qg2v9Rs86wBjINdWmWi5KOZlcFMH3AOmWF58,12903
|
|
46
|
-
swbt_python-0.
|
|
47
|
-
swbt_python-0.
|
|
48
|
-
swbt_python-0.
|
|
49
|
-
swbt_python-0.
|
|
50
|
-
swbt_python-0.
|
|
46
|
+
swbt_python-0.4.0.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
|
|
47
|
+
swbt_python-0.4.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
|
|
48
|
+
swbt_python-0.4.0.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
|
|
49
|
+
swbt_python-0.4.0.dist-info/METADATA,sha256=CjgPwuJCCm3HlnkVf6vMZUnowaeEA0PxLPp3Eo026iI,5994
|
|
50
|
+
swbt_python-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|