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.
179 lines
6.5 KiB
Python
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)
|