swbt-python 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
swbt/gamepad/output.py CHANGED
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
5
5
 
6
6
  from swbt.diagnostics import DiagnosticsRecorder
7
7
  from swbt.protocol.output_report import OutputReportParser
8
+ from swbt.protocol.session import SwitchHidSession
8
9
  from swbt.protocol.subcommand import (
9
10
  SESSION_STATE_SUBCOMMANDS,
10
11
  SubcommandResponder,
@@ -12,7 +13,8 @@ from swbt.protocol.subcommand import (
12
13
  )
13
14
  from swbt.state_store import InputStateStore
14
15
 
15
- ReplySender = Callable[[bytes], Awaitable[None]]
16
+ ReplyBuilder = Callable[[], bytes]
17
+ ReplySender = Callable[[ReplyBuilder], Awaitable[bytes]]
16
18
  ReplySenderRequirement = Callable[[], None]
17
19
 
18
20
 
@@ -23,6 +25,7 @@ class OutputReportDispatcher:
23
25
  diagnostics: DiagnosticsRecorder
24
26
  require_reply_sender: ReplySenderRequirement
25
27
  send_subcommand_reply: ReplySender
28
+ session: SwitchHidSession
26
29
  state_store: InputStateStore
27
30
  output_report_parser: OutputReportParser = field(default_factory=OutputReportParser)
28
31
  subcommand_responder: SubcommandResponder = field(default_factory=SubcommandResponder)
@@ -49,7 +52,13 @@ class OutputReportDispatcher:
49
52
  self.require_reply_sender()
50
53
  state = await self.state_store.snapshot()
51
54
  try:
52
- reply = self.subcommand_responder.respond(output_report, state=state)
55
+ reply = await self.send_subcommand_reply(
56
+ lambda: self.subcommand_responder.respond(
57
+ output_report,
58
+ state=state,
59
+ session=self.session,
60
+ )
61
+ )
53
62
  except UnsupportedSubcommandError:
54
63
  self.diagnostics.record_event(
55
64
  "unsupported_subcommand",
@@ -59,10 +68,11 @@ class OutputReportDispatcher:
59
68
  )
60
69
  raise
61
70
  if output_report.subcommand_id in SESSION_STATE_SUBCOMMANDS:
62
- session_state = self.subcommand_responder.session_state
71
+ session_state = self.session.state
63
72
  self.diagnostics.record_event(
64
73
  "subcommand_session_state",
65
74
  imu_enabled=session_state.imu_enabled,
75
+ imu_encoding_format=session_state.imu_encoding_format,
66
76
  imu_mode=_format_optional_byte(session_state.imu_mode),
67
77
  packet_id=output_report.packet_id,
68
78
  report_mode=_format_optional_byte(session_state.report_mode),
@@ -79,7 +89,6 @@ class OutputReportDispatcher:
79
89
  report_id=_format_report_id(reply[0]),
80
90
  subcommand_id=subcommand_id,
81
91
  )
82
- await self.send_subcommand_reply(reply)
83
92
 
84
93
 
85
94
  def _format_report_id(report_id: int) -> str:
swbt/gamepad/runtime.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Stateful runtime for gamepad lifecycle and input behavior."""
2
2
 
3
3
  import asyncio
4
+ from collections.abc import Callable
4
5
  from dataclasses import replace
5
6
  from types import TracebackType
6
7
 
@@ -26,6 +27,7 @@ from swbt.gamepad.transport_factory import (
26
27
  from swbt.input import Button, IMUFrame, InputState, Stick
27
28
  from swbt.protocol.input_report import InputReportBuilder
28
29
  from swbt.protocol.profiles.base import ControllerColors
30
+ from swbt.protocol.session import SwitchHidSession
29
31
  from swbt.protocol.subcommand import SubcommandResponder
30
32
  from swbt.report_loop import ReportLoop
31
33
  from swbt.state_store import InputStateStore
@@ -103,12 +105,16 @@ class ControllerRuntime:
103
105
  controller_colors=self._config.controller_colors
104
106
  or self._config.profile.controller_colors,
105
107
  )
108
+ self._protocol_session = SwitchHidSession(self._controller_profile)
106
109
  self._output_report_dispatcher = OutputReportDispatcher(
107
110
  diagnostics=self._diagnostics,
108
111
  require_reply_sender=self._require_subcommand_reply_sender,
109
112
  send_subcommand_reply=self._send_subcommand_reply,
113
+ session=self._protocol_session,
110
114
  state_store=self._state_store,
111
- subcommand_responder=SubcommandResponder(profile=self._controller_profile),
115
+ subcommand_responder=SubcommandResponder(
116
+ profile=self._controller_profile,
117
+ ),
112
118
  )
113
119
  self._report_loop: ReportLoop | None = None
114
120
  self._lifecycle_lock = asyncio.Lock()
@@ -198,6 +204,7 @@ class ControllerRuntime:
198
204
  transport = self._ensure_transport()
199
205
  self._record_run_metadata()
200
206
  self._connection_state = "opening"
207
+ self._reset_protocol_session()
201
208
  self._register_transport_callbacks()
202
209
  self._connected_event.clear()
203
210
  try:
@@ -207,7 +214,10 @@ class ControllerRuntime:
207
214
  transport=transport,
208
215
  state_store=self._state_store,
209
216
  report_period_us=self._config.report_period_us,
210
- input_report_builder=InputReportBuilder(self._controller_profile),
217
+ input_report_builder=InputReportBuilder(
218
+ self._controller_profile,
219
+ ),
220
+ session=self._protocol_session,
211
221
  diagnostics=self._diagnostics,
212
222
  )
213
223
  self._connection_state = "opened"
@@ -219,6 +229,15 @@ class ControllerRuntime:
219
229
  self._is_open = False
220
230
  raise
221
231
 
232
+ def _reset_protocol_session(self) -> None:
233
+ """Create fresh host-requested state for the next HID connection."""
234
+ self._protocol_session = SwitchHidSession(self._controller_profile)
235
+ self._output_report_dispatcher.session = self._protocol_session
236
+ self._output_report_dispatcher.subcommand_responder = SubcommandResponder(
237
+ profile=self._controller_profile,
238
+ )
239
+ self._configured_device_info_bluetooth_address = None
240
+
222
241
  async def pair(self, timeout: float | None = None) -> None: # noqa: ASYNC109
223
242
  """Start pairing advertising and wait for a host connection.
224
243
 
@@ -583,8 +602,8 @@ class ControllerRuntime:
583
602
  def _require_subcommand_reply_sender(self) -> None:
584
603
  _ = self._subcommand_reply_sender()
585
604
 
586
- async def _send_subcommand_reply(self, reply: bytes) -> None:
587
- await self._subcommand_reply_sender().send_subcommand_reply(reply)
605
+ async def _send_subcommand_reply(self, build_reply: Callable[[], bytes]) -> bytes:
606
+ return await self._subcommand_reply_sender().send_subcommand_reply(build_reply)
588
607
 
589
608
  def _subcommand_reply_sender(self) -> ReportLoop:
590
609
  if self._report_loop is None:
swbt/imu.py ADDED
@@ -0,0 +1,116 @@
1
+ """Shared virtual IMU calibration values."""
2
+
3
+ from dataclasses import dataclass
4
+ from math import degrees, isfinite, radians
5
+ from struct import pack
6
+
7
+ from swbt.errors import InvalidInputError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class _ThreeAxisCalibration:
12
+ """Shared XYZ zero/reference model for factory sensor calibration."""
13
+
14
+ zero_raw: tuple[int, int, int] = (0, 0, 0)
15
+ reference_raw: tuple[int, int, int] = (0, 0, 0)
16
+
17
+ def to_spi_bytes(self) -> bytes:
18
+ """Return zero XYZ followed by reference XYZ as signed Int16LE."""
19
+ return pack("<6h", *self.zero_raw, *self.reference_raw)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AccelerometerCalibration(_ThreeAxisCalibration):
24
+ """Virtual accelerometer calibration and fixed conversion scale."""
25
+
26
+ reference_raw: tuple[int, int, int] = (0x4000, 0x4000, 0x4000)
27
+
28
+ @property
29
+ def reference_acceleration_g(self) -> float:
30
+ """Return the fixed reference acceleration in G."""
31
+ return 4.0
32
+
33
+ @property
34
+ def g_per_raw(self) -> float:
35
+ """Return the fixed accelerometer sensitivity in G per raw unit."""
36
+ return 1 / 4096
37
+
38
+ def accelerations_to_raw(
39
+ self,
40
+ accelerations_g: tuple[float, float, float],
41
+ ) -> tuple[int, int, int]:
42
+ """Convert XYZ accelerations in G to raw values."""
43
+ values = tuple(
44
+ self._validate_acceleration(f"{axis}_g", value)
45
+ for axis, value in zip("xyz", accelerations_g, strict=True)
46
+ )
47
+ converted = tuple(
48
+ zero + round(value / self.g_per_raw)
49
+ for zero, value in zip(self.zero_raw, values, strict=True)
50
+ )
51
+ return (converted[0], converted[1], converted[2])
52
+
53
+ def raw_to_accelerations(
54
+ self,
55
+ raw: tuple[int, int, int],
56
+ ) -> tuple[float, float, float]:
57
+ """Convert XYZ raw values to accelerations in G."""
58
+ converted = tuple(
59
+ (value - zero) * self.g_per_raw for zero, value in zip(self.zero_raw, raw, strict=True)
60
+ )
61
+ return (converted[0], converted[1], converted[2])
62
+
63
+ @staticmethod
64
+ def _validate_acceleration(name: str, value: object) -> float:
65
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(value):
66
+ msg = f"{name} must be a finite number: {value}"
67
+ raise InvalidInputError(msg)
68
+ return float(value)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class GyroCalibration(_ThreeAxisCalibration):
73
+ """Virtual gyroscope zero, reference, and fixed conversion scale."""
74
+
75
+ reference_raw: tuple[int, int, int] = (0x343B, 0x343B, 0x343B)
76
+
77
+ @property
78
+ def dps_per_raw(self) -> float:
79
+ """Return the fixed gyroscope sensitivity in degrees per second per raw unit."""
80
+ return 0.070
81
+
82
+ def gyro_rates_to_raw(
83
+ self,
84
+ rates_rad_s: tuple[float, float, float],
85
+ ) -> tuple[int, int, int]:
86
+ """Convert XYZ angular velocities in radians per second to raw values."""
87
+ x_rad_s = self._validate_rate("x_rad_s", rates_rad_s[0])
88
+ y_rad_s = self._validate_rate("y_rad_s", rates_rad_s[1])
89
+ z_rad_s = self._validate_rate("z_rad_s", rates_rad_s[2])
90
+ return (
91
+ self.zero_raw[0] + round(degrees(x_rad_s) / self.dps_per_raw),
92
+ self.zero_raw[1] + round(degrees(y_rad_s) / self.dps_per_raw),
93
+ self.zero_raw[2] + round(degrees(z_rad_s) / self.dps_per_raw),
94
+ )
95
+
96
+ def raw_to_gyro_rates(
97
+ self,
98
+ raw: tuple[int, int, int],
99
+ ) -> tuple[float, float, float]:
100
+ """Convert XYZ raw values to angular velocities in radians per second."""
101
+ return (
102
+ radians((raw[0] - self.zero_raw[0]) * self.dps_per_raw),
103
+ radians((raw[1] - self.zero_raw[1]) * self.dps_per_raw),
104
+ radians((raw[2] - self.zero_raw[2]) * self.dps_per_raw),
105
+ )
106
+
107
+ @staticmethod
108
+ def _validate_rate(name: str, value: object) -> float:
109
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(value):
110
+ msg = f"{name} must be a finite number: {value}"
111
+ raise InvalidInputError(msg)
112
+ return float(value)
113
+
114
+
115
+ DEFAULT_ACCELEROMETER_CALIBRATION = AccelerometerCalibration()
116
+ DEFAULT_GYRO_CALIBRATION = GyroCalibration()
swbt/input.py CHANGED
@@ -5,6 +5,7 @@ from dataclasses import dataclass
5
5
  from enum import Enum, auto
6
6
 
7
7
  from swbt.errors import InvalidInputError
8
+ from swbt.imu import DEFAULT_ACCELEROMETER_CALIBRATION, DEFAULT_GYRO_CALIBRATION
8
9
 
9
10
 
10
11
  class Button(Enum):
@@ -309,6 +310,42 @@ class IMUFrame:
309
310
  """
310
311
  return cls.raw(gyro=(x, y, z))
311
312
 
313
+ @classmethod
314
+ def gyro_rate(
315
+ cls,
316
+ *,
317
+ x_rad_s: float = 0.0,
318
+ y_rad_s: float = 0.0,
319
+ z_rad_s: float = 0.0,
320
+ ) -> "IMUFrame":
321
+ """Return a frame from XYZ gyroscope rates in radians per second.
322
+
323
+ Conversion uses the fixed virtual sensitivity of ``0.070 dps/raw``.
324
+
325
+ Args:
326
+ x_rad_s: X-axis angular velocity in radians per second.
327
+ y_rad_s: Y-axis angular velocity in radians per second.
328
+ z_rad_s: Z-axis angular velocity in radians per second.
329
+
330
+ Returns:
331
+ IMUFrame: Frame with converted gyroscope raw values and zero accelerometer.
332
+
333
+ Raises:
334
+ InvalidInputError: A converted raw value is outside the signed 16-bit range.
335
+ """
336
+ return cls.raw(gyro=DEFAULT_GYRO_CALIBRATION.gyro_rates_to_raw((x_rad_s, y_rad_s, z_rad_s)))
337
+
338
+ def to_gyro_rate(self) -> tuple[float, float, float]:
339
+ """Return XYZ gyroscope rates in radians per second.
340
+
341
+ Conversion uses the fixed virtual sensitivity of ``0.070 dps/raw``.
342
+
343
+ Returns:
344
+ tuple[float, float, float]: X, Y, and Z angular velocities in radians per
345
+ second.
346
+ """
347
+ return DEFAULT_GYRO_CALIBRATION.raw_to_gyro_rates((self.gyro_x, self.gyro_y, self.gyro_z))
348
+
312
349
  @classmethod
313
350
  def accel(cls, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
314
351
  """Return an IMU frame with only accelerometer axes set.
@@ -326,6 +363,41 @@ class IMUFrame:
326
363
  """
327
364
  return cls.raw(accel=(x, y, z))
328
365
 
366
+ @classmethod
367
+ def accel_g(
368
+ cls,
369
+ *,
370
+ x_g: float = 0.0,
371
+ y_g: float = 0.0,
372
+ z_g: float = 0.0,
373
+ ) -> "IMUFrame":
374
+ """Return a frame from XYZ accelerations in G using ``1/4096 G/raw``.
375
+
376
+ Args:
377
+ x_g: X-axis acceleration in G.
378
+ y_g: Y-axis acceleration in G.
379
+ z_g: Z-axis acceleration in G.
380
+
381
+ Returns:
382
+ IMUFrame: Frame with converted accelerometer raw values and zero gyroscope.
383
+
384
+ Raises:
385
+ InvalidInputError: A value is non-finite or converts outside signed 16-bit.
386
+ """
387
+ return cls.raw(
388
+ accel=DEFAULT_ACCELEROMETER_CALIBRATION.accelerations_to_raw((x_g, y_g, z_g))
389
+ )
390
+
391
+ def to_accel_g(self) -> tuple[float, float, float]:
392
+ """Return XYZ accelerations in G using ``1/4096 G/raw``.
393
+
394
+ Returns:
395
+ tuple[float, float, float]: X, Y, and Z accelerations in G.
396
+ """
397
+ return DEFAULT_ACCELEROMETER_CALIBRATION.raw_to_accelerations(
398
+ (self.accel_x, self.accel_y, self.accel_z)
399
+ )
400
+
329
401
  def with_gyro(self, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
330
402
  """Return a frame with replaced gyroscope axes.
331
403
 
@@ -345,6 +417,32 @@ class IMUFrame:
345
417
  gyro=(x, y, z),
346
418
  )
