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/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)