Add stateful HD-rumble decoder with TinyUSB report normalization

Decode Nintendo HD-rumble words once in firmware into conventional low/high magnitudes and normalize stripped control SET_REPORT vs complete interrupt OUT reports through one path.
This commit is contained in:
Joey Yakimowich-Payne 2026-08-29 22:12:24 -06:00
commit bce94d38ce
21 changed files with 902 additions and 180 deletions

View file

@ -6,8 +6,11 @@ from switch_pico_bridge.switch_pico_uart import (
SwitchReport,
IMUSample,
SwitchDpad,
PicoUART,
UART_HEADER,
UART_PROTOCOL_VERSION,
RUMBLE_HEADER,
RUMBLE_TYPE_DECODED,
ACCEL_LSB_PER_G,
GYRO_LSB_PER_RAD_S,
MS2_PER_G,
@ -15,6 +18,36 @@ from switch_pico_bridge.switch_pico_uart import (
)
class BufferedSerial:
def __init__(self, data: bytes = b""):
self._data = bytearray(data)
@property
def in_waiting(self) -> int:
return len(self._data)
def read(self, size: int) -> bytes:
data = bytes(self._data[:size])
del self._data[:size]
return data
def feed(self, data: bytes) -> None:
self._data.extend(data)
def make_rumble_frame(low: int, high: int) -> bytes:
frame = bytes([RUMBLE_HEADER, RUMBLE_TYPE_DECODED, low, high])
return frame + bytes([compute_checksum(frame)])
def make_uart(data: bytes = b"") -> tuple[PicoUART, BufferedSerial]:
uart = object.__new__(PicoUART)
serial_port = BufferedSerial(data)
uart.serial = serial_port
uart._buffer = bytearray()
return uart, serial_port
def test_v2_frame_with_imu_samples():
"""V2 frame with 3 IMU samples should be 48 bytes with correct layout."""
r = SwitchReport(
@ -118,3 +151,34 @@ def test_max_imu_samples_capped():
assert len(data) == 48 # 3 samples, not 5
assert data[10] == 3
assert data[2] == 44 # payload_len for 3 samples
def test_decoded_rumble_frame_survives_fragmented_input():
frame = make_rumble_frame(64, 192)
uart, serial_port = make_uart(frame[:3])
assert uart.read_rumble() is None
serial_port.feed(frame[3:])
assert uart.read_rumble() == pytest.approx((64 / 255.0, 192 / 255.0))
def test_decoded_rumble_frame_resynchronizes_after_garbage():
uart, _ = make_uart(b"\x00\xffnot-a-frame" + make_rumble_frame(12, 34))
assert uart.read_rumble() == pytest.approx((12 / 255.0, 34 / 255.0))
def test_decoded_rumble_frame_rejects_bad_checksum():
corrupted = bytearray(make_rumble_frame(25, 50))
corrupted[-1] ^= 0x01
uart, _ = make_uart(bytes(corrupted) + make_rumble_frame(75, 100))
assert uart.read_rumble() == pytest.approx((75 / 255.0, 100 / 255.0))
def test_decoded_rumble_zero_and_full_magnitudes():
uart, _ = make_uart(make_rumble_frame(0, 0) + make_rumble_frame(255, 255))
assert uart.read_rumble() == (0.0, 0.0)
assert uart.read_rumble() == (1.0, 1.0)