switch-pico/tests/test_uart_rumble.py
Joey Yakimowich-Payne b6a017eb06 Emulate up to four controllers on the UART firmware
UART protocol v3 adds a slot byte to input frames (v2 still accepted as
slot 0) and slot-tagged BB 03 rumble frames. The regular firmware exposes
SWITCH_PICO_UART_CONTROLLERS (default 4) Switch Pro interfaces like the
AIO build. The bridge shares one serial port across controllers, maps
index:port[:slot], and demuxes rumble by slot.
2026-09-22 10:25:30 -06:00

179 lines
6.5 KiB
Python

"""Focused tests for decoded UART rumble delivery to SDL3."""
from argparse import Namespace
from io import StringIO
from typing import cast
import pytest
import sdl3
from rich.console import Console
import switch_pico_bridge.controller_uart_bridge as bridge
from switch_pico_bridge.switch_pico_uart import PicoUART, SwitchReport, UART_BAUD
class RecordingUART:
def __init__(self) -> None:
self.rumble: list[tuple[int, float, float]] = []
self.sent: list[tuple[int, int]] = []
def send_report(self, report: SwitchReport, slot: int = 0) -> None:
self.sent.append((slot, report.buttons))
def read_rumble(self) -> tuple[int, float, float] | None:
if not self.rumble:
return None
return self.rumble.pop(0)
def make_links(port: str, uart: RecordingUART) -> dict[str, bridge.UartLink]:
return {port: bridge.UartLink(port, cast(PicoUART, cast(object, uart)))}
def make_config() -> bridge.BridgeConfig:
return bridge.BridgeConfig(
interval=10.0,
deadzone_raw=0,
trigger_threshold=0,
zero_sticks=False,
zero_hotkey="",
swap_hotkey="",
button_map_default={},
button_map_swapped={},
swap_abxy_indices=set(),
swap_abxy_ids=set(),
swap_abxy_global=False,
no_imu=True,
)
def test_apply_rumble_maps_low_and_high_with_50ms_duration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[int, int, int]] = []
def fake_rumble(_controller, low, high, duration):
calls.append((low, high, duration))
return True
monkeypatch.setattr(bridge.sdl3, "SDL_RumbleGamepad", fake_rumble)
controller = cast(sdl3.SDL_Gamepad, object())
assert bridge.apply_rumble(controller, 1.0, 0.5) == (True, True)
assert calls[-1] == (0xFFFF, 0x7FFF, 50)
assert bridge.apply_rumble(controller, 0.0, 0.0) == (False, True)
assert calls[-1] == (0, 0, 50)
def test_shape_rumble_curve_and_gain_boost_faint_levels_but_keep_silence() -> None:
# Linear defaults are the identity.
assert bridge.shape_rumble(0.3, 1.0, 1.0) == pytest.approx(0.3)
# Zero must never become a nonzero idle buzz, whatever the shaping.
assert bridge.shape_rumble(0.0, 4.0, 0.5) == 0.0
# Typical Switch HD levels (~0.03) are lifted into the ERM motor's usable range.
boosted = bridge.shape_rumble(0.031, 1.0, 0.5)
assert 0.15 < boosted < 0.2
# Gain saturates instead of wrapping.
assert bridge.shape_rumble(0.467, 4.0, 1.0) == 1.0
# Shaping is monotonic so louder input never rumbles softer.
assert bridge.shape_rumble(0.1, 2.0, 0.5) < bridge.shape_rumble(0.3, 2.0, 0.5)
def test_repeated_constant_rumble_stays_active_until_idle_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[int, int, int]] = []
monkeypatch.setattr(
bridge.sdl3,
"SDL_RumbleGamepad",
lambda _controller, low, high, duration: calls.append((low, high, duration)),
)
monkeypatch.setattr(bridge, "poll_controller_buttons", lambda _ctx, _map: None)
uart = RecordingUART()
controller = cast(sdl3.SDL_Gamepad, object())
ctx = bridge.ControllerContext(controller, 7, 0, "controller", "/dev/null")
links = make_links("/dev/null", uart)
contexts = {ctx.instance_id: ctx}
args = Namespace(baud=UART_BAUD)
console = Console(file=StringIO())
magnitude = (0, 64 / 255.0, 192 / 255.0)
uart.rumble.append(magnitude)
bridge.service_contexts(1.0, args, make_config(), contexts, links, console)
uart.rumble.append(magnitude)
bridge.service_contexts(1.7, args, make_config(), contexts, links, console)
bridge.service_contexts(1.71, args, make_config(), contexts, links, console)
assert calls == [(16448, 49344, 50), (16448, 49344, 50)]
assert ctx.rumble_active
bridge.service_contexts(1.96, args, make_config(), contexts, links, console)
assert calls[-1] == (0, 0, 0)
assert not ctx.rumble_active
def test_shared_port_routes_reports_and_rumble_by_slot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[object, int, int]] = []
monkeypatch.setattr(
bridge.sdl3,
"SDL_RumbleGamepad",
lambda controller, low, high, _duration: calls.append((controller, low, high)) or True,
)
monkeypatch.setattr(bridge, "poll_controller_buttons", lambda _ctx, _map: None)
uart = RecordingUART()
pad_a = cast(sdl3.SDL_Gamepad, object())
pad_b = cast(sdl3.SDL_Gamepad, object())
ctx_a = bridge.ControllerContext(pad_a, 7, 0, "a", "COM11", slot=0)
ctx_b = bridge.ControllerContext(pad_b, 8, 1, "b", "COM11", slot=2)
ctx_a.report.buttons = 0x0001
ctx_b.report.buttons = 0x0002
contexts = {7: ctx_a, 8: ctx_b}
links = make_links("COM11", uart)
args = Namespace(baud=UART_BAUD)
console = Console(file=StringIO())
# Slot 2 rumbles, slot 0 is idle, slot 3 has no controller attached.
uart.rumble.extend([(0, 0.0, 0.0), (2, 1.0, 0.5), (3, 1.0, 1.0)])
bridge.service_contexts(20.0, args, make_config(), contexts, links, console)
assert sorted(uart.sent) == [(0, 0x0001), (2, 0x0002)]
assert calls == [(pad_a, 0, 0), (pad_b, 0xFFFF, 0x7FFF)]
assert not ctx_a.rumble_active
assert ctx_b.rumble_active
def test_auto_pairing_spreads_controllers_across_ports_then_fills_slots() -> None:
pairing = bridge.PairingState(
mapping_by_index={},
available_ports=["COM11", "COM12"],
slots_per_port=2,
auto_pairing_enabled=True,
)
console = Console(file=StringIO())
assignments = [bridge.assign_port_for_index(pairing, idx, console) for idx in range(5)]
assert assignments == [("COM11", 0), ("COM12", 0), ("COM11", 1), ("COM12", 1), None]
# Releasing a slot makes exactly that slot reusable.
del pairing.mapping_by_index[2]
assert bridge.assign_port_for_index(pairing, 9, console) == ("COM11", 1)
def test_explicit_mappings_fill_omitted_slots_and_reject_conflicts() -> None:
parser = bridge.build_arg_parser()
resolved = bridge.resolve_mapping_slots(
[(0, "COM11", None), (1, "COM11", 3), (2, "COM11", None)], 4, parser
)
assert resolved == {0: ("COM11", 0), 1: ("COM11", 3), 2: ("COM11", 1)}
with pytest.raises(SystemExit):
bridge.resolve_mapping_slots([(0, "COM11", 1), (1, "COM11", 1)], 4, parser)
with pytest.raises(SystemExit):
bridge.resolve_mapping_slots([(0, "COM11", None), (1, "COM11", None)], 1, parser)
assert bridge.parse_mapping("2:COM11:3") == (2, "COM11", 3)
assert bridge.parse_mapping("0:/dev/ttyUSB0") == (0, "/dev/ttyUSB0", None)