347
419
 
420
+ def with_gyro_rate(
421
+ self,
422
+ *,
423
+ x_rad_s: float = 0.0,
424
+ y_rad_s: float = 0.0,
425
+ z_rad_s: float = 0.0,
426
+ ) -> "IMUFrame":
427
+ """Return a frame with gyroscope rates replaced from radians per second.
428
+
429
+ Conversion uses the fixed virtual sensitivity of ``0.070 dps/raw``.
430
+
431
+ Args:
432
+ x_rad_s: Replacement X-axis angular velocity in radians per second.
433
+ y_rad_s: Replacement Y-axis angular velocity in radians per second.
434
+ z_rad_s: Replacement Z-axis angular velocity in radians per second.
435
+
436
+ Returns:
437
+ IMUFrame: Copy of this frame with accelerometer axes preserved.
438
+
439
+ Raises:
440
+ InvalidInputError: A converted raw value is outside the signed 16-bit range.
441
+ """
442
+ return self.with_gyro(
443
+ *DEFAULT_GYRO_CALIBRATION.gyro_rates_to_raw((x_rad_s, y_rad_s, z_rad_s))
444
+ )
445
+
348
446
  def with_accel(self, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
349
447
  """Return a frame with replaced accelerometer axes.
350
448
 
@@ -364,6 +462,30 @@ class IMUFrame:
364
462
  gyro=(self.gyro_x, self.gyro_y, self.gyro_z),
365
463
  )
366
464
 
465
+ def with_accel_g(
466
+ self,
467
+ *,
468
+ x_g: float = 0.0,
469
+ y_g: float = 0.0,
470
+ z_g: float = 0.0,
471
+ ) -> "IMUFrame":
472
+ """Return a frame with accelerations replaced using ``1/4096 G/raw``.
473
+
474
+ Args:
475
+ x_g: Replacement X-axis acceleration in G.
476
+ y_g: Replacement Y-axis acceleration in G.
477
+ z_g: Replacement Z-axis acceleration in G.
478
+
479
+ Returns:
480
+ IMUFrame: Copy of this frame with gyroscope axes preserved.
481
+
482
+ Raises:
483
+ InvalidInputError: A value is non-finite or converts outside signed 16-bit.
484
+ """
485
+ return self.with_accel(
486
+ *DEFAULT_ACCELEROMETER_CALIBRATION.accelerations_to_raw((x_g, y_g, z_g))
487
+ )
488
+
367
489
  @classmethod
368
490
  def _validate_i16(cls, field_name: str, value: object) -> int:
369
491
  if not isinstance(value, int) or not cls.MIN <= value <= cls.MAX:
@@ -0,0 +1,186 @@
1
+ """Pure IMU wire encoding from explicit state and report time."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ from math import cos, sin, sqrt
6
+ from struct import pack_into
7
+
8
+ from swbt.imu import GyroCalibration
9
+ from swbt.input import IMUFrame
10
+
11
+ _Quaternion = tuple[float, float, float, float]
12
+ _IDENTITY_QUATERNION: _Quaternion = (0.0, 0.0, 0.0, 1.0)
13
+
14
+
15
+ class ImuMode(IntEnum):
16
+ """Host-selected IMU wire mode."""
17
+
18
+ DISABLED = 0x00
19
+ STANDARD = 0x01
20
+ QUATERNION_1 = 0x02
21
+ QUATERNION_2 = 0x03
22
+ QUATERNION_3 = 0x04
23
+ QUATERNION_4 = 0x05
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ImuEncodingState:
28
+ """State required to encode the next quaternion IMU block."""
29
+
30
+ orientation: _Quaternion = _IDENTITY_QUATERNION
31
+ previous_report_ns: int | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ImuEncodingResult:
36
+ """One encoded IMU block and the state for the next report."""
37
+
38
+ block: bytes
39
+ state: ImuEncodingState
40
+
41
+
42
+ def encode_disabled_imu() -> ImuEncodingResult:
43
+ """Encode the disabled IMU block and reset its encoding state."""
44
+ return ImuEncodingResult(block=bytes(36), state=ImuEncodingState())
45
+
46
+
47
+ def encode_imu_block(
48
+ *,
49
+ state: ImuEncodingState,
50
+ mode: ImuMode,
51
+ frames: tuple[IMUFrame, IMUFrame, IMUFrame],
52
+ gyro_calibration: GyroCalibration,
53
+ now_ns: int,
54
+ ) -> ImuEncodingResult:
55
+ """Encode one IMU block for the explicit host-selected mode."""
56
+ if mode is ImuMode.DISABLED:
57
+ return encode_disabled_imu()
58
+ if mode is ImuMode.STANDARD:
59
+ return ImuEncodingResult(
60
+ block=encode_standard_imu(frames),
61
+ state=ImuEncodingState(),
62
+ )
63
+ return encode_quaternion_imu(
64
+ state=state,
65
+ frames=frames,
66
+ gyro_calibration=gyro_calibration,
67
+ now_ns=now_ns,
68
+ )
69
+
70
+
71
+ def encode_standard_imu(frames: tuple[IMUFrame, IMUFrame, IMUFrame]) -> bytes:
72
+ """Encode three raw six-axis frames as signed Int16LE values."""
73
+ result = bytearray(36)
74
+ for offset, frame in zip((0, 12, 24), frames, strict=True):
75
+ pack_into(
76
+ "<6h",
77
+ result,
78
+ offset,
79
+ frame.accel_x,
80
+ frame.accel_y,
81
+ frame.accel_z,
82
+ frame.gyro_x,
83
+ frame.gyro_y,
84
+ frame.gyro_z,
85
+ )
86
+ return bytes(result)
87
+
88
+
89
+ def encode_quaternion_imu(
90
+ *,
91
+ state: ImuEncodingState,
92
+ frames: tuple[IMUFrame, IMUFrame, IMUFrame],
93
+ gyro_calibration: GyroCalibration,
94
+ now_ns: int,
95
+ ) -> ImuEncodingResult:
96
+ """Encode one 36-byte packing-mode-2 block without mutating inputs."""
97
+ elapsed_seconds = 0.0
98
+ if state.previous_report_ns is not None:
99
+ elapsed_seconds = max(0, now_ns - state.previous_report_ns) / 1_000_000_000
100
+
101
+ orientation = state.orientation
102
+ sample_seconds = elapsed_seconds / len(frames)
103
+ for frame in frames:
104
+ rates = gyro_calibration.raw_to_gyro_rates((frame.gyro_x, frame.gyro_y, frame.gyro_z))
105
+ orientation = _next_orientation(orientation, rates, sample_seconds)
106
+
107
+ next_state = ImuEncodingState(
108
+ orientation=orientation,
109
+ previous_report_ns=now_ns,
110
+ )
111
+ return ImuEncodingResult(
112
+ block=_pack_mode_2(frames, orientation, timestamp_ms=now_ns // 1_000_000),
113
+ state=next_state,
114
+ )
115
+
116
+
117
+ def _next_orientation(
118
+ orientation: _Quaternion,
119
+ rates_rad_s: tuple[float, float, float],
120
+ elapsed_seconds: float,
121
+ ) -> _Quaternion:
122
+ rotation = tuple(rate * elapsed_seconds for rate in rates_rad_s)
123
+ magnitude = sqrt(sum(value * value for value in rotation))
124
+ if magnitude == 0:
125
+ return orientation
126
+ half_angle = magnitude / 2
127
+ vector_scale = sin(half_angle) / magnitude
128
+ delta = (
129
+ rotation[0] * vector_scale,
130
+ rotation[1] * vector_scale,
131
+ rotation[2] * vector_scale,
132
+ cos(half_angle),
133
+ )
134
+ return _normalize(_hamilton_product(orientation, delta))
135
+
136
+
137
+ def _hamilton_product(left: _Quaternion, right: _Quaternion) -> _Quaternion:
138
+ lx, ly, lz, lw = left
139
+ rx, ry, rz, rw = right
140
+ return (
141
+ lw * rx + lx * rw + ly * rz - lz * ry,
142
+ lw * ry + ly * rw + lz * rx - lx * rz,
143
+ lw * rz + lz * rw + lx * ry - ly * rx,
144
+ lw * rw - lx * rx - ly * ry - lz * rz,
145
+ )
146
+
147
+
148
+ def _normalize(value: _Quaternion) -> _Quaternion:
149
+ inverse = 1 / sqrt(sum(component * component for component in value))
150
+ return (value[0] * inverse, value[1] * inverse, value[2] * inverse, value[3] * inverse)
151
+
152
+
153
+ def _pack_mode_2(
154
+ frames: tuple[IMUFrame, IMUFrame, IMUFrame],
155
+ orientation: _Quaternion,
156
+ *,
157
+ timestamp_ms: int,
158
+ ) -> bytes:
159
+ result = bytearray(36)
160
+ for offset, frame in zip((0, 12, 24), frames, strict=True):
161
+ pack_into("<3h", result, offset, frame.accel_x, frame.accel_y, frame.accel_z)
162
+
163
+ max_index = max(range(4), key=lambda index: abs(orientation[index]))
164
+ sign = -1 if orientation[max_index] < 0 else 1
165
+ components = [
166
+ int(orientation[(max_index + index + 1) & 3] * sign * 0x40000000) >> 10
167
+ for index in range(3)
168
+ ]
169
+
170
+ _put_bits(result, 48, 2, 2)
171
+ _put_bits(result, 50, 2, max_index)
172
+ _put_bits(result, 52, 21, components[0])
173
+ _put_bits(result, 73, 21, components[1])
174
+ _put_bits(result, 94, 2, components[2])
175
+ _put_bits(result, 144, 19, components[2] >> 2)
176
+ _put_bits(result, 271, 11, timestamp_ms)
177
+ _put_bits(result, 282, 6, 3)
178
+ return bytes(result)
179
+
180
+
181
+ def _put_bits(target: bytearray, start: int, width: int, value: int) -> None:
182
+ masked = value & ((1 << width) - 1)
183
+ for bit in range(width):
184
+ if masked & (1 << bit):
185
+ absolute = start + bit
186
+ target[absolute // 8] |= 1 << (absolute % 8)
@@ -1,6 +1,7 @@
1
1
  """Input report builders."""
2
2
 
3
3
  from swbt.input import InputState, Stick
4
+ from swbt.protocol.imu_report import encode_standard_imu
4
5
  from swbt.protocol.profiles.base import ControllerProfile
5
6
  from swbt.protocol.profiles.pro_controller import default_controller_profile
6
7
 
@@ -8,11 +9,20 @@ from swbt.protocol.profiles.pro_controller import default_controller_profile
8
9
  class InputReportBuilder:
9
10
  """Build Switch HID input reports from immutable input state."""
10
11
 
11
- def __init__(self, profile: ControllerProfile | None = None) -> None:
12
+ def __init__(
13
+ self,
14
+ profile: ControllerProfile | None = None,
15
+ ) -> None:
12
16
  """Create a report builder."""
13
17
  self._profile = profile or default_controller_profile()
14
18
 
15
- def build_0x30(self, state: InputState, *, timer: int = 0) -> bytes:
19
+ def build_0x30(
20
+ self,
21
+ state: InputState,
22
+ *,
23
+ timer: int = 0,
24
+ imu_block: bytes | None = None,
25
+ ) -> bytes:
16
26
  """Build a 0x30 standard full input report."""
17
27
  self._profile.validate_input_state(state)
18
28
  report = bytearray(49)
@@ -23,9 +33,17 @@ class InputReportBuilder:
23
33
  report[6:9] = self._pack_stick(state.left_stick)
24
34
  report[9:12] = self._pack_stick(state.right_stick)
25
35
  report[12] = self._profile.vibrator_input
26
- self._pack_imu_frames(report, state)
36
+ block = encode_standard_imu(state.imu_frames) if imu_block is None else imu_block
37
+ self._place_imu_block(report, block)
27
38
  return bytes(report)
28
39
 
40
+ @staticmethod
41
+ def _place_imu_block(report: bytearray, imu_block: bytes) -> None:
42
+ if len(imu_block) != 36:
43
+ msg = f"IMU block must be 36 bytes, got {len(imu_block)}"
44
+ raise ValueError(msg)
45
+ report[13:49] = imu_block
46
+
29
47
  def _pack_buttons(self, report: bytearray, state: InputState) -> None:
30
48
  for button in state.buttons:
31
49
  offset, mask = self._profile.button_bit(button)
@@ -40,18 +58,3 @@ class InputReportBuilder:
40
58
  (stick.y >> 4) & 0xFF,
41
59
  )
42
60
  )
43
-
44
- @staticmethod
45
- def _pack_imu_frames(report: bytearray, state: InputState) -> None:
46
- cursor = 13
47
- for frame in state.imu_frames:
48
- for value in (
49
- frame.accel_x,
50
- frame.accel_y,
51
- frame.accel_z,
52
- frame.gyro_x,
53
- frame.gyro_y,
54
- frame.gyro_z,
55
- ):
56
- report[cursor : cursor + 2] = int(value).to_bytes(2, "little", signed=True)
57
- cursor += 2
@@ -4,6 +4,12 @@ from dataclasses import dataclass, field
4
4
  from enum import Enum
5
5
 
6
6
  from swbt.errors import InvalidInputError, UnsupportedInputError
7
+ from swbt.imu import (
8
+ DEFAULT_ACCELEROMETER_CALIBRATION,
9
+ DEFAULT_GYRO_CALIBRATION,
10
+ AccelerometerCalibration,
11
+ GyroCalibration,
12
+ )
7
13
  from swbt.input import Button, InputState, Stick
8
14
  from swbt.protocol.buttons import PRO_CONTROLLER_BUTTON_BITS, ButtonBitMap
9
15
  from swbt.protocol.descriptors import SWITCH_PRO_CONTROLLER_HID_REPORT_DESCRIPTOR
@@ -126,6 +132,8 @@ class ControllerProfile:
126
132
  hid_report_descriptor: bytes = SWITCH_PRO_CONTROLLER_HID_REPORT_DESCRIPTOR
127
133
  hid_sdp_policy: HidSdpPolicy = field(default_factory=HidSdpPolicy)
128
134
  controller_colors: ControllerColors = field(default_factory=ControllerColors)
135
+ accelerometer_calibration: AccelerometerCalibration = DEFAULT_ACCELEROMETER_CALIBRATION
136
+ gyro_calibration: GyroCalibration = DEFAULT_GYRO_CALIBRATION
129
137
  button_bits: ButtonBitMap = field(default_factory=lambda: PRO_CONTROLLER_BUTTON_BITS)
130
138
  imu_enable_modes: tuple[int, ...] = (0x00, 0x01)
131
139
  supports_left_stick: bool = True
@@ -14,7 +14,7 @@ from swbt.protocol.profiles.base import (
14
14
  HidSdpPolicy,
15
15
  )
16
16
 
17
- _JOYCON_IMU_ENABLE_MODE = 0x02
17
+ _JOYCON_IMU_ENABLE_MODES = (0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
18
18
 
19
19
 
20
20
  def _joycontrol_hid_sdp_policy() -> HidSdpPolicy:
@@ -63,7 +63,7 @@ class JoyConLeftProfile(ControllerProfile):
63
63
  hid_sdp_policy: HidSdpPolicy = field(default_factory=_joycontrol_hid_sdp_policy)
64
64
  controller_colors: ControllerColors = field(default_factory=_joycon_left_controller_colors)
65
65
  button_bits: ButtonBitMap = field(default_factory=lambda: JOYCON_LEFT_BUTTON_BITS)
66
- imu_enable_modes: tuple[int, ...] = (0x00, 0x01, _JOYCON_IMU_ENABLE_MODE)
66
+ imu_enable_modes: tuple[int, ...] = _JOYCON_IMU_ENABLE_MODES
67
67
  supports_left_stick: bool = True
68
68
  supports_right_stick: bool = False
69
69
 
@@ -79,6 +79,6 @@ class JoyConRightProfile(ControllerProfile):
79
79
  hid_sdp_policy: HidSdpPolicy = field(default_factory=_joycontrol_hid_sdp_policy)
80
80
  controller_colors: ControllerColors = field(default_factory=_joycon_right_controller_colors)
81
81
  button_bits: ButtonBitMap = field(default_factory=lambda: JOYCON_RIGHT_BUTTON_BITS)
82
- imu_enable_modes: tuple[int, ...] = (0x00, 0x01, _JOYCON_IMU_ENABLE_MODE)
82
+ imu_enable_modes: tuple[int, ...] = _JOYCON_IMU_ENABLE_MODES
83
83
  supports_left_stick: bool = False
84
84
  supports_right_stick: bool = True
@@ -15,7 +15,7 @@ class ProControllerProfile(ControllerProfile):
15
15
  device_type: int = 0x03
16
16
  device_info_tail: bytes = b"\x03\x02"
17
17
  button_bits: ButtonBitMap = field(default_factory=lambda: PRO_CONTROLLER_BUTTON_BITS)
18
- imu_enable_modes: tuple[int, ...] = (0x00, 0x01, 0x02)
18
+ imu_enable_modes: tuple[int, ...] = (0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
19
19
  supports_left_stick: bool = True
20
20
  supports_right_stick: bool = True
21
21
 
@@ -0,0 +1,112 @@
1
+ """Connection-scoped Switch HID protocol state."""
2
+
3
+ from dataclasses import dataclass, field, replace
4
+
5
+ from swbt.errors import ProtocolError
6
+ from swbt.input import IMUFrame
7
+ from swbt.protocol.imu_report import (
8
+ ImuEncodingState,
9
+ ImuMode,
10
+ encode_imu_block,
11
+ )
12
+ from swbt.protocol.profiles.base import ControllerProfile
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SwitchHidSessionState:
17
+ """Immutable host-requested state for one HID connection."""
18
+
19
+ report_mode: int | None = None
20
+ report_mode_supported: bool = False
21
+ unsupported_report_mode: int | None = None
22
+ imu_mode: ImuMode = ImuMode.DISABLED
23
+ imu_encoding_state: ImuEncodingState = field(default_factory=ImuEncodingState)
24
+ vibration_enabled: bool = False
25
+
26
+ @property
27
+ def imu_enabled(self) -> bool:
28
+ """Return whether the host selected an active IMU mode."""
29
+ return self.imu_mode is not ImuMode.DISABLED
30
+
31
+ @property
32
+ def imu_encoding_format(self) -> str:
33
+ """Return the diagnostic wire-format name derived from the IMU mode."""
34
+ if self.imu_mode is ImuMode.DISABLED:
35
+ return "disabled"
36
+ if self.imu_mode is ImuMode.STANDARD:
37
+ return "standard"
38
+ return "quaternion"
39
+
40
+
41
+ def apply_imu_mode_request(
42
+ state: SwitchHidSessionState,
43
+ *,
44
+ requested_mode: int,
45
+ accepted_modes: tuple[int, ...],
46
+ ) -> SwitchHidSessionState:
47
+ """Return the session state for a newly accepted IMU encoding epoch."""
48
+ if requested_mode not in accepted_modes:
49
+ msg = f"unsupported enable IMU value: 0x{requested_mode:02x}"
50
+ raise ProtocolError(msg)
51
+ try:
52
+ mode = ImuMode(requested_mode)
53
+ except ValueError as error:
54
+ msg = f"unknown enable IMU value: 0x{requested_mode:02x}"
55
+ raise ProtocolError(msg) from error
56
+ return replace(
57
+ state,
58
+ imu_mode=mode,
59
+ imu_encoding_state=ImuEncodingState(),
60
+ )
61
+
62
+
63
+ class SwitchHidSession:
64
+ """Own host-requested and IMU encoding state for one HID connection."""
65
+
66
+ def __init__(self, profile: ControllerProfile) -> None:
67
+ """Create a disabled session for the configured controller profile."""
68
+ self._profile = profile
69
+ self._state = SwitchHidSessionState()
70
+
71
+ @property
72
+ def state(self) -> SwitchHidSessionState:
73
+ """Return the current immutable session state."""
74
+ return self._state
75
+
76
+ def set_report_mode(self, mode: int, *, supported: bool) -> None:
77
+ """Record one host-selected input report mode."""
78
+ self._state = replace(
79
+ self._state,
80
+ report_mode=mode,
81
+ report_mode_supported=supported,
82
+ unsupported_report_mode=None if supported else mode,
83
+ )
84
+
85
+ def set_imu_mode(self, mode: int) -> None:
86
+ """Start a new IMU encoding epoch for an accepted host mode."""
87
+ self._state = apply_imu_mode_request(
88
+ self._state,
89
+ requested_mode=mode,
90
+ accepted_modes=self._profile.imu_enable_modes,
91
+ )
92
+
93
+ def set_vibration_enabled(self, enabled: bool) -> None:
94
+ """Record the host-selected vibration enable state."""
95
+ self._state = replace(self._state, vibration_enabled=enabled)
96
+
97
+ def encode_imu(
98
+ self,
99
+ frames: tuple[IMUFrame, IMUFrame, IMUFrame],
100
+ *,
101
+ now_ns: int,
102
+ ) -> bytes:
103
+ """Encode one IMU block and retain only its explicit next state."""
104
+ result = encode_imu_block(
105
+ state=self._state.imu_encoding_state,
106
+ mode=self._state.imu_mode,
107
+ frames=frames,
108
+ gyro_calibration=self._profile.gyro_calibration,
109
+ now_ns=now_ns,
110
+ )
111
+ self._state = replace(self._state, imu_encoding_state=result.state)
112
+ return result.block
swbt/protocol/spi.py CHANGED
@@ -16,6 +16,8 @@ class VirtualSpiFlash:
16
16
  COLOR_INFO_EXISTS_ADDRESS = 0x601B
17
17
  COLOR_INFO_EXISTS = 0x01
18
18
  CONTROLLER_COLORS_ADDRESS = 0x6050
19
+ FACTORY_ACCELEROMETER_CALIBRATION_ADDRESS = 0x6020
20
+ FACTORY_GYRO_CALIBRATION_ADDRESS = 0x602C
19
21
 
20
22
  def __init__(self, *, profile: ControllerProfile | None = None) -> None:
21
23
  """Create a virtual SPI flash image."""
@@ -27,6 +29,16 @@ class VirtualSpiFlash:
27
29
  self._data[
28
30
  self.CONTROLLER_COLORS_ADDRESS : self.CONTROLLER_COLORS_ADDRESS + len(controller_colors)
29
31
  ] = controller_colors
32
+ accelerometer_calibration = profile.accelerometer_calibration.to_spi_bytes()
33
+ accelerometer_address = self.FACTORY_ACCELEROMETER_CALIBRATION_ADDRESS
34
+ self._data[
35
+ accelerometer_address : accelerometer_address + len(accelerometer_calibration)
36
+ ] = accelerometer_calibration
37
+ gyro_calibration = profile.gyro_calibration.to_spi_bytes()
38
+ self._data[
39
+ self.FACTORY_GYRO_CALIBRATION_ADDRESS : self.FACTORY_GYRO_CALIBRATION_ADDRESS
40
+ + len(gyro_calibration)
41
+ ] = gyro_calibration
30
42
 
31
43
  def read(self, address: int, size: int) -> bytes:
32
44
  """Read bytes from the virtual SPI address space."""
@@ -1,13 +1,12 @@
1
1
  """Subcommand reply generation."""
2
2
 
3
- from dataclasses import dataclass
4
-
5
3
  from swbt.errors import ProtocolError
6
4
  from swbt.input import InputState
7
5
  from swbt.protocol.input_report import InputReportBuilder
8
6
  from swbt.protocol.output_report import OutputReport
9
7
  from swbt.protocol.profiles.base import ControllerProfile
10
8
  from swbt.protocol.profiles.pro_controller import default_controller_profile
9
+ from swbt.protocol.session import SwitchHidSession
11
10
  from swbt.protocol.spi import VirtualSpiFlash
12
11
 
13
12
  SIMPLE_ACK_SUBCOMMANDS = {0x08, 0x30}
@@ -21,18 +20,6 @@ MCU_CONFIG_DATA = bytes.fromhex(
21
20
  )
22
21
 
23
22
 
24
- @dataclass
25
- class SubcommandSessionState:
26
- """Mutable host-requested subcommand state for one responder lifetime."""
27
-
28
- report_mode: int | None = None
29
- report_mode_supported: bool = False
30
- unsupported_report_mode: int | None = None
31
- imu_mode: int | None = None
32
- imu_enabled: bool = False
33
- vibration_enabled: bool = False
34
-
35
-
36
23
  class UnsupportedSubcommandError(ProtocolError):
37
24
  """Raised when a subcommand is not supported by the responder."""
38
25
 
@@ -51,22 +38,13 @@ class SubcommandResponder:
51
38
  *,
52
39
  spi_flash: VirtualSpiFlash | None = None,
53
40
  profile: ControllerProfile | None = None,
54
- session_state: SubcommandSessionState | None = None,
55
41
  device_info_bluetooth_address: bytes = DEFAULT_DEVICE_INFO_BLUETOOTH_ADDRESS,
56
42
  ) -> None:
57
43
  """Create a responder."""
58
44
  self._profile = profile or default_controller_profile()
59
- self._session_state = (
60
- session_state if session_state is not None else SubcommandSessionState()
61
- )
62
45
  self._device_info_bluetooth_address = bytes(device_info_bluetooth_address)
63
46
  self._spi_flash = spi_flash or VirtualSpiFlash(profile=self._profile)
64
47
 
65
- @property
66
- def session_state(self) -> SubcommandSessionState:
67
- """Return the mutable subcommand state owned by this responder."""
68
- return self._session_state
69
-
70
48
  def set_device_info_bluetooth_address(self, bluetooth_address: bytes) -> None:
71
49
  """Update the Bluetooth address returned by subcommand 0x02."""
72
50
  if len(bluetooth_address) != 6:
@@ -74,13 +52,20 @@ class SubcommandResponder:
74
52
  raise ProtocolError(msg)
75
53
  self._device_info_bluetooth_address = bytes(bluetooth_address)
76
54
 
77
- def respond(self, output_report: OutputReport, *, state: InputState, timer: int = 0) -> bytes:
55
+ def respond(
56
+ self,
57
+ output_report: OutputReport,
58
+ *,
59
+ state: InputState,
60
+ session: SwitchHidSession | None = None,
61
+ timer: int = 0,
62
+ ) -> bytes:
78
63
  """Return a 0x21 reply for an output report with a subcommand."""
79
64
  if output_report.subcommand_id is None:
80
65
  msg = "output report does not include a subcommand"
81
66
  raise ProtocolError(msg)
82
67
 
83
- ack, data = self._reply_data(output_report)
68
+ ack, data = self._reply_data(output_report, session=session)
84
69
  return self._build_0x21_reply(
85
70
  subcommand_id=output_report.subcommand_id,
86
71
  ack=ack,
@@ -89,17 +74,31 @@ class SubcommandResponder:
89
74
  timer=timer,
90
75
  )
91
76
 
92
- def _reply_data(self, output_report: OutputReport) -> tuple[int, bytes]:
77
+ def _reply_data(
78
+ self,
79
+ output_report: OutputReport,
80
+ *,
81
+ session: SwitchHidSession | None,
82
+ ) -> tuple[int, bytes]:
93
83
  subcommand_id = output_report.subcommand_id
94
84
  if subcommand_id is None:
95
85
  msg = "output report does not include a subcommand"
96
86
  raise ProtocolError(msg)
97
87
  if subcommand_id == 0x03:
98
- return 0x80, self._set_input_report_mode(output_report.subcommand_payload)
88
+ return 0x80, self._set_input_report_mode(
89
+ output_report.subcommand_payload,
90
+ _require_session(session),
91
+ )
99
92
  if subcommand_id == 0x40:
100
- return 0x80, self._set_imu_enabled(output_report.subcommand_payload)
93
+ return 0x80, self._set_imu_enabled(
94
+ output_report.subcommand_payload,
95
+ _require_session(session),
96
+ )
101
97
  if subcommand_id == 0x48:
102
- return 0x80, self._set_vibration_enabled(output_report.subcommand_payload)
98
+ return 0x80, self._set_vibration_enabled(
99
+ output_report.subcommand_payload,
100
+ _require_session(session),
101
+ )
103
102
  if subcommand_id in SIMPLE_ACK_SUBCOMMANDS:
104
103
  return 0x80, b""
105
104
  if subcommand_id == 0x02:
@@ -114,23 +113,21 @@ class SubcommandResponder:
114
113
  return 0x80, _nfc_ir_mcu_state_payload(output_report.subcommand_payload)
115
114
  raise UnsupportedSubcommandError(subcommand_id, output_report.subcommand_payload)
116
115
 
117
- def _set_input_report_mode(self, payload: bytes) -> bytes:
116
+ def _set_input_report_mode(self, payload: bytes, session: SwitchHidSession) -> bytes:
118
117
  mode = _first_payload_byte(payload, "set input report mode")
119
- self._session_state.report_mode = mode
120
- self._session_state.report_mode_supported = mode == SUPPORTED_INPUT_REPORT_MODE
121
- self._session_state.unsupported_report_mode = (
122
- None if self._session_state.report_mode_supported else mode
118
+ session.set_report_mode(
119
+ mode,
120
+ supported=mode == SUPPORTED_INPUT_REPORT_MODE,
123
121
  )
124
122
  return b""
125
123
 
126
- def _set_imu_enabled(self, payload: bytes) -> bytes:
124
+ def _set_imu_enabled(self, payload: bytes, session: SwitchHidSession) -> bytes:
127
125
  imu_mode = _imu_enable_payload(payload, self._profile.imu_enable_modes)
128
- self._session_state.imu_mode = imu_mode
129
- self._session_state.imu_enabled = imu_mode != 0x00
126
+ session.set_imu_mode(imu_mode)
130
127
  return b""
131
128
 
132
- def _set_vibration_enabled(self, payload: bytes) -> bytes:
133
- self._session_state.vibration_enabled = _enable_payload(payload, "enable vibration")
129
+ def _set_vibration_enabled(self, payload: bytes, session: SwitchHidSession) -> bytes:
130
+ session.set_vibration_enabled(_enable_payload(payload, "enable vibration"))
134
131
  return b""
135
132
 
136
133
  def _spi_read_reply_data(self, payload: bytes) -> bytes:
@@ -171,6 +168,13 @@ def _first_payload_byte(payload: bytes, subcommand_name: str) -> int:
171
168
  return payload[0]
172
169
 
173
170
 
171
+ def _require_session(session: SwitchHidSession | None) -> SwitchHidSession:
172
+ if session is None:
173
+ msg = "session-state subcommand requires an explicit SwitchHidSession"
174
+ raise ProtocolError(msg)
175
+ return session
176
+
177
+
174
178
  def _enable_payload(payload: bytes, subcommand_name: str) -> bool:
175
179
  value = _first_payload_byte(payload, subcommand_name)
176
180
  if value not in (0x00, 0x01):
@@ -183,7 +187,8 @@ def _imu_enable_payload(payload: bytes, accepted_modes: tuple[int, ...]) -> int:
183
187
  value = _first_payload_byte(payload, "enable IMU")
184
188
  if value in accepted_modes:
185
189
  return value
186
- msg = "enable IMU subcommand argument must be 0x00 or 0x01"
190
+ accepted = ", ".join(f"0x{mode:02X}" for mode in accepted_modes)
191
+ msg = f"enable IMU subcommand argument must be one of: {accepted}"
187
192
  raise ProtocolError(msg)
188
193
 
189
194
 
swbt/report_loop.py CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  import asyncio
4
4
  from collections import deque
5
+ from collections.abc import Callable
5
6
  from contextlib import suppress
7
+ from time import monotonic_ns
6
8
 
7
9
  from swbt.diagnostics import DiagnosticsRecorder
8
10
  from swbt.protocol.input_report import InputReportBuilder
11
+ from swbt.protocol.session import SwitchHidSession
9
12
  from swbt.state_store import InputStateStore
10
13
  from swbt.transport.base import HidDeviceTransport
11
14
 
@@ -21,14 +24,18 @@ class ReportLoop:
21
24
  transport: HidDeviceTransport,
22
25
  state_store: InputStateStore,
23
26
  input_report_builder: InputReportBuilder,
27
+ session: SwitchHidSession,
24
28
  report_period_us: int = 8000,
25
29
  diagnostics: DiagnosticsRecorder | None = None,
30
+ clock_ns: Callable[[], int] = monotonic_ns,
26
31
  ) -> None:
27
32
  """Create a report loop helper."""
28
33
  self._transport = transport
29
34
  self._state_store = state_store
30
35
  self._report_period_seconds = report_period_us / 1_000_000
31
36
  self._input_report_builder = input_report_builder
37
+ self._session = session
38
+ self._clock_ns = clock_ns
32
39
  self._diagnostics = diagnostics
33
40
  self._reply_queue: deque[bytes] = deque()
34
41
  self._timer = 0
@@ -57,11 +64,13 @@ class ReportLoop:
57
64
  async with self._send_lock:
58
65
  await self._send_current_input_locked(reason=reason)
59
66
 
60
- async def send_subcommand_reply(self, report: bytes) -> None:
61
- """Send one 0x21 subcommand reply with the shared report timer."""
67
+ async def send_subcommand_reply(self, build_report: Callable[[], bytes]) -> bytes:
68
+ """Apply a subcommand transition and send its reply under the send lock."""
62
69
  async with self._send_lock:
70
+ report = build_report()
63
71
  await self._send_subcommand_reply_locked(report)
64
72
  self._holdoff_periodic_after_reply()
73
+ return report
65
74
 
66
75
  def queue_reply(self, report: bytes) -> None:
67
76
  """Queue one subcommand reply for priority transmission."""
@@ -80,7 +89,12 @@ class ReportLoop:
80
89
 
81
90
  async def _send_current_input_locked(self, *, reason: str) -> None:
82
91
  state = await self._state_store.snapshot()
83
- report = self._input_report_builder.build_0x30(state, timer=self._timer)
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
+ )
84
98
  await self._send_report(report, reason=reason)
85
99
  self._advance_timer()
86
100
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swbt-python
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python library for presenting an NX-compatible virtual Bluetooth HID input device.
5
5
  Keywords: bluetooth,bluetooth-hid,controller,gamepad,hid,nx
6
6
  Author: niart120
@@ -83,7 +83,6 @@ asyncio.run(main())
83
83
  ```
84
84
 
85
85
  Pro Controller 相当の仮想デバイスを作成し、ペアリング後に A ボタン入力を送信するコードの例です。
86
- 接続情報ファイルの形式、入力 API の使い分けなどに関する詳しい説明は [Usage Guide](https://niart120.github.io/swbt-python/usage/) にあります。
87
86
 
88
87
  ### Joy-Con L/R
89
88
 
@@ -129,7 +128,7 @@ macOS 15.7.7 / CSR8510 A10 では、Pro Controller のペアリング、保存
129
128
 
130
129
  ### 実験的構成
131
130
 
132
- Linux は experimental です。手順は Hardware Guide に整備されていますが、専用 USB Bluetooth ドングルにアクセスできるか、ペアリングできるか、入力が反映されるかは未確認です。macOS は Pro Controller の限定観測だけで、Joy-Con、別ドングル、別ファームウェアでの互換性は未確認です。
131
+ Linux は experimental です。手順は Hardware Guide に整備されていますが、専用 USB Bluetooth ドングルにアクセスできるか、ペアリングできるか、入力が反映されるかは未確認です。macOS は Pro Controller の一部挙動のみ検証済みです。Joy-Con、別ドングル、別ファームウェアでの互換性は未確認です。
133
132
 
134
133
  ## 開発
135
134
 
@@ -10,25 +10,28 @@ swbt/gamepad/connection.py,sha256=My3deMw4L64UoEXjeyRNBs1D6UvrvkeC2P7yf_PLPYQ,77
10
10
  swbt/gamepad/constants.py,sha256=gDesRFvVxZ3SMSiP4qzd42em_Dt9M5hIu3-2exgKEus,84
11
11
  swbt/gamepad/core.py,sha256=vGY24NsCxJ37XY3jagk6P1DZzDM1JJwXdggZM7fk1Ug,15042
12
12
  swbt/gamepad/interface.py,sha256=GgPo5VwFhvcPIDy265R_dOQSYrBN1MYekqyPvIuXWQY,9431
13
- swbt/gamepad/output.py,sha256=CN0gp6rgaYkg8eTMVH-YpdHk2eZMkKUSsT_vmKaaPk8,3802
14
- swbt/gamepad/runtime.py,sha256=pkSQv2I6zAV7o2Iwu9pmJ7Rf06T2NJ4XU-dc4b1pAR8,29619
13
+ swbt/gamepad/output.py,sha256=Et2efB09tkNZF50_0ANk-JjHTJsJBpbzSdsJeWMDtfA,4101
14
+ swbt/gamepad/runtime.py,sha256=28wDzmCWWEihSLnzg2E0MYP73z3fIGoc2Vu98XZuH88,30508
15
15
  swbt/gamepad/transport_factory.py,sha256=2LLGIF3Bc4ufBNjp1AcdqNkHNP8ZBDFU2PNt898vo8o,2059
16
- swbt/input.py,sha256=M6TRm8iGteI2UaO4uHwNVC40m4p_S3IrnWm5f9pl_jA,18153
16
+ swbt/imu.py,sha256=sT1_MsYECQlQ-pSfayD8gojD9l3w9zEtOI3xP1IovQM,4237
17
+ swbt/input.py,sha256=7gv75lzrcmoV8Sijz7f1pvsheRrmUi_TCIMW3DjUVjw,22275
17
18
  swbt/probe.py,sha256=DnHMGhfDU2ooyC01JID7JQRly6BAyjFJVTEi-Mco0uY,7283
18
19
  swbt/protocol/__init__.py,sha256=Uvdlp06CBsKDivW1juPY-T5LaqJVCF00MbQaHl13XPo,35
19
20
  swbt/protocol/buttons.py,sha256=P0ojZ8tlpwwR007N_QL158a1rWOXYnYhZ3n8eCtZodc,1740
20
21
  swbt/protocol/descriptors.py,sha256=8J-l9xYUoCs5PODO6nkuEo4YCzhnTUzA_EAzKz-UC7g,2982
21
- swbt/protocol/input_report.py,sha256=9QQNU9NVISNiyVsXQjSMT9QwgXP4E-lB57t_H2k9J_k,2048
22
+ swbt/protocol/imu_report.py,sha256=xpo21hiRD5Bnq8-z3PBLXoDxSZNAWf-hMOltguosEeg,5656
23
+ swbt/protocol/input_report.py,sha256=jy8AuIwUFPbsyp2XVqBnpU_AD9MeFuMO8FWdrW4Bgig,2063
22
24
  swbt/protocol/output_report.py,sha256=00d-QZRBWFJ9k5ZLl5I0nSAe2pZY0oR50Z0YvZ2_YWs,1751
23
25
  swbt/protocol/profiles/__init__.py,sha256=LZH7FTwdMTyWlT-tJ2y558mdsPOHq9gOK5jYdgNkPUc,82
24
- swbt/protocol/profiles/base.py,sha256=l_9lbaMdRnYtGQPlgK-YpTFVFavfTKilGVh70YiWN80,9583
25
- swbt/protocol/profiles/joycon.py,sha256=dwK9OcguIsw42AmPkBi_9RzTLCgLRf40_P1eccUgzr8,2708
26
- swbt/protocol/profiles/pro_controller.py,sha256=9IN6xJtrDnt0w4VjOu339RH6tG_MSJ2m4O_b1aDC2bM,939
26
+ swbt/protocol/profiles/base.py,sha256=hCYEZQ37TlJdwBzjVr-VBhG7kdyJF2V2VrLaBYEEbZM,9885
27
+ swbt/protocol/profiles/joycon.py,sha256=vLuvdq217jkQZEkqs7yPuJ-8yUwK6x_6W_1f_tuvF9I,2715
28
+ swbt/protocol/profiles/pro_controller.py,sha256=zR4rjaf1AfU1VFi2xkgGM4dqiJWAuX_jQ_1G9WrDfz8,957
27
29
  swbt/protocol/rumble.py,sha256=94Qq4gstmU5KlER-i0eW3mSr1HKY3CgArdz9D5gpqso,584
28
- swbt/protocol/spi.py,sha256=SiSN17WYKecEQS4q7DoBrKsfbK5QzeuoEhWGjJl66as,1938
29
- swbt/protocol/subcommand.py,sha256=CfhUO6cWadYq5i-I3EGbBXGOyEqbVmGy1KXxQml3EoQ,7756
30
+ swbt/protocol/session.py,sha256=TtnnFySalCXsEUwVv0DX9w1E2xq1yFIfmgCrA6wGHzs,3758
31
+ swbt/protocol/spi.py,sha256=buKyNYYOHOSYbpVh3Wa2968FL4VIO_43QcSXNowNfJs,2594
32
+ swbt/protocol/subcommand.py,sha256=1Z3Xf8xo7AydhyTa3bHgHM24hN2YS37yp7eioOHTu0A,7633
30
33
  swbt/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
31
- swbt/report_loop.py,sha256=sMb3fEHOAuxM7yD1V0aKocCJ36My1j5MjwQpVHKYM5k,4384
34
+ swbt/report_loop.py,sha256=3qGLLWpJo_lWMdYXkkLqajZrQJtTLF2w7Z1z7iruB8c,4903
32
35
  swbt/state_store.py,sha256=Mf8uKR5CoQdzyMmqflNE_2KUWE_ZgmLaHcGXUcVThW4,2693
33
36
  swbt/transport/__init__.py,sha256=b_Vqsg0k_g7XclN9XHQrcJxqCwGIklxG5NqzISal4_I,68
34
37
  swbt/transport/_bumble_acl.py,sha256=RnueTwEgAd0w3YgxnXkw0AKQXMeECIRope96W-xQ1K8,1425
@@ -40,8 +43,8 @@ swbt/transport/_bumble_usb_devices.py,sha256=soAKiHTzeeTYdb7M357yaac-RxG2c_BiKWH
40
43
  swbt/transport/base.py,sha256=lAGAJjGMg5CwtdAKmefCZglWqT3mWzm7CFOOCGGKul4,5619
41
44
  swbt/transport/bumble.py,sha256=gB0vy1GW-1Mbg_EZuYSrdOKVClKyNEKXMy1jju41Bts,30147
42
45
  swbt/transport/fake.py,sha256=Pmr-9a3qg2v9Rs86wBjINdWmWi5KOZlcFMH3AOmWF58,12903
43
- swbt_python-0.2.0.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
44
- swbt_python-0.2.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
45
- swbt_python-0.2.0.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
46
- swbt_python-0.2.0.dist-info/METADATA,sha256=5R1B6tdPgNOolLPy3PBJkO60VFNfmFRKCtjD0_nbK9A,6158
47
- swbt_python-0.2.0.dist-info/RECORD,,
46
+ swbt_python-0.3.0.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
47
+ swbt_python-0.3.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
48
+ swbt_python-0.3.0.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
49
+ swbt_python-0.3.0.dist-info/METADATA,sha256=Dta7JIdGnIRn3tZ8xqguwesI8Vf94-4CIL5b-UMbHWA,5994
50
+ swbt_python-0.3.0.dist-info/RECORD,,