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.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-22 10:25:30 -06:00
commit b6a017eb06
14 changed files with 737 additions and 411 deletions

View file

@ -564,8 +564,14 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HD_RUMBLE=1)
endif()
else()
set(SWITCH_PICO_UART_CONTROLLERS 4 CACHE STRING
"Switch Pro controllers exposed by the UART firmware (1-4, one USB interface each)")
if(NOT SWITCH_PICO_UART_CONTROLLERS MATCHES "^[1-4]$")
message(FATAL_ERROR
"SWITCH_PICO_UART_CONTROLLERS must be 1, 2, 3 or 4 (got '${SWITCH_PICO_UART_CONTROLLERS}')")
endif()
target_compile_definitions(switch-pico PRIVATE
SWITCH_PICO_HID_INSTANCE_COUNT=1
SWITCH_PICO_HID_INSTANCE_COUNT=${SWITCH_PICO_UART_CONTROLLERS}
)
endif()

View file

@ -3,7 +3,7 @@
Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from Bluetooth controllers through Bluepad32.
## What you get
- **Firmware** (`src/firmware/`): acts as a Switch Pro controller (one on standard Pico, four on Pico 2 W AIO), accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
- **Firmware** (`src/firmware/`): acts as up to four Switch Pro controllers on one USB device (regular Pico driven over UART, or Pico 2 W AIO), accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
- **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL3 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux).
- **Color configuration** (`src/firmware/platform/pico/controller_color_config.h`): compile-time RGB colors for emulated controller grips and supported Bluetooth controller LEDs.
- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts four concurrent Bluetooth controllers and sends their controls, calibrated motion, rumble, and slot identity through four separate Switch Pro USB interfaces without a computer.
@ -2458,13 +2458,41 @@ RUMBLE (force feedback)
Nintendo sends two stateful four-byte HD-rumble actuator words with full/relative low/high-band commands and up to three substeps. `SwitchHapticsDecoder` retains this timeline as well as conventional strong/weak magnitudes. The selected DualSense's native PCM backend uses the timeline; ordinary controller-parser and UART/SDL paths use the magnitudes. Preserving frequency intent is not a claim of identical force response across actuators. Native forwarding for genuine Switch-family controllers is [planned separately](ADAPTER_PARITY_PLAN.md#native-switch-family-hd-rumble--planned), not enabled by the DualSense implementation.
The UART return frame carries the decoded result rather than raw HD-rumble bytes:
### UART framing (v3, multi-controller)
The regular Pico firmware exposes up to four Switch Pro controllers on one USB
device (`SWITCH_PICO_UART_CONTROLLERS`, default 4, CMake cache option). Every
frame in both directions names the slot it belongs to:
```text
0xBB, 0x02, low-frequency magnitude, high-frequency magnitude, checksum
Host -> Pico : 0xAA, 0x03, payload_len, slot, payload..., checksum
Pico -> Host : 0xBB, 0x03, slot, low-frequency magnitude, high-frequency magnitude, checksum
```
The checksum is the sum of the first four bytes modulo 256. Firmware and Python bridge versions from before this change are not rumble-protocol compatible; controller input framing remains unchanged.
The checksum is the sum of all preceding bytes modulo 256; the payload is
unchanged from v2 (buttons, hat, sticks, IMU count and samples). The firmware
still accepts v2 input frames (`0xAA, 0x02, payload_len, payload..., checksum`)
as slot 0, so older `switch_pico_uart` scripts keep working, but it only emits
the slot-tagged rumble frame. The bridge accepts both the 0x03 slot frame and
the older 5-byte `0xBB, 0x02` frame (as slot 0). Firmware and bridge from before
this change are not compatible with each other beyond that: an old bridge sees
no rumble from new firmware, and an old firmware ignores v3 input.
Bridge usage with several controllers on one Pico:
```sh
# Auto-pairing fills slots 0-3 on a port once every available port has one controller.
controller-uart-bridge --ports COM11
# Explicit slots (omitted slots are filled in order from 0).
controller-uart-bridge --map 0:COM11 --map 1:COM11 --map 2:COM11:3
# Firmware built with SWITCH_PICO_UART_CONTROLLERS=1, or one controller per Pico:
controller-uart-bridge --slots-per-port 1
```
All configured controllers are always present to the Switch, exactly like the
AIO firmware. Four controllers with IMU enabled at the default 500 Hz exceed the
921600-baud link (4 x 49 bytes x 500 Hz ~ 98 KB/s vs ~92 KB/s); use
`--frequency 250` or `--no-imu` when multiplexing all four.
## Hardware wiring (Pico)
- UART1 pins (fixed in firmware):
@ -2698,12 +2726,12 @@ For simple scripts or tests you can skip SDL and drive the Pico directly with `s
```python
from switch_pico_bridge import SwitchUARTClient, SwitchButton, SwitchDpad
with SwitchUARTClient("/dev/cu.usbserial-0001") as client:
with SwitchUARTClient("/dev/cu.usbserial-0001", slot=0) as client: # slot selects one of the Pico's four controllers
client.press(SwitchButton.A)
client.release(SwitchButton.A)
client.move_left_stick(0.0, -1.0) # push up
client.set_hat(SwitchDpad.UP_RIGHT)
print(client.poll_rumble()) # returns (left, right) amplitudes 0.0-1.0 or None
print(client.poll_rumble()) # returns (low, high) amplitudes 0.0-1.0 for this slot, or None
```
- `SwitchButton` is an `IntFlag` (bitwise friendly) and `SwitchDpad` is an `IntEnum` for the DPAD/hat values (alias `SwitchHat` remains for older scripts).
- The helper only depends on `pyserial`; SDL is not required.

View file

@ -29,8 +29,9 @@ ALL_BUTTONS = [
def main() -> None:
# auto_send keeps the current state flowing in the background, so we don't
# need to manually pump frames to the Pico.
with SwitchUARTClient(PORT, send_interval=SEND_INTERVAL, auto_send=True) as client:
# need to manually pump frames to the Pico. slot picks which of the Pico's
# four emulated controllers (0-3) this script drives.
with SwitchUARTClient(PORT, send_interval=SEND_INTERVAL, auto_send=True, slot=0) as client:
client.neutral()
# Press every button/DPAD direction one-by-one, holding each briefly.

Binary file not shown.

Binary file not shown.

View file

@ -33,7 +33,7 @@
#define UART_TX_PIN 4
#define UART_RX_PIN 5
#define UART_RUMBLE_HEADER 0xBB
#define UART_RUMBLE_TYPE 0x02
#define UART_RUMBLE_TYPE_SLOT 0x03
#endif
#ifdef SWITCH_PICO_BLUEPAD32
@ -47,9 +47,8 @@ static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
static ControllerState
g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
#else
static constexpr uint8_t SWITCH_HID_INSTANCE = 0;
static bool g_last_ready = false;
static ControllerState g_user_state;
static bool g_last_ready[SWITCH_PICO_HID_INSTANCE_COUNT]{};
static ControllerState g_user_states[SWITCH_PICO_HID_INSTANCE_COUNT]{};
#endif
static bool g_last_mounted = false;
@ -68,17 +67,20 @@ static ControllerState neutral_input() {
}
#ifndef SWITCH_PICO_BLUEPAD32
static void send_rumble_uart_frame(const ControllerRumbleOutput& rumble) {
uint8_t frame[5] = {
// Pico -> host: 0xBB 0x03 slot low high checksum(sum of first 5 bytes).
static void send_rumble_uart_frame(uint8_t slot,
const ControllerRumbleOutput& rumble) {
uint8_t frame[6] = {
UART_RUMBLE_HEADER,
UART_RUMBLE_TYPE,
UART_RUMBLE_TYPE_SLOT,
slot,
rumble.low_frequency_magnitude,
rumble.high_frequency_magnitude,
0,
};
for (uint8_t i = 0; i < 4; ++i) {
frame[4] = static_cast<uint8_t>(frame[4] + frame[i]);
for (uint8_t i = 0; i < 5; ++i) {
frame[5] = static_cast<uint8_t>(frame[5] + frame[i]);
}
uart_write_blocking(UART_ID, frame, sizeof(frame));
}
@ -96,10 +98,10 @@ static void on_rumble_from_usb(uint8_t instance,
instance, controller_profile_runtime_scale_host_rumble(
instance, snapshot, rumble));
#else
if (instance != SWITCH_HID_INSTANCE) {
if (instance >= SWITCH_PICO_HID_INSTANCE_COUNT) {
return;
}
send_rumble_uart_frame(rumble);
send_rumble_uart_frame(instance, rumble);
#endif
}
@ -137,7 +139,9 @@ static bool poll_uart_frames() {
buffer[index++] = byte;
if (index == 3) {
expected_len = static_cast<uint8_t>(buffer[2] + 4u);
// v2: header(3) + payload + checksum; v3 adds a slot byte.
const uint8_t overhead = buffer[1] == 0x03 ? 5u : 4u;
expected_len = static_cast<uint8_t>(buffer[2] + overhead);
if (expected_len < 12 || expected_len > sizeof(buffer)) {
index = 0;
expected_len = 0;
@ -147,10 +151,12 @@ static bool poll_uart_frames() {
if (expected_len > 0 && index >= expected_len) {
ControllerState parsed{};
if (switch_pro_apply_uart_packet(buffer, expected_len, parsed)) {
g_user_state = parsed;
uint8_t slot = 0;
if (switch_pro_apply_uart_packet(buffer, expected_len, parsed, slot)) {
g_user_states[slot] = parsed;
new_data = true;
LOG_PRINTF("[UART] packet buttons=0x%04x hat=%u lx=%u ly=%u rx=%u ry=%u\n",
LOG_PRINTF("[UART] slot=%u buttons=0x%04x hat=%u lx=%u ly=%u rx=%u ry=%u\n",
slot,
(parsed.button_east ? SWITCH_PRO_MASK_A : 0) |
(parsed.button_south ? SWITCH_PRO_MASK_B : 0) |
(parsed.button_north ? SWITCH_PRO_MASK_X : 0) |
@ -207,11 +213,14 @@ static void log_usb_state() {
}
}
#else
const bool ready = usb_output_driver_is_ready(SWITCH_HID_INSTANCE);
if (ready != g_last_ready) {
g_last_ready = ready;
LOG_PRINTF("[SWITCH] driver %s\n",
ready ? "ready (handshake OK)" : "not ready");
for (uint8_t instance = 0;
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
const bool ready = usb_output_driver_is_ready(instance);
if (ready != g_last_ready[instance]) {
g_last_ready[instance] = ready;
LOG_PRINTF("[SWITCH %u] driver %s\n", instance,
ready ? "ready (handshake OK)" : "not ready");
}
}
#endif
}
@ -247,12 +256,14 @@ int main() {
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
}
#else
usb_output_driver_set_rumble_callback(SWITCH_HID_INSTANCE,
on_rumble_from_usb);
g_user_state = neutral_input();
usb_output_driver_set_input(SWITCH_HID_INSTANCE, g_user_state,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
for (uint8_t instance = 0;
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
usb_output_driver_set_rumble_callback(instance, on_rumble_from_usb);
g_user_states[instance] = neutral_input();
usb_output_driver_set_input(instance, g_user_states[instance],
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
}
#endif
#ifdef SWITCH_PICO_BLUEPAD32
@ -261,8 +272,8 @@ int main() {
usb_output_driver_mode_name());
#else
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n",
UART_TX_PIN, UART_RX_PIN, BAUD_RATE);
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d slots=%d\n",
UART_TX_PIN, UART_RX_PIN, BAUD_RATE, SWITCH_PICO_HID_INSTANCE_COUNT);
#endif
while (true) {
@ -330,11 +341,13 @@ int main() {
#else
bool new_data = poll_uart_frames(); // Pull controller state from UART1
(void)new_data;
ControllerState state = g_user_state;
usb_output_driver_set_input(SWITCH_HID_INSTANCE, state,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
(void)usb_output_driver_task(SWITCH_HID_INSTANCE);
for (uint8_t instance = 0;
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
usb_output_driver_set_input(instance, g_user_states[instance],
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
(void)usb_output_driver_task(instance);
}
#endif
log_usb_state();
}

View file

@ -927,43 +927,56 @@ bool switch_pro_task(uint8_t instance) {
}
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
ControllerState& out_state) {
if (packet == nullptr) {
ControllerState& out_state,
uint8_t& out_slot) {
if (packet == nullptr || length < 12 || packet[0] != 0xAA) {
return false;
}
// v2 format: 0xAA + 0x02 + payload_len + payload... + checksum
if (length < 12 || packet[0] != 0xAA || packet[1] != 0x02) {
// v2: 0xAA 0x02 payload_len payload... checksum (slot 0)
// v3: 0xAA 0x03 payload_len slot payload... checksum
uint8_t header_len;
uint8_t slot = 0;
if (packet[1] == 0x02) {
header_len = 3;
} else if (packet[1] == 0x03) {
header_len = 4;
slot = packet[3];
} else {
return false;
}
uint8_t payload_len = packet[2];
if ((uint16_t)payload_len + 4u != length) {
const uint8_t payload_len = packet[2];
if ((uint16_t)payload_len + header_len + 1u != length) {
return false;
}
uint16_t sum = 0;
for (uint16_t i = 0; i < (uint16_t)(3u + payload_len); ++i) {
for (uint16_t i = 0; i < (uint16_t)(length - 1u); ++i) {
sum += packet[i];
}
if ((sum & 0xFF) != packet[length - 1]) {
return false;
}
if (slot >= SWITCH_PICO_HID_INSTANCE_COUNT) {
return false;
}
// payload: buttons(2 LE), hat, lx, ly, rx, ry, motion_count,
// [motion_samples...]
if (payload_len < 8) {
return false;
}
const uint8_t* payload = packet + header_len;
SwitchProOutReport out{};
out.buttons = static_cast<uint16_t>(packet[3]) |
(static_cast<uint16_t>(packet[4]) << 8);
out.hat = packet[5];
out.lx = packet[6];
out.ly = packet[7];
out.rx = packet[8];
out.ry = packet[9];
uint8_t motion_count = packet[10];
out.buttons = static_cast<uint16_t>(payload[0]) |
(static_cast<uint16_t>(payload[1]) << 8);
out.hat = payload[2];
out.lx = payload[3];
out.ly = payload[4];
out.rx = payload[5];
out.ry = payload[6];
uint8_t motion_count = payload[7];
if (motion_count > CONTROLLER_MOTION_SAMPLE_CAPACITY) {
motion_count = CONTROLLER_MOTION_SAMPLE_CAPACITY;
}
@ -988,7 +1001,7 @@ bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
ControllerState state = make_neutral_state();
state.motion_sample_count = motion_count;
for (uint8_t i = 0; i < motion_count; ++i) {
const uint8_t* base = &packet[11 + i * 12];
const uint8_t* base = &payload[8 + i * 12];
state.motion_samples[i].accel_x = read_int16(base + 0);
state.motion_samples[i].accel_y = read_int16(base + 2);
state.motion_samples[i].accel_z = read_int16(base + 4);
@ -1053,6 +1066,7 @@ bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
state.right_stick_y = expand_axis(out.ry);
out_state = state;
out_slot = slot;
return true;
}

View file

@ -38,9 +38,11 @@ void switch_pro_set_input(uint8_t instance, const ControllerState& state,
// 0x30 input report was successfully queued.
bool switch_pro_task(uint8_t instance);
// Convert a packed UART message into controller state (returns true if parsed).
// Convert a packed UART message (v2 = slot 0, v3 carries a slot byte) into
// controller state. Returns true if parsed; outputs are untouched on failure.
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
ControllerState& out_state);
ControllerState& out_state,
uint8_t& out_slot);
// Driver state helpers
bool switch_pro_is_ready(uint8_t instance);

View file

@ -2,13 +2,14 @@
"""
Bridge multiple SDL3 controllers to switch-pico over UART and mirror rumble back.
The framing matches ``switch-pico.cpp``:
- Host -> Pico : UART v2 controller report
- Pico -> Host : 0xBB, 0x02, low-frequency magnitude,
The framing matches ``src/firmware/main.cpp``:
- Host -> Pico : UART v3 controller report (0xAA, 0x03, len, slot, payload, checksum)
- Pico -> Host : 0xBB, 0x03, slot, low-frequency magnitude,
high-frequency magnitude, checksum
Features inspired by ``host/controller_bridge.py``:
- Multiple controllers paired to multiple UART ports
- Multiple controllers paired to multiple UART ports, or to the separate
controller slots (0-3) of one Pico sharing a single port
- Rich-powered interactive pairing UI
- Adjustable send frequency, deadzone, and trigger thresholds
- Rumble feedback delivered to SDL3 controllers
@ -36,6 +37,7 @@ from rich.text import Text
from .switch_pico_uart import (
UART_BAUD,
UART_SLOT_COUNT,
MS2_PER_G,
RAD_TO_DEG,
ACCEL_LSB_PER_G,
@ -62,11 +64,14 @@ SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE
GYRO_BIAS_SAMPLES = 200
def parse_mapping(value: str) -> Tuple[int, str]:
"""Parse 'index:serial_port' CLI mapping argument."""
if ":" not in value:
raise argparse.ArgumentTypeError("Mapping must look like 'index:serial_port'")
idx_str, port = value.split(":", 1)
def parse_mapping(value: str) -> Tuple[int, str, Optional[int]]:
"""Parse 'index:serial_port[:slot]' CLI mapping argument."""
parts = value.split(":")
if len(parts) not in (2, 3):
raise argparse.ArgumentTypeError(
"Mapping must look like 'index:serial_port' or 'index:serial_port:slot'"
)
idx_str, port = parts[0], parts[1].strip()
try:
idx = int(idx_str, 10)
except ValueError as exc:
@ -75,7 +80,17 @@ def parse_mapping(value: str) -> Tuple[int, str]:
) from exc
if not port:
raise argparse.ArgumentTypeError("Serial port cannot be empty")
return idx, port.strip()
slot: Optional[int] = None
if len(parts) == 3:
try:
slot = int(parts[2], 10)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"Invalid slot '{parts[2]}'") from exc
if not 0 <= slot < UART_SLOT_COUNT:
raise argparse.ArgumentTypeError(
f"Slot must be 0-{UART_SLOT_COUNT - 1}, got {slot}"
)
return idx, port, slot
def download_controller_db(console: Console, destination: Path, url: str) -> bool:
@ -160,26 +175,41 @@ STICK_AXES = tuple(axis for axis, _ in STICK_AXIS_LABELS)
def interactive_pairing(
console: Console, controller_info: Dict[int, str], ports: List[Dict[str, str]]
) -> List[Tuple[int, str]]:
"""Prompt the user to pair controllers to UART ports via Rich UI."""
available = ports.copy()
mappings: List[Tuple[int, str]] = []
for controller_idx in controller_info:
console: Console,
controller_info: Dict[int, str],
ports: List[Dict[str, str]],
slots_per_port: int,
) -> List[Tuple[int, str, Optional[int]]]:
"""Prompt the user to pair controllers to UART ports via Rich UI.
A port stays selectable until all of its controller slots are taken.
"""
used_slots: Dict[str, int] = {}
mappings: List[Tuple[int, str, Optional[int]]] = []
for controller_idx, name in controller_info.items():
available = [
port for port in ports if used_slots.get(port["device"], 0) < slots_per_port
]
if not available:
console.print(
"[bold red]No more UART devices available for pairing.[/bold red]"
"[bold red]No more UART controller slots available for pairing.[/bold red]"
)
break
table = Table(
title=f"Available UART Devices for Controller {controller_idx} ({controller_info[controller_idx]})"
title=f"Available UART Devices for Controller {controller_idx} ({name})"
)
table.add_column("Choice", justify="center")
table.add_column("Port")
table.add_column("Description")
table.add_column("Slots used", justify="center")
for i, port in enumerate(available):
table.add_row(str(i), port["device"], port["description"])
table.add_row(
str(i),
port["device"],
port["description"],
f"{used_slots.get(port['device'], 0)}/{slots_per_port}",
)
console.print(table)
choices = [str(i) for i in range(len(available))] + ["q"]
selection = Prompt.ask(
@ -189,11 +219,12 @@ def interactive_pairing(
)
if selection == "q":
break
idx = int(selection)
port = available.pop(idx)
mappings.append((controller_idx, port["device"]))
device = available[int(selection)]["device"]
slot = used_slots.get(device, 0)
used_slots[device] = slot + 1
mappings.append((controller_idx, device, slot))
console.print(
f"[bold green]Paired controller {controller_idx} with {port['device']}[/bold green]"
f"[bold green]Paired controller {controller_idx} with {device} slot {slot}[/bold green]"
)
return mappings
@ -227,6 +258,15 @@ def apply_rumble(
return low != 0 or high != 0, accepted
@dataclass
class UartLink:
"""One serial port shared by every controller mapped to its slots."""
port: str
uart: Optional[PicoUART] = None
last_reopen_attempt: float = 0.0
@dataclass
class ControllerContext:
controller: sdl3.SDL_Gamepad
@ -234,7 +274,7 @@ class ControllerContext:
controller_index: int
stable_id: str
port: Optional[str]
uart: Optional[PicoUART]
slot: int = 0
report: SwitchReport = field(default_factory=SwitchReport)
dpad: Dict[str, bool] = field(
default_factory=lambda: {
@ -249,7 +289,6 @@ class ControllerContext:
default_factory=lambda: {"left": False, "right": False}
)
last_send: float = 0.0
last_reopen_attempt: float = 0.0
last_rumble_at: float = 0.0
rumble_active: bool = False
axis_offsets: Dict[int, int] = field(default_factory=dict)
@ -644,13 +683,29 @@ def build_arg_parser() -> argparse.ArgumentParser:
action="append",
type=parse_mapping,
default=[],
help="Controller mapping 'index:serial_port'. Repeat per controller.",
help=(
"Controller mapping 'index:serial_port[:slot]'. Repeat per controller; "
"controllers sharing a serial port drive that Pico's separate emulated "
"controllers. Omitted slots are filled in order from 0."
),
)
parser.add_argument(
"--ports",
nargs="+",
help="Serial ports to auto-pair with controllers in ascending index order.",
)
parser.add_argument(
"--slots-per-port",
type=int,
default=UART_SLOT_COUNT,
choices=range(1, UART_SLOT_COUNT + 1),
metavar="N",
help=(
"Emulated controllers per Pico for auto/interactive pairing (default: "
f"{UART_SLOT_COUNT}; match the firmware's SWITCH_PICO_UART_CONTROLLERS). "
"Auto-pairing spreads controllers across ports before reusing one."
),
)
parser.add_argument(
"--interactive",
action="store_true",
@ -881,8 +936,11 @@ class DisplayIndexAllocator:
@dataclass
class PairingState:
mapping_by_index: Dict[int, str]
# controller display index -> (serial port, firmware slot)
mapping_by_index: Dict[int, Tuple[str, int]]
# Ports auto-pairing may hand out; a port stays here while it has free slots.
available_ports: List[str]
slots_per_port: int = UART_SLOT_COUNT
auto_assigned_indices: set[int] = field(default_factory=set)
auto_pairing_enabled: bool = False
auto_discover_ports: bool = False
@ -892,6 +950,17 @@ class PairingState:
include_port_mfr: List[str] = field(default_factory=list)
display_index_alloc: DisplayIndexAllocator = field(default_factory=DisplayIndexAllocator)
def used_slots(self, port: str) -> set[int]:
return {slot for mapped, slot in self.mapping_by_index.values() if mapped == port}
def free_slot(self, port: str) -> Optional[int]:
"""Lowest unused slot on ``port`` within the configured slot budget."""
used = self.used_slots(port)
for slot in range(self.slots_per_port):
if slot not in used:
return slot
return None
def load_button_maps(
console: Console, args: argparse.Namespace
@ -1078,6 +1147,31 @@ def list_serial_ports(console: Console, args: argparse.Namespace) -> None:
console.print(table)
def resolve_mapping_slots(
mappings: List[Tuple[int, str, Optional[int]]],
slots_per_port: int,
parser: argparse.ArgumentParser,
) -> Dict[int, Tuple[str, int]]:
"""Assign explicit or next-free slots to CLI/interactive mappings."""
resolved: Dict[int, Tuple[str, int]] = {}
taken: Dict[str, set[int]] = {}
for index, port, slot in mappings:
if index in resolved:
parser.error(f"Controller {index} is mapped more than once.")
used = taken.setdefault(port, set())
if slot is None:
slot = next((s for s in range(slots_per_port) if s not in used), None)
if slot is None:
parser.error(
f"{port} has no free controller slot (limit {slots_per_port}; see --slots-per-port)."
)
elif slot in used:
parser.error(f"{port} slot {slot} is mapped to more than one controller.")
used.add(slot)
resolved[index] = (port, slot)
return resolved
def prepare_pairing_state(
args: argparse.Namespace,
console: Console,
@ -1092,9 +1186,10 @@ def prepare_pairing_state(
ignore_port_desc = [d.lower() for d in args.ignore_port_desc]
include_port_desc = [d.lower() for d in args.include_port_desc]
include_port_mfr = [m.lower() for m in args.include_port_manufacturer]
slots_per_port = int(args.slots_per_port)
available_ports: List[str] = []
mappings = list(args.map)
mappings: List[Tuple[int, str, Optional[int]]] = list(args.map)
if args.interactive:
if not controller_indices:
parser.error("No controllers detected for interactive pairing.")
@ -1107,7 +1202,7 @@ def prepare_pairing_state(
)
if not discovered:
parser.error("No UART devices found for interactive pairing.")
mappings = interactive_pairing(console, controller_names, discovered)
mappings = interactive_pairing(console, controller_names, discovered, slots_per_port)
if not mappings:
parser.error("No controller-to-UART mappings were selected.")
elif auto_pairing_enabled:
@ -1134,10 +1229,10 @@ def prepare_pairing_state(
"[yellow]No UART devices detected yet; waiting for hotplug...[/yellow]"
)
mapping_by_index = {index: port for index, port in mappings}
return PairingState(
mapping_by_index=mapping_by_index,
mapping_by_index=resolve_mapping_slots(mappings, slots_per_port, parser),
available_ports=available_ports,
slots_per_port=slots_per_port,
auto_pairing_enabled=auto_pairing_enabled,
auto_discover_ports=auto_discover_ports,
include_non_usb=include_non_usb,
@ -1149,32 +1244,75 @@ def prepare_pairing_state(
def assign_port_for_index(
pairing: PairingState, idx: int, console: Console
) -> Optional[str]:
"""Return the UART assigned to a controller index, auto-pairing if allowed."""
) -> Optional[Tuple[str, int]]:
"""Return the (port, slot) for a controller index, auto-pairing if allowed.
Auto-pairing prefers the port with the fewest controllers so several Picos
are filled evenly before any one of them multiplexes.
"""
if idx in pairing.mapping_by_index:
return pairing.mapping_by_index[idx]
if not pairing.auto_pairing_enabled:
return None
if not pairing.available_ports:
candidates = [
(len(pairing.used_slots(port)), order, port)
for order, port in enumerate(pairing.available_ports)
if pairing.free_slot(port) is not None
]
if not candidates:
return None
port_choice = pairing.available_ports.pop(0)
pairing.mapping_by_index[idx] = port_choice
_, _, port_choice = min(candidates)
slot = pairing.free_slot(port_choice)
assert slot is not None
pairing.mapping_by_index[idx] = (port_choice, slot)
pairing.auto_assigned_indices.add(idx)
console.print(f"[green]Auto-paired controller {idx} to {port_choice}[/green]")
return port_choice
console.print(f"[green]Auto-paired controller {idx} to {port_choice} slot {slot}[/green]")
return port_choice, slot
def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set[str]:
"""Return a set of UART paths currently reserved or mapped."""
used = set(pairing.mapping_by_index.values())
used = {port for port, _ in pairing.mapping_by_index.values()}
used.update(ctx.port for ctx in contexts.values() if ctx.port)
return used
def close_link(links: Dict[str, UartLink], port: str) -> None:
"""Close and forget the shared UART for ``port`` if it is open."""
link = links.pop(port, None)
if link and link.uart:
try:
link.uart.close()
except Exception:
pass
def ensure_link(
links: Dict[str, UartLink], port: str, baud: int, console: Console, now: float
) -> UartLink:
"""Return the shared link for ``port``, opening the serial device if needed."""
link = links.get(port)
if link is None:
link = UartLink(port=port)
links[port] = link
if link.uart is None:
link.last_reopen_attempt = now
link.uart = open_uart_or_warn(port, baud, console)
return link
def detach_context_from_port(ctx: ControllerContext) -> None:
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.port = None
ctx.slot = 0
ctx.rumble_active = False
def handle_removed_port(
path: str,
pairing: PairingState,
contexts: Dict[int, ControllerContext],
links: Dict[str, UartLink],
console: Console,
) -> None:
"""Clear mappings/contexts for a UART path that disappeared."""
@ -1184,31 +1322,26 @@ def handle_removed_port(
f"[yellow]UART {path} removed; dropping from available pool[/yellow]"
)
indices_to_clear = [
idx for idx, mapped in pairing.mapping_by_index.items() if mapped == path
idx for idx, (mapped, _) in pairing.mapping_by_index.items() if mapped == path
]
for idx in indices_to_clear:
pairing.mapping_by_index.pop(idx, None)
pairing.auto_assigned_indices.discard(idx)
close_link(links, path)
for ctx in list(contexts.values()):
if ctx.port != path:
continue
if ctx.uart:
try:
ctx.uart.close()
except Exception:
pass
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.uart = None
ctx.port = None
ctx.rumble_active = False
ctx.last_reopen_attempt = time.monotonic()
detach_context_from_port(ctx)
console.print(
f"[yellow]UART {path} removed; controller {ctx.controller_index} waiting for reassignment[/yellow]"
)
def discover_new_ports(
pairing: PairingState, contexts: Dict[int, ControllerContext], console: Console
pairing: PairingState,
contexts: Dict[int, ControllerContext],
links: Dict[str, UartLink],
console: Console,
) -> None:
"""Scan for new serial ports and add unused ones to the available pool."""
if not pairing.auto_discover_ports:
@ -1221,12 +1354,11 @@ def discover_new_ports(
)
current_paths = {info["device"] for info in discovered}
known_paths = set(pairing.available_ports)
known_paths.update(pairing.mapping_by_index.values())
known_paths.update(ctx.port for ctx in contexts.values() if ctx.port)
known_paths.update(ports_in_use(pairing, contexts))
# Drop any paths we previously knew about that are no longer present.
removed_paths = [path for path in known_paths if path not in current_paths]
for path in removed_paths:
handle_removed_port(path, pairing, contexts, console)
handle_removed_port(path, pairing, contexts, links, console)
in_use = ports_in_use(pairing, contexts)
for info in discovered:
path = info["device"]
@ -1238,35 +1370,88 @@ def discover_new_ports(
)
def announce_pairing(
ctx: ControllerContext, link: UartLink, console: Console
) -> None:
who = f"Controller {ctx.controller_index} (id {ctx.stable_id}, inst {ctx.instance_id})"
if link.uart:
console.print(f"[green]{who} paired to {link.port} slot {ctx.slot}[/green]")
else:
console.print(f"[yellow]{who} waiting for UART {link.port} (slot {ctx.slot})[/yellow]")
def pair_waiting_contexts(
args: argparse.Namespace,
pairing: PairingState,
contexts: Dict[int, ControllerContext],
uarts: List[PicoUART],
links: Dict[str, UartLink],
console: Console,
) -> None:
"""Attach UARTs to contexts that are waiting for a port assignment/open."""
"""Attach ports to contexts that are waiting for a slot assignment."""
for ctx in list(contexts.values()):
if ctx.port is not None:
continue
# Try to grab a port for this controller; if none are available, leave it waiting.
port_choice = assign_port_for_index(pairing, ctx.controller_index, console)
if port_choice is None:
# Try to grab a slot for this controller; if none are available, leave it waiting.
assignment = assign_port_for_index(pairing, ctx.controller_index, console)
if assignment is None:
continue
ctx.port = port_choice
uart = open_uart_or_warn(port_choice, args.baud, console)
ctx.last_reopen_attempt = time.monotonic()
if uart:
uarts.append(uart)
ctx.uart = uart
console.print(
f"[green]Controller {ctx.controller_index} (id {ctx.stable_id}, inst {ctx.instance_id}) paired to {port_choice}[/green]"
)
else:
ctx.uart = None
console.print(
f"[yellow]Controller {ctx.controller_index} (id {ctx.stable_id}, inst {ctx.instance_id}) waiting for UART {port_choice}[/yellow]"
)
ctx.port, ctx.slot = assignment
link = ensure_link(links, ctx.port, args.baud, console, time.monotonic())
announce_pairing(ctx, link, console)
def attach_controller(
sdl_id: int,
args: argparse.Namespace,
pairing: PairingState,
contexts: Dict[int, ControllerContext],
links: Dict[str, UartLink],
console: Console,
config: BridgeConfig,
) -> None:
"""Open an SDL gamepad, give it a slot if one is mapped or free, and track it."""
if sdl_id in contexts:
return
if not sdl3.SDL_IsGamepad(sdl_id):
name = sdl3.SDL_GetJoystickNameForID(sdl_id)
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
console.print(
f"[yellow]Device {sdl_id} is not a GameController ({name_str}).[/yellow]"
)
return
display_idx = pairing.display_index_alloc.allocate()
assignment = assign_port_for_index(pairing, display_idx, console)
if assignment is None and not pairing.auto_pairing_enabled:
pairing.display_index_alloc.release(display_idx)
return
try:
controller, instance_id, guid = open_controller(sdl_id)
except Exception as exc:
console.print(f"[red]Failed to open controller {display_idx}: {exc}[/red]")
pairing.display_index_alloc.release(display_idx)
return
should_swap = display_idx in config.swap_abxy_indices or guid in config.swap_abxy_ids
ctx = ControllerContext(
controller=controller,
instance_id=instance_id,
controller_index=display_idx,
stable_id=guid,
port=assignment[0] if assignment else None,
slot=assignment[1] if assignment else 0,
swap_abxy=should_swap,
)
if assignment:
link = ensure_link(links, assignment[0], args.baud, console, time.monotonic())
announce_pairing(ctx, link, console)
else:
console.print(
f"[yellow]Controller {display_idx} (id {guid}, inst {instance_id}) connected; waiting for an available UART slot[/yellow]"
)
if not config.no_imu:
initialize_controller_sensors(ctx, console)
if config.zero_sticks:
zero_context_sticks(ctx, console)
contexts[instance_id] = ctx
def open_initial_contexts(
@ -1275,62 +1460,13 @@ def open_initial_contexts(
controller_indices: List[int],
console: Console,
config: BridgeConfig,
) -> Tuple[Dict[int, ControllerContext], List[PicoUART]]:
) -> Tuple[Dict[int, ControllerContext], Dict[str, UartLink]]:
"""Open initial controllers and UARTs for detected indices."""
contexts: Dict[int, ControllerContext] = {}
uarts: List[PicoUART] = []
links: Dict[str, UartLink] = {}
for instance_id in controller_indices:
if not sdl3.SDL_IsGamepad(instance_id):
name = sdl3.SDL_GetJoystickNameForID(instance_id)
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
console.print(
f"[yellow]ID {instance_id} is not a GameController ({name_str}). Trying raw open failed.[/yellow]"
)
continue
display_idx = pairing.display_index_alloc.allocate()
port = assign_port_for_index(pairing, display_idx, console)
if port is None and not pairing.auto_pairing_enabled:
pairing.display_index_alloc.release(display_idx)
continue
try:
controller, opened_instance_id, guid = open_controller(instance_id)
except Exception as exc:
console.print(f"[red]Failed to open controller {display_idx}: {exc}[/red]")
pairing.display_index_alloc.release(display_idx)
continue
stable_id = guid
should_swap = (
display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids
)
uart = open_uart_or_warn(port, args.baud, console) if port else None
if uart:
uarts.append(uart)
console.print(
f"[green]Controller {display_idx} (id {stable_id}, inst {opened_instance_id}) paired to {port}[/green]"
)
elif port:
console.print(
f"[yellow]Controller {display_idx} (id {stable_id}, inst {opened_instance_id}) waiting for UART {port}[/yellow]"
)
else:
console.print(
f"[yellow]Controller {display_idx} (id {stable_id}, inst {opened_instance_id}) connected; waiting for an available UART[/yellow]"
)
ctx = ControllerContext(
controller=controller,
instance_id=opened_instance_id,
controller_index=display_idx,
stable_id=stable_id,
port=port,
uart=uart,
swap_abxy=should_swap,
)
if not config.no_imu:
initialize_controller_sensors(ctx, console)
if config.zero_sticks:
zero_context_sticks(ctx, console)
contexts[opened_instance_id] = ctx
return contexts, uarts
attach_controller(instance_id, args, pairing, contexts, links, console, config)
return contexts, links
def handle_axis_motion(
@ -1506,72 +1642,22 @@ def handle_device_added(
args: argparse.Namespace,
pairing: PairingState,
contexts: Dict[int, ControllerContext],
uarts: List[PicoUART],
links: Dict[str, UartLink],
console: Console,
config: BridgeConfig,
) -> None:
"""Handle controller hotplug by opening and pairing UART if possible."""
sdl_id = event.gdevice.which
if sdl_id in contexts:
return
if not sdl3.SDL_IsGamepad(sdl_id):
name = sdl3.SDL_GetJoystickNameForID(sdl_id)
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
console.print(
f"[yellow]Device {sdl_id} is not a GameController ({name_str}).[/yellow]"
)
return
display_idx = pairing.display_index_alloc.allocate()
port = assign_port_for_index(pairing, display_idx, console)
if port is None and not pairing.auto_pairing_enabled:
pairing.display_index_alloc.release(display_idx)
return
try:
controller, instance_id, guid = open_controller(sdl_id)
except Exception as exc:
console.print(f"[red]Hotplug open failed for controller {display_idx}: {exc}[/red]")
pairing.display_index_alloc.release(display_idx)
return
stable_id = guid
should_swap = display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids
uart = open_uart_or_warn(port, args.baud, console) if port else None
if uart:
uarts.append(uart)
console.print(
f"[green]Controller {display_idx} (id {stable_id}, inst {instance_id}) paired to {port}[/green]"
)
elif port:
console.print(
f"[yellow]Controller {display_idx} (id {stable_id}, inst {instance_id}) waiting for UART {port}[/yellow]"
)
else:
console.print(
f"[yellow]Controller {display_idx} (id {stable_id}, inst {instance_id}) connected; waiting for an available UART[/yellow]"
)
ctx = ControllerContext(
controller=controller,
instance_id=instance_id,
controller_index=display_idx,
stable_id=stable_id,
port=port,
uart=uart,
swap_abxy=should_swap,
)
if not config.no_imu:
initialize_controller_sensors(ctx, console)
if config.zero_sticks:
zero_context_sticks(ctx, console)
contexts[instance_id] = ctx
"""Handle controller hotplug by opening it and pairing a UART slot if possible."""
attach_controller(event.gdevice.which, args, pairing, contexts, links, console, config)
def handle_device_removed(
event: sdl3.SDL_Event,
pairing: PairingState,
contexts: Dict[int, ControllerContext],
uarts: List[PicoUART],
links: Dict[str, UartLink],
console: Console,
) -> None:
"""Handle controller removal and release any auto-assigned UART."""
"""Handle controller removal and release any auto-assigned UART slot."""
instance_id = event.gdevice.which
ctx = contexts.pop(instance_id, None)
if not ctx:
@ -1579,57 +1665,37 @@ def handle_device_removed(
console.print(
f"[yellow]Controller {ctx.controller_index} (id {ctx.stable_id}) removed[/yellow]"
)
# Close the UART handle *before* returning the port to the pool so the
# next consumer can actually open it (Windows holds the port exclusively).
if ctx.uart:
try:
ctx.uart.close()
except Exception:
pass
if ctx.uart in uarts:
uarts.remove(ctx.uart)
ctx.uart = None
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
# Close the serial handle once no controller uses the port so the next
# consumer can actually open it (Windows holds the port exclusively).
if ctx.port and all(other.port != ctx.port for other in contexts.values()):
close_link(links, ctx.port)
if ctx.controller_index in pairing.auto_assigned_indices:
# Return auto-paired UART back to the pool so a future device can use it.
# Return the slot to the pool so a future device can use it.
freed = pairing.mapping_by_index.pop(ctx.controller_index, None)
pairing.auto_assigned_indices.discard(ctx.controller_index)
if freed and freed not in pairing.available_ports:
pairing.available_ports.append(freed)
console.print(f"[cyan]Released UART {freed} back to pool[/cyan]")
if freed:
port, slot = freed
if port not in pairing.available_ports:
pairing.available_ports.append(port)
console.print(f"[cyan]Released {port} slot {slot} back to pool[/cyan]")
pairing.display_index_alloc.release(ctx.controller_index)
sdl3.SDL_CloseGamepad(ctx.controller)
def service_contexts(
def service_link(
now: float,
args: argparse.Namespace,
config: BridgeConfig,
contexts: Dict[int, ControllerContext],
uarts: List[PicoUART],
link: UartLink,
members: List[ControllerContext],
console: Console,
) -> None:
"""Poll controllers, reconnect UARTs, send reports, and apply rumble."""
for ctx in list(contexts.values()):
current_button_map = (
config.button_map_swapped
if (config.swap_abxy_global or ctx.swap_abxy)
else config.button_map_default
)
poll_controller_buttons(ctx, current_button_map)
# Reconnect UART if needed.
if ctx.port and ctx.uart is None and (now - ctx.last_reopen_attempt) > 1.0:
ctx.last_reopen_attempt = now
uart = open_uart_or_warn(ctx.port, args.baud, console)
if uart:
uarts.append(uart)
console.print(
f"[green]Reconnected UART {ctx.port} for controller {ctx.controller_index}[/green]"
)
ctx.uart = uart
if ctx.uart is None:
continue
try:
"""Send due reports for every controller on one port and demux its rumble."""
uart = link.uart
if uart is None:
return
try:
for ctx in members:
if now - ctx.last_send >= config.interval:
if ctx.sensors_enabled and not config.no_imu:
# Keep publishing the latest complete sensor window. Draining
@ -1637,19 +1703,24 @@ def service_contexts(
ctx.report.imu_samples = ctx.imu_samples
else:
ctx.report.imu_samples = []
ctx.uart.send_report(ctx.report)
uart.send_report(ctx.report, ctx.slot)
ctx.last_send = now
latest_rumble = None
while True:
rumble = ctx.uart.read_rumble()
if rumble is None:
break
latest_rumble = rumble
ctx.debug_rumble_frames += 1
# Keep only the freshest rumble command per slot seen during this tick.
latest_by_slot: Dict[int, Tuple[float, float]] = {}
frames_by_slot: Dict[int, int] = {}
while True:
frame = uart.read_rumble()
if frame is None:
break
slot, low, high = frame
latest_by_slot[slot] = (low, high)
frames_by_slot[slot] = frames_by_slot.get(slot, 0) + 1
for ctx in members:
ctx.debug_rumble_frames += frames_by_slot.get(ctx.slot, 0)
latest_rumble = latest_by_slot.get(ctx.slot)
if latest_rumble is not None:
# Apply only the freshest rumble command seen during this tick.
ctx.rumble_active, accepted = apply_rumble(
ctx.controller,
latest_rumble[0],
@ -1664,30 +1735,65 @@ def service_contexts(
ctx.last_debug_rumble = latest_rumble
error = "" if accepted else f" sdl_error={sdl3.SDL_GetError().decode(errors='ignore')!r}"
print(
f"[RUMBLE idx={ctx.controller_index}] frame#{ctx.debug_rumble_frames} "
f"[RUMBLE idx={ctx.controller_index} slot={ctx.slot}] frame#{ctx.debug_rumble_frames} "
f"low={latest_rumble[0]:.3f} high={latest_rumble[1]:.3f} "
f"-> motor low={shape_rumble(latest_rumble[0], config.rumble_gain, config.rumble_curve):.3f} "
f"high={shape_rumble(latest_rumble[1], config.rumble_gain, config.rumble_curve):.3f} "
f"accepted={accepted}{error}"
)
elif (
ctx.rumble_active
and (now - ctx.last_rumble_at) > RUMBLE_IDLE_TIMEOUT
):
elif ctx.rumble_active and (now - ctx.last_rumble_at) > RUMBLE_IDLE_TIMEOUT:
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.rumble_active = False
except SerialException as exc:
console.print(f"[yellow]UART {ctx.port} disconnected: {exc}[/yellow]")
try:
ctx.uart.close()
except Exception:
pass
except SerialException as exc:
console.print(f"[yellow]UART {link.port} disconnected: {exc}[/yellow]")
try:
uart.close()
except Exception:
pass
link.uart = None
link.last_reopen_attempt = now
for ctx in members:
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.uart = None
ctx.rumble_active = False
ctx.last_reopen_attempt = now
except Exception as exc:
console.print(f"[red]UART error on {ctx.port}: {exc}[/red]")
except Exception as exc:
console.print(f"[red]UART error on {link.port}: {exc}[/red]")
def service_contexts(
now: float,
args: argparse.Namespace,
config: BridgeConfig,
contexts: Dict[int, ControllerContext],
links: Dict[str, UartLink],
console: Console,
) -> None:
"""Poll controllers, reconnect UARTs, send reports, and apply rumble."""
members_by_port: Dict[str, List[ControllerContext]] = {}
for ctx in list(contexts.values()):
current_button_map = (
config.button_map_swapped
if (config.swap_abxy_global or ctx.swap_abxy)
else config.button_map_default
)
poll_controller_buttons(ctx, current_button_map)
if ctx.port:
members_by_port.setdefault(ctx.port, []).append(ctx)
for port, members in members_by_port.items():
link = links.get(port)
if link is None:
link = UartLink(port=port)
links[port] = link
# Reconnect the shared UART if needed.
if link.uart is None and (now - link.last_reopen_attempt) > 1.0:
link.last_reopen_attempt = now
link.uart = open_uart_or_warn(port, args.baud, console)
if link.uart:
console.print(
f"[green]Reconnected UART {port} for controller(s) "
f"{', '.join(str(ctx.controller_index) for ctx in members)}[/green]"
)
service_link(now, config, link, members, console)
def run_bridge_loop(
@ -1696,7 +1802,7 @@ def run_bridge_loop(
config: BridgeConfig,
pairing: PairingState,
contexts: Dict[int, ControllerContext],
uarts: List[PicoUART],
links: Dict[str, UartLink],
hotkey: Optional[HotkeyMonitor] = None,
) -> None:
"""Main event loop for bridging controllers to UART and handling rumble."""
@ -1721,20 +1827,18 @@ def run_bridge_loop(
handle_sensor_update(event, contexts, config)
elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED:
handle_device_added(
event, args, pairing, contexts, uarts, console, config
event, args, pairing, contexts, links, console, config
)
elif event.type == sdl3.SDL_EVENT_GAMEPAD_REMOVED:
handle_device_removed(event, pairing, contexts, uarts, console)
handle_device_removed(event, pairing, contexts, links, console)
now = time.monotonic()
if now - last_port_scan > port_scan_interval:
# Periodically rescan for new UARTs to auto-pair hotplugged devices.
discover_new_ports(pairing, contexts, console)
discover_new_ports(pairing, contexts, links, console)
last_port_scan = now
pair_waiting_contexts(args, pairing, contexts, uarts, console)
else:
pair_waiting_contexts(args, pairing, contexts, uarts, console)
service_contexts(now, args, config, contexts, uarts, console)
pair_waiting_contexts(args, pairing, contexts, links, console)
service_contexts(now, args, config, contexts, links, console)
if hotkey:
for key in hotkey.poll_keys():
if key == config.zero_hotkey:
@ -1744,12 +1848,13 @@ def run_bridge_loop(
sdl3.SDL_Delay(1)
def cleanup(contexts: Dict[int, ControllerContext], uarts: List[PicoUART]) -> None:
def cleanup(contexts: Dict[int, ControllerContext], links: Dict[str, UartLink]) -> None:
"""Gracefully close controllers, UARTs, and SDL subsystems."""
for ctx in contexts.values():
sdl3.SDL_CloseGamepad(ctx.controller)
for uart in uarts:
uart.close()
for link in links.values():
if link.uart:
link.uart.close()
sdl3.SDL_Quit()
@ -1764,7 +1869,7 @@ def main() -> None:
config = build_bridge_config(console, args)
initialize_sdl(parser)
contexts: Dict[int, ControllerContext] = {}
uarts: List[PicoUART] = []
links: Dict[str, UartLink] = {}
hotkey_monitor: Optional[HotkeyMonitor] = None
try:
if args.list_controllers:
@ -1790,18 +1895,18 @@ def main() -> None:
candidate = HotkeyMonitor(console, hotkey_messages)
if candidate.start():
hotkey_monitor = candidate
contexts, uarts = open_initial_contexts(
contexts, links = open_initial_contexts(
args, pairing, controller_indices, console, config
)
if not contexts:
console.print(
"[yellow]No controllers opened; waiting for hotplug events...[/yellow]"
)
run_bridge_loop(args, console, config, pairing, contexts, uarts, hotkey_monitor)
run_bridge_loop(args, console, config, pairing, contexts, links, hotkey_monitor)
finally:
if hotkey_monitor:
hotkey_monitor.stop()
cleanup(contexts, uarts)
cleanup(contexts, links)
if __name__ == "__main__":

View file

@ -4,11 +4,13 @@ Lightweight helpers for talking to the switch-pico firmware over UART.
This module exposes the report structure plus a small convenience wrapper
so other scripts can do things like "press a button" or "move a stick" without
depending on SDL. It mirrors the framing in ``switch-pico.cpp``:
depending on SDL. It mirrors the framing in ``src/firmware/main.cpp``:
Host -> Pico : UART v2 controller report
Pico -> Host : 0xBB, 0x02, low-frequency magnitude, high-frequency magnitude,
checksum (sum of the first 4 bytes)
Host -> Pico : UART v3 controller report
0xAA, 0x03, payload_len, slot, payload..., checksum
(v2 frames without the slot byte are still accepted as slot 0)
Pico -> Host : 0xBB, 0x03, slot, low-frequency magnitude,
high-frequency magnitude, checksum (sum of the first 5 bytes)
"""
from __future__ import annotations
@ -25,10 +27,13 @@ import serial
from serial.tools import list_ports, list_ports_common
UART_HEADER = 0xAA
UART_PROTOCOL_VERSION = 0x02
UART_PROTOCOL_VERSION = 0x03
RUMBLE_HEADER = 0xBB
# Legacy 5-byte frame (no slot) from firmware before multi-controller support.
RUMBLE_TYPE_DECODED = 0x02
RUMBLE_TYPE_SLOT = 0x03
UART_BAUD = 921600
UART_SLOT_COUNT = 4
IMU_SAMPLES_PER_REPORT = 3
MS2_PER_G = 9.80665
@ -242,8 +247,10 @@ class SwitchReport:
ry: int = 128
imu_samples: List[IMUSample] = field(default_factory=list)
def to_bytes(self) -> bytes:
"""Serialize the report into UART v2 framed packet format."""
def to_bytes(self, slot: int = 0) -> bytes:
"""Serialize the report into a UART v3 frame addressed to ``slot``."""
if not 0 <= slot < UART_SLOT_COUNT:
raise ValueError(f"slot must be 0-{UART_SLOT_COUNT - 1}, got {slot}")
count = min(len(self.imu_samples), IMU_SAMPLES_PER_REPORT)
payload = struct.pack(
"<HBBBBBB",
@ -269,7 +276,7 @@ class SwitchReport:
)
payload_len = len(payload)
frame = bytes([UART_HEADER, UART_PROTOCOL_VERSION, payload_len]) + payload
frame = bytes([UART_HEADER, UART_PROTOCOL_VERSION, payload_len, slot]) + payload
return frame + bytes([compute_checksum(frame)])
@ -290,20 +297,21 @@ class PicoUART:
)
self._buffer = bytearray()
def send_report(self, report: SwitchReport) -> None:
"""Send a controller report to the Pico."""
self.serial.write(report.to_bytes())
def send_report(self, report: SwitchReport, slot: int = 0) -> None:
"""Send a controller report to one of the Pico's controller slots."""
self.serial.write(report.to_bytes(slot))
def read_rumble(self) -> Optional[Tuple[float, float]]:
def read_rumble(self) -> Optional[Tuple[int, float, float]]:
"""
Extract one decoded rumble frame as normalized low/high magnitudes.
Extract one decoded rumble frame as (slot, low, high) with magnitudes
normalized to 0.0-1.0.
Frame format:
0: 0xBB (RUMBLE_HEADER)
1: type (0x02 for decoded rumble)
2: low-frequency magnitude (0-255)
3: high-frequency magnitude (0-255)
4: checksum (sum of first 4 bytes) & 0xFF
1: type (0x03 slot frame; legacy 0x02 has no slot byte and means slot 0)
2: slot (0x03 only)
then low-frequency magnitude, high-frequency magnitude (0-255)
and checksum (sum of the preceding bytes) & 0xFF
"""
waiting = self.serial.in_waiting
if waiting:
@ -317,19 +325,25 @@ class PicoUART:
if start < 0:
self._buffer.clear()
return None
if len(self._buffer) - start < 2:
del self._buffer[:start]
return None
if len(self._buffer) - start < 5:
frame_type = self._buffer[start + 1]
length = 6 if frame_type == RUMBLE_TYPE_SLOT else 5
if len(self._buffer) - start < length:
if start > 0:
del self._buffer[:start]
return None
frame = self._buffer[start : start + 5]
checksum = compute_checksum(bytes(frame[:4]))
if frame[1] == RUMBLE_TYPE_DECODED and checksum == frame[4]:
rumble = (frame[2] / 255.0, frame[3] / 255.0)
del self._buffer[: start + 5]
return rumble
frame = bytes(self._buffer[start : start + length])
if compute_checksum(frame[:-1]) == frame[-1]:
if frame_type == RUMBLE_TYPE_SLOT and frame[2] < UART_SLOT_COUNT:
del self._buffer[: start + length]
return frame[2], frame[3] / 255.0, frame[4] / 255.0
if frame_type == RUMBLE_TYPE_DECODED:
del self._buffer[: start + length]
return 0, frame[2] / 255.0, frame[3] / 255.0
del self._buffer[: start + 1]
@ -408,6 +422,7 @@ class SwitchUARTClient:
baud: int = UART_BAUD,
send_interval: float = 1.0 / 500.0,
auto_send: bool = True,
slot: int = 0,
) -> None:
"""
Args:
@ -416,7 +431,11 @@ class SwitchUARTClient:
send_interval: Minimum interval between sends in seconds (defaults to 500 Hz).
auto_send: If True, keep sending the current state in a background thread so the
Pico continuously sees the latest input (mirrors controller_uart_bridge).
slot: Which of the Pico's emulated controllers (0-3) this client drives.
"""
if not 0 <= slot < UART_SLOT_COUNT:
raise ValueError(f"slot must be 0-{UART_SLOT_COUNT - 1}, got {slot}")
self.slot = slot
self.uart = PicoUART(port, baud)
self.state = SwitchControllerState()
self.send_interval = max(0.0, send_interval)
@ -432,7 +451,7 @@ class SwitchUARTClient:
now = time.monotonic()
if self.send_interval and (now - self._last_send) < self.send_interval:
return
self.uart.send_report(self.state.report)
self.uart.send_report(self.state.report, self.slot)
self._last_send = now
def _start_auto_send_thread(self) -> None:
@ -517,10 +536,17 @@ class SwitchUARTClient:
def poll_rumble(self) -> Optional[Tuple[float, float]]:
"""
Poll for decoded low/high rumble magnitudes normalized to 0.0-1.0.
Returns None if no rumble frame was available.
Poll for decoded low/high rumble magnitudes normalized to 0.0-1.0 for
this client's slot. Returns None if no rumble frame was available;
frames addressed to other slots are discarded.
"""
return self.uart.read_rumble()
while True:
frame = self.uart.read_rumble()
if frame is None:
return None
slot, low, high = frame
if slot == self.slot:
return low, high
def close(self) -> None:
if self._auto_thread:

View file

@ -681,8 +681,10 @@ void test_uart_parser_is_pure() {
packet.back() = static_cast<uint8_t>(packet.back() + packet[i]);
}
ControllerState parsed{};
expect(switch_pro_apply_uart_packet(packet.data(), packet.size(), parsed),
uint8_t slot = 0xff;
expect(switch_pro_apply_uart_packet(packet.data(), packet.size(), parsed, slot),
"valid UART packet was rejected");
expect(slot == 0, "v2 UART packet must map to slot 0");
expect(parsed.button_east && parsed.button_left_shoulder && parsed.dpad_down &&
parsed.dpad_left,
"UART buttons or hat were parsed incorrectly");
@ -707,12 +709,44 @@ void test_uart_parser_is_pure() {
ControllerState unchanged{};
unchanged.button_system = true;
unchanged.left_stick_x = 123;
uint8_t unchanged_slot = 0xff;
packet.back() ^= 0xffu;
expect(!switch_pro_apply_uart_packet(packet.data(), packet.size(),
unchanged),
unchanged, unchanged_slot),
"invalid UART checksum was accepted");
expect(unchanged.button_system && unchanged.left_stick_x == 123,
"failed UART parse modified its output reference");
expect(unchanged.button_system && unchanged.left_stick_x == 123 &&
unchanged_slot == 0xff,
"failed UART parse modified its output references");
// v3 inserts a slot byte between the length and the payload.
std::array<uint8_t, 13> slotted{};
slotted[0] = 0xaa;
slotted[1] = 0x03;
slotted[2] = 8;
slotted[3] = 2;
std::copy(packet.begin() + 3, packet.begin() + 10, slotted.begin() + 4);
for (unsigned i = 0; i < slotted.size() - 1; ++i) {
slotted.back() = static_cast<uint8_t>(slotted.back() + slotted[i]);
}
ControllerState slotted_state{};
expect(switch_pro_apply_uart_packet(slotted.data(), slotted.size(),
slotted_state, slot),
"valid v3 UART packet was rejected");
expect(slot == 2, "v3 slot byte was not reported");
expect(slotted_state.button_east && slotted_state.button_left_shoulder &&
slotted_state.dpad_down && slotted_state.dpad_left &&
slotted_state.right_stick_y ==
controller_axis_from_unsigned(0x7878),
"v3 payload offsets were parsed incorrectly");
slotted[3] = SWITCH_PICO_HID_INSTANCE_COUNT;
slotted.back() = 0;
for (unsigned i = 0; i < slotted.size() - 1; ++i) {
slotted.back() = static_cast<uint8_t>(slotted.back() + slotted[i]);
}
expect(!switch_pro_apply_uart_packet(slotted.data(), slotted.size(),
slotted_state, slot),
"out-of-range v3 slot was accepted");
}
void test_motion_backpressure_retries_without_advancing_state() {

View file

@ -28,10 +28,10 @@ class RecordingUART:
def __init__(self) -> None:
self.sent_imu: list[tuple[IMUSample, ...]] = []
def send_report(self, report: SwitchReport) -> None:
def send_report(self, report: SwitchReport, slot: int = 0) -> None:
self.sent_imu.append(tuple(report.imu_samples))
def read_rumble(self) -> tuple[float, float] | None:
def read_rumble(self) -> tuple[int, float, float] | None:
return None
@ -101,9 +101,8 @@ def test_sensor_buffer_retains_latest_three_samples() -> None:
def test_service_republishes_latest_imu_window(monkeypatch: MonkeyPatch) -> None:
uart = RecordingUART()
controller = cast(sdl3.SDL_Gamepad, object())
ctx = bridge.ControllerContext(
controller, 7, 0, "dualsense", "/dev/null", cast(PicoUART, cast(object, uart))
)
ctx = bridge.ControllerContext(controller, 7, 0, "dualsense", "/dev/null")
links = {"/dev/null": bridge.UartLink("/dev/null", cast(PicoUART, cast(object, uart)))}
ctx.sensors_enabled = True
samples = [
IMUSample(1, 2, 3, 4, 5, 6),
@ -123,8 +122,8 @@ def test_service_republishes_latest_imu_window(monkeypatch: MonkeyPatch) -> None
args = Namespace(baud=UART_BAUD)
console = Console(file=StringIO())
bridge.service_contexts(1.0, args, config, contexts, [], console)
bridge.service_contexts(2.0, args, config, contexts, [], console)
bridge.service_contexts(1.0, args, config, contexts, links, console)
bridge.service_contexts(2.0, args, config, contexts, links, console)
assert uart.sent_imu == [tuple(samples), tuple(samples)]
assert ctx.imu_samples == samples

View file

@ -1,4 +1,4 @@
"""Tests for UART v2 protocol serialization in switch_pico_uart."""
"""Tests for UART v3 protocol serialization in switch_pico_uart."""
import struct
import pytest
@ -9,8 +9,10 @@ from switch_pico_bridge.switch_pico_uart import (
PicoUART,
UART_HEADER,
UART_PROTOCOL_VERSION,
UART_SLOT_COUNT,
RUMBLE_HEADER,
RUMBLE_TYPE_DECODED,
RUMBLE_TYPE_SLOT,
ACCEL_LSB_PER_G,
GYRO_LSB_PER_RAD_S,
MS2_PER_G,
@ -35,7 +37,12 @@ class BufferedSerial:
self._data.extend(data)
def make_rumble_frame(low: int, high: int) -> bytes:
def make_rumble_frame(low: int, high: int, slot: int = 0) -> bytes:
frame = bytes([RUMBLE_HEADER, RUMBLE_TYPE_SLOT, slot, low, high])
return frame + bytes([compute_checksum(frame)])
def make_legacy_rumble_frame(low: int, high: int) -> bytes:
frame = bytes([RUMBLE_HEADER, RUMBLE_TYPE_DECODED, low, high])
return frame + bytes([compute_checksum(frame)])
@ -48,8 +55,8 @@ def make_uart(data: bytes = b"") -> tuple[PicoUART, BufferedSerial]:
return uart, serial_port
def test_v2_frame_with_imu_samples():
"""V2 frame with 3 IMU samples should be 48 bytes with correct layout."""
def test_v3_frame_with_imu_samples():
"""V3 frame with 3 IMU samples should be 49 bytes with correct layout."""
r = SwitchReport(
buttons=0,
imu_samples=[
@ -59,35 +66,49 @@ def test_v2_frame_with_imu_samples():
],
)
data = r.to_bytes()
assert len(data) == 48, f"Expected 48 bytes, got {len(data)}"
assert len(data) == 49, f"Expected 49 bytes, got {len(data)}"
assert data[0] == UART_HEADER # 0xAA
assert data[1] == UART_PROTOCOL_VERSION # 0x02
assert data[1] == UART_PROTOCOL_VERSION # 0x03
assert data[2] == 44 # payload_len
assert data[10] == 3 # imu_count
assert data[3] == 0 # slot
assert data[11] == 3 # imu_count
# Verify checksum
assert data[-1] == compute_checksum(data[:-1])
# Verify first sample accel_x (int16 LE at byte 11)
ax0 = struct.unpack_from("<h", data, 11)[0]
# Verify first sample accel_x (int16 LE at byte 12)
ax0 = struct.unpack_from("<h", data, 12)[0]
assert ax0 == 100, f"Expected accel_x=100, got {ax0}"
# Verify first sample gyro_z (int16 LE at bytes 21-22)
gz0 = struct.unpack_from("<h", data, 21)[0]
# Verify first sample gyro_z (int16 LE at bytes 22-23)
gz0 = struct.unpack_from("<h", data, 22)[0]
assert gz0 == 0, f"Expected gyro_z=0, got {gz0}"
def test_v2_frame_no_imu():
"""V2 frame with no IMU samples should be 12 bytes."""
def test_v3_frame_no_imu():
"""V3 frame with no IMU samples should be 13 bytes."""
r = SwitchReport(
buttons=0x0004, hat=SwitchDpad.CENTER, lx=128, ly=128, rx=128, ry=128
)
data = r.to_bytes()
assert len(data) == 12, f"Expected 12 bytes, got {len(data)}"
assert len(data) == 13, f"Expected 13 bytes, got {len(data)}"
assert data[0] == UART_HEADER
assert data[1] == UART_PROTOCOL_VERSION
assert data[2] == 8 # payload_len
assert data[10] == 0 # imu_count
assert data[3] == 0 # slot
assert data[11] == 0 # imu_count
assert data[-1] == compute_checksum(data[:-1])
def test_v3_frame_addresses_slot():
"""The slot byte selects which emulated controller receives the report."""
data = SwitchReport(buttons=0x0001).to_bytes(slot=3)
assert data[3] == 3
assert struct.unpack_from("<H", data, 4)[0] == 0x0001
assert data[-1] == compute_checksum(data[:-1])
with pytest.raises(ValueError):
SwitchReport().to_bytes(slot=UART_SLOT_COUNT)
with pytest.raises(ValueError):
SwitchReport().to_bytes(slot=-1)
def test_checksum_validation():
"""Checksum should match sum of all preceding bytes & 0xFF."""
r = SwitchReport(buttons=0x0001)
@ -96,7 +117,7 @@ def test_checksum_validation():
assert data[-1] == expected_checksum
# Corrupt a byte and verify mismatch
corrupted = bytearray(data)
corrupted[3] ^= 0xFF # flip bits in first payload byte
corrupted[4] ^= 0xFF # flip bits in first payload byte
recalculated = sum(corrupted[:-1]) & 0xFF
assert corrupted[-1] != recalculated, "Checksum should not match corrupted data"
@ -126,21 +147,21 @@ def test_imu_sample_dataclass():
s2 = IMUSample(accel_x=99999)
r = SwitchReport(imu_samples=[s2])
data = r.to_bytes()
ax = struct.unpack_from("<h", data, 11)[0]
ax = struct.unpack_from("<h", data, 12)[0]
assert ax == 32767, f"Expected clamped value 32767, got {ax}"
def test_backward_compat_switch_report():
"""SwitchReport with no imu_samples produces valid v2 frame (backward compat)."""
def test_switch_report_payload_layout():
"""Buttons and axes land at the documented v3 payload offsets."""
r = SwitchReport(buttons=0x000A, lx=200, ly=50, rx=128, ry=128)
data = r.to_bytes()
assert len(data) == 12
assert data[1] == 0x02 # still v2
# Buttons at bytes 3-4
buttons = struct.unpack_from("<H", data, 3)[0]
assert len(data) == 13
assert data[1] == 0x03
# Buttons at bytes 4-5
buttons = struct.unpack_from("<H", data, 4)[0]
assert buttons == 0x000A
# lx at byte 6
assert data[6] == 200
# lx at byte 7
assert data[7] == 200
def test_max_imu_samples_capped():
@ -148,37 +169,51 @@ def test_max_imu_samples_capped():
samples = [IMUSample(i, 0, 0, 0, 0, 0) for i in range(5)]
r = SwitchReport(imu_samples=samples)
data = r.to_bytes()
assert len(data) == 48 # 3 samples, not 5
assert data[10] == 3
assert len(data) == 49 # 3 samples, not 5
assert data[11] == 3
assert data[2] == 44 # payload_len for 3 samples
def test_decoded_rumble_frame_survives_fragmented_input():
frame = make_rumble_frame(64, 192)
frame = make_rumble_frame(64, 192, slot=1)
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))
assert uart.read_rumble() == pytest.approx((1, 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))
assert uart.read_rumble() == pytest.approx((0, 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))
uart, _ = make_uart(bytes(corrupted) + make_rumble_frame(75, 100, slot=2))
assert uart.read_rumble() == pytest.approx((75 / 255.0, 100 / 255.0))
assert uart.read_rumble() == pytest.approx((2, 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))
uart, _ = make_uart(make_rumble_frame(0, 0) + make_rumble_frame(255, 255, slot=3))
assert uart.read_rumble() == (0.0, 0.0)
assert uart.read_rumble() == (1.0, 1.0)
assert uart.read_rumble() == (0, 0.0, 0.0)
assert uart.read_rumble() == (3, 1.0, 1.0)
def test_legacy_rumble_frame_maps_to_slot_zero():
"""Pre-multi-controller firmware sends 5-byte frames without a slot byte."""
uart, _ = make_uart(make_legacy_rumble_frame(10, 20) + make_rumble_frame(30, 40, slot=1))
assert uart.read_rumble() == pytest.approx((0, 10 / 255.0, 20 / 255.0))
assert uart.read_rumble() == pytest.approx((1, 30 / 255.0, 40 / 255.0))
def test_rumble_frame_with_out_of_range_slot_is_skipped():
uart, _ = make_uart(make_rumble_frame(1, 2, slot=UART_SLOT_COUNT) + make_rumble_frame(3, 4))
assert uart.read_rumble() == pytest.approx((0, 3 / 255.0, 4 / 255.0))

View file

@ -14,17 +14,22 @@ from switch_pico_bridge.switch_pico_uart import PicoUART, SwitchReport, UART_BAU
class RecordingUART:
def __init__(self) -> None:
self.rumble: list[tuple[float, float]] = []
self.rumble: list[tuple[int, float, float]] = []
self.sent: list[tuple[int, int]] = []
def send_report(self, _report: SwitchReport) -> None:
pass
def send_report(self, report: SwitchReport, slot: int = 0) -> None:
self.sent.append((slot, report.buttons))
def read_rumble(self) -> tuple[float, float] | None:
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,
@ -88,29 +93,87 @@ def test_repeated_constant_rumble_stays_active_until_idle_timeout(
uart = RecordingUART()
controller = cast(sdl3.SDL_Gamepad, object())
ctx = bridge.ControllerContext(
controller,
7,
0,
"controller",
"/dev/null",
cast(PicoUART, cast(object, uart)),
)
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 = (64 / 255.0, 192 / 255.0)
magnitude = (0, 64 / 255.0, 192 / 255.0)
uart.rumble.append(magnitude)
bridge.service_contexts(1.0, args, make_config(), contexts, [], console)
bridge.service_contexts(1.0, args, make_config(), contexts, links, console)
uart.rumble.append(magnitude)
bridge.service_contexts(1.7, args, make_config(), contexts, [], console)
bridge.service_contexts(1.71, args, make_config(), contexts, [], console)
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, [], console)
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)