swbt-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- swbt/__init__.py +37 -0
- swbt/diagnostics.py +202 -0
- swbt/errors.py +33 -0
- swbt/gamepad/__init__.py +14 -0
- swbt/gamepad/connection.py +222 -0
- swbt/gamepad/core.py +697 -0
- swbt/gamepad/output.py +73 -0
- swbt/gamepad/transport_factory.py +22 -0
- swbt/input.py +550 -0
- swbt/probe.py +150 -0
- swbt/protocol/__init__.py +1 -0
- swbt/protocol/input_report.py +77 -0
- swbt/protocol/output_report.py +58 -0
- swbt/protocol/profile.py +221 -0
- swbt/protocol/rumble.py +21 -0
- swbt/protocol/spi.py +37 -0
- swbt/protocol/subcommand.py +102 -0
- swbt/py.typed +1 -0
- swbt/report_loop.py +115 -0
- swbt/state_store.py +60 -0
- swbt/transport/__init__.py +3 -0
- swbt/transport/_bumble_acl.py +33 -0
- swbt/transport/_bumble_hidp.py +34 -0
- swbt/transport/_bumble_key_store.py +220 -0
- swbt/transport/_bumble_lifecycle.py +311 -0
- swbt/transport/_bumble_sdp.py +203 -0
- swbt/transport/base.py +81 -0
- swbt/transport/bumble.py +709 -0
- swbt/transport/fake.py +315 -0
- swbt_python-0.1.0.dist-info/METADATA +122 -0
- swbt_python-0.1.0.dist-info/RECORD +34 -0
- swbt_python-0.1.0.dist-info/WHEEL +4 -0
- swbt_python-0.1.0.dist-info/entry_points.txt +3 -0
- swbt_python-0.1.0.dist-info/licenses/LICENSE +21 -0
swbt/gamepad/output.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Output report dispatch for SwitchGamepad."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from swbt.diagnostics import DiagnosticsRecorder
|
|
7
|
+
from swbt.protocol.output_report import OutputReportParser
|
|
8
|
+
from swbt.protocol.subcommand import SubcommandResponder, UnsupportedSubcommandError
|
|
9
|
+
from swbt.state_store import InputStateStore
|
|
10
|
+
|
|
11
|
+
ReplySender = Callable[[bytes], Awaitable[None]]
|
|
12
|
+
ReplySenderRequirement = Callable[[], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class OutputReportDispatcher:
|
|
17
|
+
"""Parse host output reports, record diagnostics, and enqueue replies."""
|
|
18
|
+
|
|
19
|
+
diagnostics: DiagnosticsRecorder
|
|
20
|
+
require_reply_sender: ReplySenderRequirement
|
|
21
|
+
send_subcommand_reply: ReplySender
|
|
22
|
+
state_store: InputStateStore
|
|
23
|
+
output_report_parser: OutputReportParser = field(default_factory=OutputReportParser)
|
|
24
|
+
subcommand_responder: SubcommandResponder = field(default_factory=SubcommandResponder)
|
|
25
|
+
|
|
26
|
+
async def dispatch(self, payload: bytes) -> None:
|
|
27
|
+
"""Handle one host-to-device output report payload."""
|
|
28
|
+
output_report = self.output_report_parser.parse(payload)
|
|
29
|
+
subcommand_id = _format_subcommand_id(output_report.subcommand_id)
|
|
30
|
+
if output_report.rumble is not None:
|
|
31
|
+
self.diagnostics.record_raw_rumble(output_report.rumble)
|
|
32
|
+
self.diagnostics.record_event(
|
|
33
|
+
"output_report_rx",
|
|
34
|
+
length=len(payload),
|
|
35
|
+
packet_id=output_report.packet_id,
|
|
36
|
+
report_id=_format_report_id(output_report.report_id),
|
|
37
|
+
subcommand_id=subcommand_id,
|
|
38
|
+
)
|
|
39
|
+
if output_report.subcommand_id is None:
|
|
40
|
+
return
|
|
41
|
+
self.diagnostics.record_subcommand_rx(
|
|
42
|
+
packet_id=output_report.packet_id,
|
|
43
|
+
subcommand_id=output_report.subcommand_id,
|
|
44
|
+
)
|
|
45
|
+
self.require_reply_sender()
|
|
46
|
+
state = await self.state_store.snapshot()
|
|
47
|
+
try:
|
|
48
|
+
reply = self.subcommand_responder.respond(output_report, state=state)
|
|
49
|
+
except UnsupportedSubcommandError:
|
|
50
|
+
self.diagnostics.record_event(
|
|
51
|
+
"unsupported_subcommand",
|
|
52
|
+
packet_id=output_report.packet_id,
|
|
53
|
+
payload=output_report.subcommand_payload.hex(),
|
|
54
|
+
subcommand_id=subcommand_id,
|
|
55
|
+
)
|
|
56
|
+
raise
|
|
57
|
+
self.diagnostics.record_event(
|
|
58
|
+
"subcommand_reply_tx",
|
|
59
|
+
packet_id=output_report.packet_id,
|
|
60
|
+
report_id=_format_report_id(reply[0]),
|
|
61
|
+
subcommand_id=subcommand_id,
|
|
62
|
+
)
|
|
63
|
+
await self.send_subcommand_reply(reply)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_report_id(report_id: int) -> str:
|
|
67
|
+
return f"0x{report_id:02x}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _format_subcommand_id(subcommand_id: int | None) -> str | None:
|
|
71
|
+
if subcommand_id is None:
|
|
72
|
+
return None
|
|
73
|
+
return f"0x{subcommand_id:02x}"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Default transport factory for SwitchGamepad."""
|
|
2
|
+
|
|
3
|
+
from swbt.diagnostics import DiagnosticsRecorder
|
|
4
|
+
from swbt.transport.base import HidDeviceTransport
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_default_transport(
|
|
8
|
+
*,
|
|
9
|
+
adapter: str,
|
|
10
|
+
device_name: str,
|
|
11
|
+
diagnostics: DiagnosticsRecorder,
|
|
12
|
+
key_store_path: str | None,
|
|
13
|
+
) -> HidDeviceTransport:
|
|
14
|
+
"""Create the default Bumble-backed transport without importing Bumble at API import time."""
|
|
15
|
+
from swbt.transport.bumble import BumbleHidTransport # noqa: PLC0415
|
|
16
|
+
|
|
17
|
+
return BumbleHidTransport(
|
|
18
|
+
adapter=adapter,
|
|
19
|
+
device_name=device_name,
|
|
20
|
+
diagnostics=diagnostics,
|
|
21
|
+
key_store_path=key_store_path,
|
|
22
|
+
)
|
swbt/input.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Input state value objects."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
from swbt.errors import InvalidInputError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Button(Enum):
|
|
11
|
+
"""Buttons exposed by the input model.
|
|
12
|
+
|
|
13
|
+
Each member maps to one supported gamepad button. The HID bit positions are
|
|
14
|
+
defined by the protocol layer and its tests, not by the enum values.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
A = auto()
|
|
18
|
+
B = auto()
|
|
19
|
+
X = auto()
|
|
20
|
+
Y = auto()
|
|
21
|
+
L = auto()
|
|
22
|
+
R = auto()
|
|
23
|
+
ZL = auto()
|
|
24
|
+
ZR = auto()
|
|
25
|
+
PLUS = auto()
|
|
26
|
+
MINUS = auto()
|
|
27
|
+
HOME = auto()
|
|
28
|
+
CAPTURE = auto()
|
|
29
|
+
LEFT_STICK = auto()
|
|
30
|
+
RIGHT_STICK = auto()
|
|
31
|
+
DPAD_UP = auto()
|
|
32
|
+
DPAD_DOWN = auto()
|
|
33
|
+
DPAD_LEFT = auto()
|
|
34
|
+
DPAD_RIGHT = auto()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Stick:
|
|
39
|
+
"""12-bit raw stick position.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
x: Horizontal raw axis value in the inclusive ``0..4095`` range.
|
|
43
|
+
y: Vertical raw axis value in the inclusive ``0..4095`` range.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
x: int
|
|
47
|
+
y: int
|
|
48
|
+
|
|
49
|
+
MIN = 0
|
|
50
|
+
CENTER = 2048
|
|
51
|
+
MAX = 4095
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
"""Validate direct dataclass construction."""
|
|
55
|
+
self._validate_axis("x", self.x)
|
|
56
|
+
self._validate_axis("y", self.y)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def center(cls) -> "Stick":
|
|
60
|
+
"""Return the neutral stick position.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Stick: Centered stick with both axes set to ``CENTER``.
|
|
64
|
+
"""
|
|
65
|
+
return cls(x=cls.CENTER, y=cls.CENTER)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def raw(cls, *, x: int, y: int) -> "Stick":
|
|
69
|
+
"""Return a stick position from 12-bit raw values.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
x: Horizontal raw axis value.
|
|
73
|
+
y: Vertical raw axis value.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Stick: Stick position with the supplied raw axis values.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
InvalidInputError: Either axis is outside the supported raw range.
|
|
80
|
+
"""
|
|
81
|
+
cls._validate_axis("x", x)
|
|
82
|
+
cls._validate_axis("y", y)
|
|
83
|
+
return cls(x=x, y=y)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def normalized(cls, *, x: float, y: float) -> "Stick":
|
|
87
|
+
"""Return a stick position from normalized axis values.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
x: Horizontal value in the inclusive ``-1.0..1.0`` range.
|
|
91
|
+
y: Vertical value in the inclusive ``-1.0..1.0`` range.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Stick: Raw stick position converted from normalized values.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
InvalidInputError: Either normalized axis is outside the supported range.
|
|
98
|
+
"""
|
|
99
|
+
return cls.raw(
|
|
100
|
+
x=cls._normalized_axis_to_raw("x", x),
|
|
101
|
+
y=cls._normalized_axis_to_raw("y", y),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def tilt(cls, x: float, y: float) -> "Stick":
|
|
106
|
+
"""Return a stick position from normalized tilt values.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
x: Horizontal tilt in the inclusive ``-1.0..1.0`` range.
|
|
110
|
+
y: Vertical tilt in the inclusive ``-1.0..1.0`` range.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Stick: Raw stick position converted from normalized tilt values.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
InvalidInputError: Either tilt axis is outside the supported range.
|
|
117
|
+
"""
|
|
118
|
+
return cls.normalized(x=x, y=y)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def up(cls, amount: float = 1.0) -> "Stick":
|
|
122
|
+
"""Return an upward stick tilt.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
amount: Tilt amount in the inclusive ``0.0..1.0`` range.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Stick: Stick tilted upward by ``amount``.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
InvalidInputError: ``amount`` is outside the supported range.
|
|
132
|
+
"""
|
|
133
|
+
cls._validate_amount(amount)
|
|
134
|
+
return cls.tilt(0.0, amount)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def down(cls, amount: float = 1.0) -> "Stick":
|
|
138
|
+
"""Return a downward stick tilt.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
amount: Tilt amount in the inclusive ``0.0..1.0`` range.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Stick: Stick tilted downward by ``amount``.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
InvalidInputError: ``amount`` is outside the supported range.
|
|
148
|
+
"""
|
|
149
|
+
cls._validate_amount(amount)
|
|
150
|
+
return cls.tilt(0.0, -amount)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def left(cls, amount: float = 1.0) -> "Stick":
|
|
154
|
+
"""Return a leftward stick tilt.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
amount: Tilt amount in the inclusive ``0.0..1.0`` range.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Stick: Stick tilted left by ``amount``.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
InvalidInputError: ``amount`` is outside the supported range.
|
|
164
|
+
"""
|
|
165
|
+
cls._validate_amount(amount)
|
|
166
|
+
return cls.tilt(-amount, 0.0)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def right(cls, amount: float = 1.0) -> "Stick":
|
|
170
|
+
"""Return a rightward stick tilt.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
amount: Tilt amount in the inclusive ``0.0..1.0`` range.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Stick: Stick tilted right by ``amount``.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
InvalidInputError: ``amount`` is outside the supported range.
|
|
180
|
+
"""
|
|
181
|
+
cls._validate_amount(amount)
|
|
182
|
+
return cls.tilt(amount, 0.0)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _validate_axis(cls, axis_name: str, value: int) -> None:
|
|
186
|
+
if not cls.MIN <= value <= cls.MAX:
|
|
187
|
+
msg = f"{axis_name} must be between {cls.MIN} and {cls.MAX}: {value}"
|
|
188
|
+
raise InvalidInputError(msg)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def _normalized_axis_to_raw(cls, axis_name: str, value: float) -> int:
|
|
192
|
+
if not -1.0 <= value <= 1.0:
|
|
193
|
+
msg = f"{axis_name} must be between -1.0 and 1.0: {value}"
|
|
194
|
+
raise InvalidInputError(msg)
|
|
195
|
+
if value < 0:
|
|
196
|
+
return cls.CENTER + round(value * (cls.CENTER - cls.MIN))
|
|
197
|
+
return cls.CENTER + round(value * (cls.MAX - cls.CENTER))
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _validate_amount(cls, value: float) -> None:
|
|
201
|
+
if not 0.0 <= value <= 1.0:
|
|
202
|
+
msg = f"amount must be between 0.0 and 1.0: {value}"
|
|
203
|
+
raise InvalidInputError(msg)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class IMUFrame:
|
|
208
|
+
"""One 6-axis IMU frame.
|
|
209
|
+
|
|
210
|
+
Attributes:
|
|
211
|
+
accel_x: Accelerometer X-axis raw value.
|
|
212
|
+
accel_y: Accelerometer Y-axis raw value.
|
|
213
|
+
accel_z: Accelerometer Z-axis raw value.
|
|
214
|
+
gyro_x: Gyroscope X-axis raw value.
|
|
215
|
+
gyro_y: Gyroscope Y-axis raw value.
|
|
216
|
+
gyro_z: Gyroscope Z-axis raw value.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
accel_x: int
|
|
220
|
+
accel_y: int
|
|
221
|
+
accel_z: int
|
|
222
|
+
gyro_x: int
|
|
223
|
+
gyro_y: int
|
|
224
|
+
gyro_z: int
|
|
225
|
+
|
|
226
|
+
MIN = -32768
|
|
227
|
+
MAX = 32767
|
|
228
|
+
|
|
229
|
+
def __post_init__(self) -> None:
|
|
230
|
+
"""Validate direct dataclass construction."""
|
|
231
|
+
for field_name in (
|
|
232
|
+
"accel_x",
|
|
233
|
+
"accel_y",
|
|
234
|
+
"accel_z",
|
|
235
|
+
"gyro_x",
|
|
236
|
+
"gyro_y",
|
|
237
|
+
"gyro_z",
|
|
238
|
+
):
|
|
239
|
+
self._validate_i16(field_name, getattr(self, field_name))
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def neutral(cls) -> "IMUFrame":
|
|
243
|
+
"""Return an IMU frame with no movement.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
IMUFrame: Frame with all accelerometer and gyroscope values set to zero.
|
|
247
|
+
"""
|
|
248
|
+
return cls(accel_x=0, accel_y=0, accel_z=0, gyro_x=0, gyro_y=0, gyro_z=0)
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def raw(
|
|
252
|
+
cls,
|
|
253
|
+
*,
|
|
254
|
+
accel: tuple[int, int, int] | None = None,
|
|
255
|
+
gyro: tuple[int, int, int] | None = None,
|
|
256
|
+
) -> "IMUFrame":
|
|
257
|
+
"""Return an IMU frame from raw accelerometer and gyroscope axes.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
accel: Optional accelerometer ``(x, y, z)`` raw values.
|
|
261
|
+
gyro: Optional gyroscope ``(x, y, z)`` raw values.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
IMUFrame: Frame with omitted sensor axes set to zero.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
InvalidInputError: A supplied axis tuple does not contain three values
|
|
268
|
+
or any value is outside the supported signed 16-bit range.
|
|
269
|
+
"""
|
|
270
|
+
accel_x, accel_y, accel_z = cls._defaulted_axes("accel", accel)
|
|
271
|
+
gyro_x, gyro_y, gyro_z = cls._defaulted_axes("gyro", gyro)
|
|
272
|
+
return cls(
|
|
273
|
+
accel_x=accel_x,
|
|
274
|
+
accel_y=accel_y,
|
|
275
|
+
accel_z=accel_z,
|
|
276
|
+
gyro_x=gyro_x,
|
|
277
|
+
gyro_y=gyro_y,
|
|
278
|
+
gyro_z=gyro_z,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def gyro(cls, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
283
|
+
"""Return an IMU frame with only gyroscope axes set.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
x: Gyroscope X-axis raw value.
|
|
287
|
+
y: Gyroscope Y-axis raw value.
|
|
288
|
+
z: Gyroscope Z-axis raw value.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
IMUFrame: Frame with gyroscope values set and accelerometer values zeroed.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
InvalidInputError: Any value is outside the supported signed 16-bit range.
|
|
295
|
+
"""
|
|
296
|
+
return cls.raw(gyro=(x, y, z))
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def accel(cls, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
300
|
+
"""Return an IMU frame with only accelerometer axes set.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
x: Accelerometer X-axis raw value.
|
|
304
|
+
y: Accelerometer Y-axis raw value.
|
|
305
|
+
z: Accelerometer Z-axis raw value.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
IMUFrame: Frame with accelerometer values set and gyroscope values zeroed.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
InvalidInputError: Any value is outside the supported signed 16-bit range.
|
|
312
|
+
"""
|
|
313
|
+
return cls.raw(accel=(x, y, z))
|
|
314
|
+
|
|
315
|
+
def with_gyro(self, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
316
|
+
"""Return a frame with replaced gyroscope axes.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
x: Replacement gyroscope X-axis raw value.
|
|
320
|
+
y: Replacement gyroscope Y-axis raw value.
|
|
321
|
+
z: Replacement gyroscope Z-axis raw value.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
IMUFrame: Copy of this frame with accelerometer axes preserved.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
InvalidInputError: Any value is outside the supported signed 16-bit range.
|
|
328
|
+
"""
|
|
329
|
+
return IMUFrame.raw(
|
|
330
|
+
accel=(self.accel_x, self.accel_y, self.accel_z),
|
|
331
|
+
gyro=(x, y, z),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def with_accel(self, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
335
|
+
"""Return a frame with replaced accelerometer axes.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
x: Replacement accelerometer X-axis raw value.
|
|
339
|
+
y: Replacement accelerometer Y-axis raw value.
|
|
340
|
+
z: Replacement accelerometer Z-axis raw value.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
IMUFrame: Copy of this frame with gyroscope axes preserved.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
InvalidInputError: Any value is outside the supported signed 16-bit range.
|
|
347
|
+
"""
|
|
348
|
+
return IMUFrame.raw(
|
|
349
|
+
accel=(x, y, z),
|
|
350
|
+
gyro=(self.gyro_x, self.gyro_y, self.gyro_z),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def _validate_i16(cls, field_name: str, value: object) -> int:
|
|
355
|
+
if not isinstance(value, int) or not cls.MIN <= value <= cls.MAX:
|
|
356
|
+
msg = f"{field_name} must be an int between {cls.MIN} and {cls.MAX}: {value}"
|
|
357
|
+
raise InvalidInputError(msg)
|
|
358
|
+
return value
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def _defaulted_axes(
|
|
362
|
+
cls,
|
|
363
|
+
name: str,
|
|
364
|
+
values: tuple[int, int, int] | None,
|
|
365
|
+
) -> tuple[int, int, int]:
|
|
366
|
+
if values is None:
|
|
367
|
+
return (0, 0, 0)
|
|
368
|
+
return cls._validate_axes(name, values)
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def _validate_axes(cls, name: str, values: object) -> tuple[int, int, int]:
|
|
372
|
+
if not isinstance(values, tuple) or len(values) != 3:
|
|
373
|
+
msg = f"{name} must be a tuple of three raw values"
|
|
374
|
+
raise InvalidInputError(msg)
|
|
375
|
+
x, y, z = values
|
|
376
|
+
return (
|
|
377
|
+
cls._validate_i16(f"{name}_x", x),
|
|
378
|
+
cls._validate_i16(f"{name}_y", y),
|
|
379
|
+
cls._validate_i16(f"{name}_z", z),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class InputState:
|
|
385
|
+
"""Immutable controller input state.
|
|
386
|
+
|
|
387
|
+
Attributes:
|
|
388
|
+
buttons: Pressed buttons represented as an immutable set.
|
|
389
|
+
left_stick: Current left stick position.
|
|
390
|
+
right_stick: Current right stick position.
|
|
391
|
+
imu_frames: Three IMU frames included in the next input report.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
buttons: frozenset[Button]
|
|
395
|
+
left_stick: Stick
|
|
396
|
+
right_stick: Stick
|
|
397
|
+
imu_frames: tuple[IMUFrame, IMUFrame, IMUFrame]
|
|
398
|
+
|
|
399
|
+
@classmethod
|
|
400
|
+
def neutral(cls) -> "InputState":
|
|
401
|
+
"""Return a state with no buttons pressed and centered sticks.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
InputState: Neutral state with centered sticks and neutral IMU frames.
|
|
405
|
+
"""
|
|
406
|
+
neutral_imu = IMUFrame.neutral()
|
|
407
|
+
return cls(
|
|
408
|
+
buttons=frozenset(),
|
|
409
|
+
left_stick=Stick.center(),
|
|
410
|
+
right_stick=Stick.center(),
|
|
411
|
+
imu_frames=(neutral_imu, neutral_imu, neutral_imu),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def with_buttons(self, buttons: Iterable[Button]) -> "InputState":
|
|
415
|
+
"""Return a state with a replaced button set.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
buttons: Buttons that should be pressed in the returned state.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
InputState: Copy of this state with the supplied button set.
|
|
422
|
+
"""
|
|
423
|
+
return InputState(
|
|
424
|
+
buttons=frozenset(buttons),
|
|
425
|
+
left_stick=self.left_stick,
|
|
426
|
+
right_stick=self.right_stick,
|
|
427
|
+
imu_frames=self.imu_frames,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def with_sticks(
|
|
431
|
+
self,
|
|
432
|
+
*,
|
|
433
|
+
left_stick: Stick | None = None,
|
|
434
|
+
right_stick: Stick | None = None,
|
|
435
|
+
) -> "InputState":
|
|
436
|
+
"""Return a state with replaced stick values.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
left_stick: Optional replacement for the left stick.
|
|
440
|
+
right_stick: Optional replacement for the right stick.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
InputState: Copy of this state with supplied stick replacements.
|
|
444
|
+
"""
|
|
445
|
+
return InputState(
|
|
446
|
+
buttons=self.buttons,
|
|
447
|
+
left_stick=left_stick if left_stick is not None else self.left_stick,
|
|
448
|
+
right_stick=right_stick if right_stick is not None else self.right_stick,
|
|
449
|
+
imu_frames=self.imu_frames,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def with_imu(self, *frames: IMUFrame) -> "InputState":
|
|
453
|
+
"""Return a state with replaced IMU frames.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
frames: One frame to repeat across all three IMU slots, or exactly three
|
|
457
|
+
frames to store in order.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
InputState: Copy of this state with supplied IMU frames.
|
|
461
|
+
|
|
462
|
+
Raises:
|
|
463
|
+
InvalidInputError: The frame count is not one or three, or any value is
|
|
464
|
+
not an ``IMUFrame``.
|
|
465
|
+
"""
|
|
466
|
+
return InputState(
|
|
467
|
+
buttons=self.buttons,
|
|
468
|
+
left_stick=self.left_stick,
|
|
469
|
+
right_stick=self.right_stick,
|
|
470
|
+
imu_frames=self._normalize_imu_frames(frames),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def with_gyro(self, *samples: tuple[int, int, int]) -> "InputState":
|
|
474
|
+
"""Return a state with replaced gyroscope axes.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
samples: One ``(x, y, z)`` sample to repeat across all frames, or exactly
|
|
478
|
+
three samples to apply in order.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
InputState: Copy of this state with accelerometer axes preserved.
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
InvalidInputError: The sample count is not one or three, a sample is not a
|
|
485
|
+
three-value tuple, or any value is outside the signed 16-bit range.
|
|
486
|
+
"""
|
|
487
|
+
normalized = self._normalize_imu_samples("gyro", samples)
|
|
488
|
+
return self.with_imu(
|
|
489
|
+
*(
|
|
490
|
+
frame.with_gyro(*sample)
|
|
491
|
+
for frame, sample in zip(self.imu_frames, normalized, strict=True)
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def with_accel(self, *samples: tuple[int, int, int]) -> "InputState":
|
|
496
|
+
"""Return a state with replaced accelerometer axes.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
samples: One ``(x, y, z)`` sample to repeat across all frames, or exactly
|
|
500
|
+
three samples to apply in order.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
InputState: Copy of this state with gyroscope axes preserved.
|
|
504
|
+
|
|
505
|
+
Raises:
|
|
506
|
+
InvalidInputError: The sample count is not one or three, a sample is not a
|
|
507
|
+
three-value tuple, or any value is outside the signed 16-bit range.
|
|
508
|
+
"""
|
|
509
|
+
normalized = self._normalize_imu_samples("accel", samples)
|
|
510
|
+
return self.with_imu(
|
|
511
|
+
*(
|
|
512
|
+
frame.with_accel(*sample)
|
|
513
|
+
for frame, sample in zip(self.imu_frames, normalized, strict=True)
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _normalize_imu_frames(
|
|
519
|
+
frames: tuple[IMUFrame, ...],
|
|
520
|
+
) -> tuple[IMUFrame, IMUFrame, IMUFrame]:
|
|
521
|
+
if len(frames) == 1:
|
|
522
|
+
frame = frames[0]
|
|
523
|
+
if not isinstance(frame, IMUFrame):
|
|
524
|
+
msg = "frames must contain IMUFrame values"
|
|
525
|
+
raise InvalidInputError(msg)
|
|
526
|
+
return (frame, frame, frame)
|
|
527
|
+
if len(frames) == 3:
|
|
528
|
+
frame1, frame2, frame3 = frames
|
|
529
|
+
if not all(isinstance(frame, IMUFrame) for frame in frames):
|
|
530
|
+
msg = "frames must contain IMUFrame values"
|
|
531
|
+
raise InvalidInputError(msg)
|
|
532
|
+
return (frame1, frame2, frame3)
|
|
533
|
+
msg = f"expected 1 or 3 IMU frames, got {len(frames)}"
|
|
534
|
+
raise InvalidInputError(msg)
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _normalize_imu_samples(
|
|
538
|
+
name: str,
|
|
539
|
+
samples: tuple[tuple[int, int, int], ...],
|
|
540
|
+
) -> tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]:
|
|
541
|
+
if len(samples) == 1:
|
|
542
|
+
sample = IMUFrame._validate_axes(name, samples[0])
|
|
543
|
+
return (sample, sample, sample)
|
|
544
|
+
if len(samples) == 3:
|
|
545
|
+
sample1, sample2, sample3 = (
|
|
546
|
+
IMUFrame._validate_axes(name, sample) for sample in samples
|
|
547
|
+
)
|
|
548
|
+
return (sample1, sample2, sample3)
|
|
549
|
+
msg = f"expected 1 or 3 IMU {name} samples, got {len(samples)}"
|
|
550
|
+
raise InvalidInputError(msg)
|