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:
parent
883eba193c
commit
b6a017eb06
14 changed files with 737 additions and 411 deletions
|
|
@ -564,8 +564,14 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||||
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HD_RUMBLE=1)
|
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HD_RUMBLE=1)
|
||||||
endif()
|
endif()
|
||||||
else()
|
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
|
target_compile_definitions(switch-pico PRIVATE
|
||||||
SWITCH_PICO_HID_INSTANCE_COUNT=1
|
SWITCH_PICO_HID_INSTANCE_COUNT=${SWITCH_PICO_UART_CONTROLLERS}
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|
|
||||||
40
README.md
40
README.md
|
|
@ -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.
|
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
|
## 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).
|
- **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.
|
- **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.
|
- **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.
|
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
|
```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)
|
## Hardware wiring (Pico)
|
||||||
- UART1 pins (fixed in firmware):
|
- 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
|
```python
|
||||||
from switch_pico_bridge import SwitchUARTClient, SwitchButton, SwitchDpad
|
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.press(SwitchButton.A)
|
||||||
client.release(SwitchButton.A)
|
client.release(SwitchButton.A)
|
||||||
client.move_left_stick(0.0, -1.0) # push up
|
client.move_left_stick(0.0, -1.0) # push up
|
||||||
client.set_hat(SwitchDpad.UP_RIGHT)
|
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).
|
- `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.
|
- The helper only depends on `pyserial`; SDL is not required.
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ ALL_BUTTONS = [
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# auto_send keeps the current state flowing in the background, so we don't
|
# auto_send keeps the current state flowing in the background, so we don't
|
||||||
# need to manually pump frames to the Pico.
|
# need to manually pump frames to the Pico. slot picks which of the Pico's
|
||||||
with SwitchUARTClient(PORT, send_interval=SEND_INTERVAL, auto_send=True) as client:
|
# four emulated controllers (0-3) this script drives.
|
||||||
|
with SwitchUARTClient(PORT, send_interval=SEND_INTERVAL, auto_send=True, slot=0) as client:
|
||||||
client.neutral()
|
client.neutral()
|
||||||
|
|
||||||
# Press every button/DPAD direction one-by-one, holding each briefly.
|
# Press every button/DPAD direction one-by-one, holding each briefly.
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -33,7 +33,7 @@
|
||||||
#define UART_TX_PIN 4
|
#define UART_TX_PIN 4
|
||||||
#define UART_RX_PIN 5
|
#define UART_RX_PIN 5
|
||||||
#define UART_RUMBLE_HEADER 0xBB
|
#define UART_RUMBLE_HEADER 0xBB
|
||||||
#define UART_RUMBLE_TYPE 0x02
|
#define UART_RUMBLE_TYPE_SLOT 0x03
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef SWITCH_PICO_BLUEPAD32
|
#ifdef SWITCH_PICO_BLUEPAD32
|
||||||
|
|
@ -47,9 +47,8 @@ static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
|
||||||
static ControllerState
|
static ControllerState
|
||||||
g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
|
g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
|
||||||
#else
|
#else
|
||||||
static constexpr uint8_t SWITCH_HID_INSTANCE = 0;
|
static bool g_last_ready[SWITCH_PICO_HID_INSTANCE_COUNT]{};
|
||||||
static bool g_last_ready = false;
|
static ControllerState g_user_states[SWITCH_PICO_HID_INSTANCE_COUNT]{};
|
||||||
static ControllerState g_user_state;
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static bool g_last_mounted = false;
|
static bool g_last_mounted = false;
|
||||||
|
|
@ -68,17 +67,20 @@ static ControllerState neutral_input() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifndef SWITCH_PICO_BLUEPAD32
|
#ifndef SWITCH_PICO_BLUEPAD32
|
||||||
static void send_rumble_uart_frame(const ControllerRumbleOutput& rumble) {
|
// Pico -> host: 0xBB 0x03 slot low high checksum(sum of first 5 bytes).
|
||||||
uint8_t frame[5] = {
|
static void send_rumble_uart_frame(uint8_t slot,
|
||||||
|
const ControllerRumbleOutput& rumble) {
|
||||||
|
uint8_t frame[6] = {
|
||||||
UART_RUMBLE_HEADER,
|
UART_RUMBLE_HEADER,
|
||||||
UART_RUMBLE_TYPE,
|
UART_RUMBLE_TYPE_SLOT,
|
||||||
|
slot,
|
||||||
rumble.low_frequency_magnitude,
|
rumble.low_frequency_magnitude,
|
||||||
rumble.high_frequency_magnitude,
|
rumble.high_frequency_magnitude,
|
||||||
0,
|
0,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (uint8_t i = 0; i < 4; ++i) {
|
for (uint8_t i = 0; i < 5; ++i) {
|
||||||
frame[4] = static_cast<uint8_t>(frame[4] + frame[i]);
|
frame[5] = static_cast<uint8_t>(frame[5] + frame[i]);
|
||||||
}
|
}
|
||||||
uart_write_blocking(UART_ID, frame, sizeof(frame));
|
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, controller_profile_runtime_scale_host_rumble(
|
||||||
instance, snapshot, rumble));
|
instance, snapshot, rumble));
|
||||||
#else
|
#else
|
||||||
if (instance != SWITCH_HID_INSTANCE) {
|
if (instance >= SWITCH_PICO_HID_INSTANCE_COUNT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send_rumble_uart_frame(rumble);
|
send_rumble_uart_frame(instance, rumble);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +139,9 @@ static bool poll_uart_frames() {
|
||||||
|
|
||||||
buffer[index++] = byte;
|
buffer[index++] = byte;
|
||||||
if (index == 3) {
|
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)) {
|
if (expected_len < 12 || expected_len > sizeof(buffer)) {
|
||||||
index = 0;
|
index = 0;
|
||||||
expected_len = 0;
|
expected_len = 0;
|
||||||
|
|
@ -147,10 +151,12 @@ static bool poll_uart_frames() {
|
||||||
|
|
||||||
if (expected_len > 0 && index >= expected_len) {
|
if (expected_len > 0 && index >= expected_len) {
|
||||||
ControllerState parsed{};
|
ControllerState parsed{};
|
||||||
if (switch_pro_apply_uart_packet(buffer, expected_len, parsed)) {
|
uint8_t slot = 0;
|
||||||
g_user_state = parsed;
|
if (switch_pro_apply_uart_packet(buffer, expected_len, parsed, slot)) {
|
||||||
|
g_user_states[slot] = parsed;
|
||||||
new_data = true;
|
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_east ? SWITCH_PRO_MASK_A : 0) |
|
||||||
(parsed.button_south ? SWITCH_PRO_MASK_B : 0) |
|
(parsed.button_south ? SWITCH_PRO_MASK_B : 0) |
|
||||||
(parsed.button_north ? SWITCH_PRO_MASK_X : 0) |
|
(parsed.button_north ? SWITCH_PRO_MASK_X : 0) |
|
||||||
|
|
@ -207,11 +213,14 @@ static void log_usb_state() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
const bool ready = usb_output_driver_is_ready(SWITCH_HID_INSTANCE);
|
for (uint8_t instance = 0;
|
||||||
if (ready != g_last_ready) {
|
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
|
||||||
g_last_ready = ready;
|
const bool ready = usb_output_driver_is_ready(instance);
|
||||||
LOG_PRINTF("[SWITCH] driver %s\n",
|
if (ready != g_last_ready[instance]) {
|
||||||
ready ? "ready (handshake OK)" : "not ready");
|
g_last_ready[instance] = ready;
|
||||||
|
LOG_PRINTF("[SWITCH %u] driver %s\n", instance,
|
||||||
|
ready ? "ready (handshake OK)" : "not ready");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
@ -247,12 +256,14 @@ int main() {
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
|
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
usb_output_driver_set_rumble_callback(SWITCH_HID_INSTANCE,
|
for (uint8_t instance = 0;
|
||||||
on_rumble_from_usb);
|
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
|
||||||
g_user_state = neutral_input();
|
usb_output_driver_set_rumble_callback(instance, on_rumble_from_usb);
|
||||||
usb_output_driver_set_input(SWITCH_HID_INSTANCE, g_user_state,
|
g_user_states[instance] = neutral_input();
|
||||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
usb_output_driver_set_input(instance, g_user_states[instance],
|
||||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
||||||
|
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef SWITCH_PICO_BLUEPAD32
|
#ifdef SWITCH_PICO_BLUEPAD32
|
||||||
|
|
@ -261,8 +272,8 @@ int main() {
|
||||||
usb_output_driver_mode_name());
|
usb_output_driver_mode_name());
|
||||||
#else
|
#else
|
||||||
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
|
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
|
||||||
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n",
|
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d slots=%d\n",
|
||||||
UART_TX_PIN, UART_RX_PIN, BAUD_RATE);
|
UART_TX_PIN, UART_RX_PIN, BAUD_RATE, SWITCH_PICO_HID_INSTANCE_COUNT);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -330,11 +341,13 @@ int main() {
|
||||||
#else
|
#else
|
||||||
bool new_data = poll_uart_frames(); // Pull controller state from UART1
|
bool new_data = poll_uart_frames(); // Pull controller state from UART1
|
||||||
(void)new_data;
|
(void)new_data;
|
||||||
ControllerState state = g_user_state;
|
for (uint8_t instance = 0;
|
||||||
usb_output_driver_set_input(SWITCH_HID_INSTANCE, state,
|
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
|
||||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
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(SWITCH_HID_INSTANCE);
|
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
||||||
|
(void)usb_output_driver_task(instance);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
log_usb_state();
|
log_usb_state();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -927,43 +927,56 @@ bool switch_pro_task(uint8_t instance) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
|
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
|
||||||
ControllerState& out_state) {
|
ControllerState& out_state,
|
||||||
if (packet == nullptr) {
|
uint8_t& out_slot) {
|
||||||
|
if (packet == nullptr || length < 12 || packet[0] != 0xAA) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// v2 format: 0xAA + 0x02 + payload_len + payload... + checksum
|
// v2: 0xAA 0x02 payload_len payload... checksum (slot 0)
|
||||||
if (length < 12 || packet[0] != 0xAA || packet[1] != 0x02) {
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t payload_len = packet[2];
|
const uint8_t payload_len = packet[2];
|
||||||
if ((uint16_t)payload_len + 4u != length) {
|
if ((uint16_t)payload_len + header_len + 1u != length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t sum = 0;
|
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];
|
sum += packet[i];
|
||||||
}
|
}
|
||||||
if ((sum & 0xFF) != packet[length - 1]) {
|
if ((sum & 0xFF) != packet[length - 1]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (slot >= SWITCH_PICO_HID_INSTANCE_COUNT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// payload: buttons(2 LE), hat, lx, ly, rx, ry, motion_count,
|
// payload: buttons(2 LE), hat, lx, ly, rx, ry, motion_count,
|
||||||
// [motion_samples...]
|
// [motion_samples...]
|
||||||
if (payload_len < 8) {
|
if (payload_len < 8) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const uint8_t* payload = packet + header_len;
|
||||||
|
|
||||||
SwitchProOutReport out{};
|
SwitchProOutReport out{};
|
||||||
out.buttons = static_cast<uint16_t>(packet[3]) |
|
out.buttons = static_cast<uint16_t>(payload[0]) |
|
||||||
(static_cast<uint16_t>(packet[4]) << 8);
|
(static_cast<uint16_t>(payload[1]) << 8);
|
||||||
out.hat = packet[5];
|
out.hat = payload[2];
|
||||||
out.lx = packet[6];
|
out.lx = payload[3];
|
||||||
out.ly = packet[7];
|
out.ly = payload[4];
|
||||||
out.rx = packet[8];
|
out.rx = payload[5];
|
||||||
out.ry = packet[9];
|
out.ry = payload[6];
|
||||||
uint8_t motion_count = packet[10];
|
uint8_t motion_count = payload[7];
|
||||||
if (motion_count > CONTROLLER_MOTION_SAMPLE_CAPACITY) {
|
if (motion_count > CONTROLLER_MOTION_SAMPLE_CAPACITY) {
|
||||||
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();
|
ControllerState state = make_neutral_state();
|
||||||
state.motion_sample_count = motion_count;
|
state.motion_sample_count = motion_count;
|
||||||
for (uint8_t i = 0; i < motion_count; ++i) {
|
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_x = read_int16(base + 0);
|
||||||
state.motion_samples[i].accel_y = read_int16(base + 2);
|
state.motion_samples[i].accel_y = read_int16(base + 2);
|
||||||
state.motion_samples[i].accel_z = read_int16(base + 4);
|
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);
|
state.right_stick_y = expand_axis(out.ry);
|
||||||
|
|
||||||
out_state = state;
|
out_state = state;
|
||||||
|
out_slot = slot;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,11 @@ void switch_pro_set_input(uint8_t instance, const ControllerState& state,
|
||||||
// 0x30 input report was successfully queued.
|
// 0x30 input report was successfully queued.
|
||||||
bool switch_pro_task(uint8_t instance);
|
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,
|
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
|
// Driver state helpers
|
||||||
bool switch_pro_is_ready(uint8_t instance);
|
bool switch_pro_is_ready(uint8_t instance);
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
"""
|
"""
|
||||||
Bridge multiple SDL3 controllers to switch-pico over UART and mirror rumble back.
|
Bridge multiple SDL3 controllers to switch-pico over UART and mirror rumble back.
|
||||||
|
|
||||||
The framing matches ``switch-pico.cpp``:
|
The framing matches ``src/firmware/main.cpp``:
|
||||||
- Host -> Pico : UART v2 controller report
|
- Host -> Pico : UART v3 controller report (0xAA, 0x03, len, slot, payload, checksum)
|
||||||
- Pico -> Host : 0xBB, 0x02, low-frequency magnitude,
|
- Pico -> Host : 0xBB, 0x03, slot, low-frequency magnitude,
|
||||||
high-frequency magnitude, checksum
|
high-frequency magnitude, checksum
|
||||||
|
|
||||||
Features inspired by ``host/controller_bridge.py``:
|
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
|
- Rich-powered interactive pairing UI
|
||||||
- Adjustable send frequency, deadzone, and trigger thresholds
|
- Adjustable send frequency, deadzone, and trigger thresholds
|
||||||
- Rumble feedback delivered to SDL3 controllers
|
- Rumble feedback delivered to SDL3 controllers
|
||||||
|
|
@ -36,6 +37,7 @@ from rich.text import Text
|
||||||
|
|
||||||
from .switch_pico_uart import (
|
from .switch_pico_uart import (
|
||||||
UART_BAUD,
|
UART_BAUD,
|
||||||
|
UART_SLOT_COUNT,
|
||||||
MS2_PER_G,
|
MS2_PER_G,
|
||||||
RAD_TO_DEG,
|
RAD_TO_DEG,
|
||||||
ACCEL_LSB_PER_G,
|
ACCEL_LSB_PER_G,
|
||||||
|
|
@ -62,11 +64,14 @@ SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE
|
||||||
GYRO_BIAS_SAMPLES = 200
|
GYRO_BIAS_SAMPLES = 200
|
||||||
|
|
||||||
|
|
||||||
def parse_mapping(value: str) -> Tuple[int, str]:
|
def parse_mapping(value: str) -> Tuple[int, str, Optional[int]]:
|
||||||
"""Parse 'index:serial_port' CLI mapping argument."""
|
"""Parse 'index:serial_port[:slot]' CLI mapping argument."""
|
||||||
if ":" not in value:
|
parts = value.split(":")
|
||||||
raise argparse.ArgumentTypeError("Mapping must look like 'index:serial_port'")
|
if len(parts) not in (2, 3):
|
||||||
idx_str, port = value.split(":", 1)
|
raise argparse.ArgumentTypeError(
|
||||||
|
"Mapping must look like 'index:serial_port' or 'index:serial_port:slot'"
|
||||||
|
)
|
||||||
|
idx_str, port = parts[0], parts[1].strip()
|
||||||
try:
|
try:
|
||||||
idx = int(idx_str, 10)
|
idx = int(idx_str, 10)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
@ -75,7 +80,17 @@ def parse_mapping(value: str) -> Tuple[int, str]:
|
||||||
) from exc
|
) from exc
|
||||||
if not port:
|
if not port:
|
||||||
raise argparse.ArgumentTypeError("Serial port cannot be empty")
|
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:
|
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(
|
def interactive_pairing(
|
||||||
console: Console, controller_info: Dict[int, str], ports: List[Dict[str, str]]
|
console: Console,
|
||||||
) -> List[Tuple[int, str]]:
|
controller_info: Dict[int, str],
|
||||||
"""Prompt the user to pair controllers to UART ports via Rich UI."""
|
ports: List[Dict[str, str]],
|
||||||
available = ports.copy()
|
slots_per_port: int,
|
||||||
mappings: List[Tuple[int, str]] = []
|
) -> List[Tuple[int, str, Optional[int]]]:
|
||||||
for controller_idx in controller_info:
|
"""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:
|
if not available:
|
||||||
console.print(
|
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
|
break
|
||||||
|
|
||||||
table = Table(
|
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("Choice", justify="center")
|
||||||
table.add_column("Port")
|
table.add_column("Port")
|
||||||
table.add_column("Description")
|
table.add_column("Description")
|
||||||
|
table.add_column("Slots used", justify="center")
|
||||||
for i, port in enumerate(available):
|
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)
|
console.print(table)
|
||||||
choices = [str(i) for i in range(len(available))] + ["q"]
|
choices = [str(i) for i in range(len(available))] + ["q"]
|
||||||
selection = Prompt.ask(
|
selection = Prompt.ask(
|
||||||
|
|
@ -189,11 +219,12 @@ def interactive_pairing(
|
||||||
)
|
)
|
||||||
if selection == "q":
|
if selection == "q":
|
||||||
break
|
break
|
||||||
idx = int(selection)
|
device = available[int(selection)]["device"]
|
||||||
port = available.pop(idx)
|
slot = used_slots.get(device, 0)
|
||||||
mappings.append((controller_idx, port["device"]))
|
used_slots[device] = slot + 1
|
||||||
|
mappings.append((controller_idx, device, slot))
|
||||||
console.print(
|
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
|
return mappings
|
||||||
|
|
||||||
|
|
@ -227,6 +258,15 @@ def apply_rumble(
|
||||||
return low != 0 or high != 0, accepted
|
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
|
@dataclass
|
||||||
class ControllerContext:
|
class ControllerContext:
|
||||||
controller: sdl3.SDL_Gamepad
|
controller: sdl3.SDL_Gamepad
|
||||||
|
|
@ -234,7 +274,7 @@ class ControllerContext:
|
||||||
controller_index: int
|
controller_index: int
|
||||||
stable_id: str
|
stable_id: str
|
||||||
port: Optional[str]
|
port: Optional[str]
|
||||||
uart: Optional[PicoUART]
|
slot: int = 0
|
||||||
report: SwitchReport = field(default_factory=SwitchReport)
|
report: SwitchReport = field(default_factory=SwitchReport)
|
||||||
dpad: Dict[str, bool] = field(
|
dpad: Dict[str, bool] = field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
|
|
@ -249,7 +289,6 @@ class ControllerContext:
|
||||||
default_factory=lambda: {"left": False, "right": False}
|
default_factory=lambda: {"left": False, "right": False}
|
||||||
)
|
)
|
||||||
last_send: float = 0.0
|
last_send: float = 0.0
|
||||||
last_reopen_attempt: float = 0.0
|
|
||||||
last_rumble_at: float = 0.0
|
last_rumble_at: float = 0.0
|
||||||
rumble_active: bool = False
|
rumble_active: bool = False
|
||||||
axis_offsets: Dict[int, int] = field(default_factory=dict)
|
axis_offsets: Dict[int, int] = field(default_factory=dict)
|
||||||
|
|
@ -644,13 +683,29 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
action="append",
|
action="append",
|
||||||
type=parse_mapping,
|
type=parse_mapping,
|
||||||
default=[],
|
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(
|
parser.add_argument(
|
||||||
"--ports",
|
"--ports",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
help="Serial ports to auto-pair with controllers in ascending index order.",
|
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(
|
parser.add_argument(
|
||||||
"--interactive",
|
"--interactive",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -881,8 +936,11 @@ class DisplayIndexAllocator:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PairingState:
|
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]
|
available_ports: List[str]
|
||||||
|
slots_per_port: int = UART_SLOT_COUNT
|
||||||
auto_assigned_indices: set[int] = field(default_factory=set)
|
auto_assigned_indices: set[int] = field(default_factory=set)
|
||||||
auto_pairing_enabled: bool = False
|
auto_pairing_enabled: bool = False
|
||||||
auto_discover_ports: bool = False
|
auto_discover_ports: bool = False
|
||||||
|
|
@ -892,6 +950,17 @@ class PairingState:
|
||||||
include_port_mfr: List[str] = field(default_factory=list)
|
include_port_mfr: List[str] = field(default_factory=list)
|
||||||
display_index_alloc: DisplayIndexAllocator = field(default_factory=DisplayIndexAllocator)
|
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(
|
def load_button_maps(
|
||||||
console: Console, args: argparse.Namespace
|
console: Console, args: argparse.Namespace
|
||||||
|
|
@ -1078,6 +1147,31 @@ def list_serial_ports(console: Console, args: argparse.Namespace) -> None:
|
||||||
console.print(table)
|
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(
|
def prepare_pairing_state(
|
||||||
args: argparse.Namespace,
|
args: argparse.Namespace,
|
||||||
console: Console,
|
console: Console,
|
||||||
|
|
@ -1092,9 +1186,10 @@ def prepare_pairing_state(
|
||||||
ignore_port_desc = [d.lower() for d in args.ignore_port_desc]
|
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_desc = [d.lower() for d in args.include_port_desc]
|
||||||
include_port_mfr = [m.lower() for m in args.include_port_manufacturer]
|
include_port_mfr = [m.lower() for m in args.include_port_manufacturer]
|
||||||
|
slots_per_port = int(args.slots_per_port)
|
||||||
available_ports: List[str] = []
|
available_ports: List[str] = []
|
||||||
|
|
||||||
mappings = list(args.map)
|
mappings: List[Tuple[int, str, Optional[int]]] = list(args.map)
|
||||||
if args.interactive:
|
if args.interactive:
|
||||||
if not controller_indices:
|
if not controller_indices:
|
||||||
parser.error("No controllers detected for interactive pairing.")
|
parser.error("No controllers detected for interactive pairing.")
|
||||||
|
|
@ -1107,7 +1202,7 @@ def prepare_pairing_state(
|
||||||
)
|
)
|
||||||
if not discovered:
|
if not discovered:
|
||||||
parser.error("No UART devices found for interactive pairing.")
|
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:
|
if not mappings:
|
||||||
parser.error("No controller-to-UART mappings were selected.")
|
parser.error("No controller-to-UART mappings were selected.")
|
||||||
elif auto_pairing_enabled:
|
elif auto_pairing_enabled:
|
||||||
|
|
@ -1134,10 +1229,10 @@ def prepare_pairing_state(
|
||||||
"[yellow]No UART devices detected yet; waiting for hotplug...[/yellow]"
|
"[yellow]No UART devices detected yet; waiting for hotplug...[/yellow]"
|
||||||
)
|
)
|
||||||
|
|
||||||
mapping_by_index = {index: port for index, port in mappings}
|
|
||||||
return PairingState(
|
return PairingState(
|
||||||
mapping_by_index=mapping_by_index,
|
mapping_by_index=resolve_mapping_slots(mappings, slots_per_port, parser),
|
||||||
available_ports=available_ports,
|
available_ports=available_ports,
|
||||||
|
slots_per_port=slots_per_port,
|
||||||
auto_pairing_enabled=auto_pairing_enabled,
|
auto_pairing_enabled=auto_pairing_enabled,
|
||||||
auto_discover_ports=auto_discover_ports,
|
auto_discover_ports=auto_discover_ports,
|
||||||
include_non_usb=include_non_usb,
|
include_non_usb=include_non_usb,
|
||||||
|
|
@ -1149,32 +1244,75 @@ def prepare_pairing_state(
|
||||||
|
|
||||||
def assign_port_for_index(
|
def assign_port_for_index(
|
||||||
pairing: PairingState, idx: int, console: Console
|
pairing: PairingState, idx: int, console: Console
|
||||||
) -> Optional[str]:
|
) -> Optional[Tuple[str, int]]:
|
||||||
"""Return the UART assigned to a controller index, auto-pairing if allowed."""
|
"""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:
|
if idx in pairing.mapping_by_index:
|
||||||
return pairing.mapping_by_index[idx]
|
return pairing.mapping_by_index[idx]
|
||||||
if not pairing.auto_pairing_enabled:
|
if not pairing.auto_pairing_enabled:
|
||||||
return None
|
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
|
return None
|
||||||
port_choice = pairing.available_ports.pop(0)
|
_, _, port_choice = min(candidates)
|
||||||
pairing.mapping_by_index[idx] = port_choice
|
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)
|
pairing.auto_assigned_indices.add(idx)
|
||||||
console.print(f"[green]Auto-paired controller {idx} to {port_choice}[/green]")
|
console.print(f"[green]Auto-paired controller {idx} to {port_choice} slot {slot}[/green]")
|
||||||
return port_choice
|
return port_choice, slot
|
||||||
|
|
||||||
|
|
||||||
def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set[str]:
|
def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set[str]:
|
||||||
"""Return a set of UART paths currently reserved or mapped."""
|
"""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)
|
used.update(ctx.port for ctx in contexts.values() if ctx.port)
|
||||||
return used
|
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(
|
def handle_removed_port(
|
||||||
path: str,
|
path: str,
|
||||||
pairing: PairingState,
|
pairing: PairingState,
|
||||||
contexts: Dict[int, ControllerContext],
|
contexts: Dict[int, ControllerContext],
|
||||||
|
links: Dict[str, UartLink],
|
||||||
console: Console,
|
console: Console,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Clear mappings/contexts for a UART path that disappeared."""
|
"""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]"
|
f"[yellow]UART {path} removed; dropping from available pool[/yellow]"
|
||||||
)
|
)
|
||||||
indices_to_clear = [
|
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:
|
for idx in indices_to_clear:
|
||||||
pairing.mapping_by_index.pop(idx, None)
|
pairing.mapping_by_index.pop(idx, None)
|
||||||
pairing.auto_assigned_indices.discard(idx)
|
pairing.auto_assigned_indices.discard(idx)
|
||||||
|
close_link(links, path)
|
||||||
for ctx in list(contexts.values()):
|
for ctx in list(contexts.values()):
|
||||||
if ctx.port != path:
|
if ctx.port != path:
|
||||||
continue
|
continue
|
||||||
if ctx.uart:
|
detach_context_from_port(ctx)
|
||||||
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()
|
|
||||||
console.print(
|
console.print(
|
||||||
f"[yellow]UART {path} removed; controller {ctx.controller_index} waiting for reassignment[/yellow]"
|
f"[yellow]UART {path} removed; controller {ctx.controller_index} waiting for reassignment[/yellow]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def discover_new_ports(
|
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:
|
) -> None:
|
||||||
"""Scan for new serial ports and add unused ones to the available pool."""
|
"""Scan for new serial ports and add unused ones to the available pool."""
|
||||||
if not pairing.auto_discover_ports:
|
if not pairing.auto_discover_ports:
|
||||||
|
|
@ -1221,12 +1354,11 @@ def discover_new_ports(
|
||||||
)
|
)
|
||||||
current_paths = {info["device"] for info in discovered}
|
current_paths = {info["device"] for info in discovered}
|
||||||
known_paths = set(pairing.available_ports)
|
known_paths = set(pairing.available_ports)
|
||||||
known_paths.update(pairing.mapping_by_index.values())
|
known_paths.update(ports_in_use(pairing, contexts))
|
||||||
known_paths.update(ctx.port for ctx in contexts.values() if ctx.port)
|
|
||||||
# Drop any paths we previously knew about that are no longer present.
|
# 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]
|
removed_paths = [path for path in known_paths if path not in current_paths]
|
||||||
for path in removed_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)
|
in_use = ports_in_use(pairing, contexts)
|
||||||
for info in discovered:
|
for info in discovered:
|
||||||
path = info["device"]
|
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(
|
def pair_waiting_contexts(
|
||||||
args: argparse.Namespace,
|
args: argparse.Namespace,
|
||||||
pairing: PairingState,
|
pairing: PairingState,
|
||||||
contexts: Dict[int, ControllerContext],
|
contexts: Dict[int, ControllerContext],
|
||||||
uarts: List[PicoUART],
|
links: Dict[str, UartLink],
|
||||||
console: Console,
|
console: Console,
|
||||||
) -> None:
|
) -> 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()):
|
for ctx in list(contexts.values()):
|
||||||
if ctx.port is not None:
|
if ctx.port is not None:
|
||||||
continue
|
continue
|
||||||
# Try to grab a port for this controller; if none are available, leave it waiting.
|
# Try to grab a slot for this controller; if none are available, leave it waiting.
|
||||||
port_choice = assign_port_for_index(pairing, ctx.controller_index, console)
|
assignment = assign_port_for_index(pairing, ctx.controller_index, console)
|
||||||
if port_choice is None:
|
if assignment is None:
|
||||||
continue
|
continue
|
||||||
ctx.port = port_choice
|
ctx.port, ctx.slot = assignment
|
||||||
uart = open_uart_or_warn(port_choice, args.baud, console)
|
link = ensure_link(links, ctx.port, args.baud, console, time.monotonic())
|
||||||
ctx.last_reopen_attempt = time.monotonic()
|
announce_pairing(ctx, link, console)
|
||||||
if uart:
|
|
||||||
uarts.append(uart)
|
|
||||||
ctx.uart = uart
|
def attach_controller(
|
||||||
console.print(
|
sdl_id: int,
|
||||||
f"[green]Controller {ctx.controller_index} (id {ctx.stable_id}, inst {ctx.instance_id}) paired to {port_choice}[/green]"
|
args: argparse.Namespace,
|
||||||
)
|
pairing: PairingState,
|
||||||
else:
|
contexts: Dict[int, ControllerContext],
|
||||||
ctx.uart = None
|
links: Dict[str, UartLink],
|
||||||
console.print(
|
console: Console,
|
||||||
f"[yellow]Controller {ctx.controller_index} (id {ctx.stable_id}, inst {ctx.instance_id}) waiting for UART {port_choice}[/yellow]"
|
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(
|
def open_initial_contexts(
|
||||||
|
|
@ -1275,62 +1460,13 @@ def open_initial_contexts(
|
||||||
controller_indices: List[int],
|
controller_indices: List[int],
|
||||||
console: Console,
|
console: Console,
|
||||||
config: BridgeConfig,
|
config: BridgeConfig,
|
||||||
) -> Tuple[Dict[int, ControllerContext], List[PicoUART]]:
|
) -> Tuple[Dict[int, ControllerContext], Dict[str, UartLink]]:
|
||||||
"""Open initial controllers and UARTs for detected indices."""
|
"""Open initial controllers and UARTs for detected indices."""
|
||||||
contexts: Dict[int, ControllerContext] = {}
|
contexts: Dict[int, ControllerContext] = {}
|
||||||
uarts: List[PicoUART] = []
|
links: Dict[str, UartLink] = {}
|
||||||
for instance_id in controller_indices:
|
for instance_id in controller_indices:
|
||||||
if not sdl3.SDL_IsGamepad(instance_id):
|
attach_controller(instance_id, args, pairing, contexts, links, console, config)
|
||||||
name = sdl3.SDL_GetJoystickNameForID(instance_id)
|
return contexts, links
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def handle_axis_motion(
|
def handle_axis_motion(
|
||||||
|
|
@ -1506,72 +1642,22 @@ def handle_device_added(
|
||||||
args: argparse.Namespace,
|
args: argparse.Namespace,
|
||||||
pairing: PairingState,
|
pairing: PairingState,
|
||||||
contexts: Dict[int, ControllerContext],
|
contexts: Dict[int, ControllerContext],
|
||||||
uarts: List[PicoUART],
|
links: Dict[str, UartLink],
|
||||||
console: Console,
|
console: Console,
|
||||||
config: BridgeConfig,
|
config: BridgeConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle controller hotplug by opening and pairing UART if possible."""
|
"""Handle controller hotplug by opening it and pairing a UART slot if possible."""
|
||||||
sdl_id = event.gdevice.which
|
attach_controller(event.gdevice.which, args, pairing, contexts, links, console, config)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def handle_device_removed(
|
def handle_device_removed(
|
||||||
event: sdl3.SDL_Event,
|
event: sdl3.SDL_Event,
|
||||||
pairing: PairingState,
|
pairing: PairingState,
|
||||||
contexts: Dict[int, ControllerContext],
|
contexts: Dict[int, ControllerContext],
|
||||||
uarts: List[PicoUART],
|
links: Dict[str, UartLink],
|
||||||
console: Console,
|
console: Console,
|
||||||
) -> None:
|
) -> 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
|
instance_id = event.gdevice.which
|
||||||
ctx = contexts.pop(instance_id, None)
|
ctx = contexts.pop(instance_id, None)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
|
|
@ -1579,57 +1665,37 @@ def handle_device_removed(
|
||||||
console.print(
|
console.print(
|
||||||
f"[yellow]Controller {ctx.controller_index} (id {ctx.stable_id}) removed[/yellow]"
|
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)
|
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:
|
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)
|
freed = pairing.mapping_by_index.pop(ctx.controller_index, None)
|
||||||
pairing.auto_assigned_indices.discard(ctx.controller_index)
|
pairing.auto_assigned_indices.discard(ctx.controller_index)
|
||||||
if freed and freed not in pairing.available_ports:
|
if freed:
|
||||||
pairing.available_ports.append(freed)
|
port, slot = freed
|
||||||
console.print(f"[cyan]Released UART {freed} back to pool[/cyan]")
|
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)
|
pairing.display_index_alloc.release(ctx.controller_index)
|
||||||
sdl3.SDL_CloseGamepad(ctx.controller)
|
sdl3.SDL_CloseGamepad(ctx.controller)
|
||||||
|
|
||||||
|
|
||||||
def service_contexts(
|
def service_link(
|
||||||
now: float,
|
now: float,
|
||||||
args: argparse.Namespace,
|
|
||||||
config: BridgeConfig,
|
config: BridgeConfig,
|
||||||
contexts: Dict[int, ControllerContext],
|
link: UartLink,
|
||||||
uarts: List[PicoUART],
|
members: List[ControllerContext],
|
||||||
console: Console,
|
console: Console,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Poll controllers, reconnect UARTs, send reports, and apply rumble."""
|
"""Send due reports for every controller on one port and demux its rumble."""
|
||||||
for ctx in list(contexts.values()):
|
uart = link.uart
|
||||||
current_button_map = (
|
if uart is None:
|
||||||
config.button_map_swapped
|
return
|
||||||
if (config.swap_abxy_global or ctx.swap_abxy)
|
try:
|
||||||
else config.button_map_default
|
for ctx in members:
|
||||||
)
|
|
||||||
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:
|
|
||||||
if now - ctx.last_send >= config.interval:
|
if now - ctx.last_send >= config.interval:
|
||||||
if ctx.sensors_enabled and not config.no_imu:
|
if ctx.sensors_enabled and not config.no_imu:
|
||||||
# Keep publishing the latest complete sensor window. Draining
|
# Keep publishing the latest complete sensor window. Draining
|
||||||
|
|
@ -1637,19 +1703,24 @@ def service_contexts(
|
||||||
ctx.report.imu_samples = ctx.imu_samples
|
ctx.report.imu_samples = ctx.imu_samples
|
||||||
else:
|
else:
|
||||||
ctx.report.imu_samples = []
|
ctx.report.imu_samples = []
|
||||||
ctx.uart.send_report(ctx.report)
|
uart.send_report(ctx.report, ctx.slot)
|
||||||
ctx.last_send = now
|
ctx.last_send = now
|
||||||
|
|
||||||
latest_rumble = None
|
# Keep only the freshest rumble command per slot seen during this tick.
|
||||||
while True:
|
latest_by_slot: Dict[int, Tuple[float, float]] = {}
|
||||||
rumble = ctx.uart.read_rumble()
|
frames_by_slot: Dict[int, int] = {}
|
||||||
if rumble is None:
|
while True:
|
||||||
break
|
frame = uart.read_rumble()
|
||||||
latest_rumble = rumble
|
if frame is None:
|
||||||
ctx.debug_rumble_frames += 1
|
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:
|
if latest_rumble is not None:
|
||||||
# Apply only the freshest rumble command seen during this tick.
|
|
||||||
ctx.rumble_active, accepted = apply_rumble(
|
ctx.rumble_active, accepted = apply_rumble(
|
||||||
ctx.controller,
|
ctx.controller,
|
||||||
latest_rumble[0],
|
latest_rumble[0],
|
||||||
|
|
@ -1664,30 +1735,65 @@ def service_contexts(
|
||||||
ctx.last_debug_rumble = latest_rumble
|
ctx.last_debug_rumble = latest_rumble
|
||||||
error = "" if accepted else f" sdl_error={sdl3.SDL_GetError().decode(errors='ignore')!r}"
|
error = "" if accepted else f" sdl_error={sdl3.SDL_GetError().decode(errors='ignore')!r}"
|
||||||
print(
|
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"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"-> 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"high={shape_rumble(latest_rumble[1], config.rumble_gain, config.rumble_curve):.3f} "
|
||||||
f"accepted={accepted}{error}"
|
f"accepted={accepted}{error}"
|
||||||
)
|
)
|
||||||
elif (
|
elif ctx.rumble_active and (now - ctx.last_rumble_at) > RUMBLE_IDLE_TIMEOUT:
|
||||||
ctx.rumble_active
|
|
||||||
and (now - ctx.last_rumble_at) > RUMBLE_IDLE_TIMEOUT
|
|
||||||
):
|
|
||||||
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
||||||
ctx.rumble_active = False
|
ctx.rumble_active = False
|
||||||
except SerialException as exc:
|
except SerialException as exc:
|
||||||
console.print(f"[yellow]UART {ctx.port} disconnected: {exc}[/yellow]")
|
console.print(f"[yellow]UART {link.port} disconnected: {exc}[/yellow]")
|
||||||
try:
|
try:
|
||||||
ctx.uart.close()
|
uart.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
link.uart = None
|
||||||
|
link.last_reopen_attempt = now
|
||||||
|
for ctx in members:
|
||||||
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
||||||
ctx.uart = None
|
|
||||||
ctx.rumble_active = False
|
ctx.rumble_active = False
|
||||||
ctx.last_reopen_attempt = now
|
except Exception as exc:
|
||||||
except Exception as exc:
|
console.print(f"[red]UART error on {link.port}: {exc}[/red]")
|
||||||
console.print(f"[red]UART error on {ctx.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(
|
def run_bridge_loop(
|
||||||
|
|
@ -1696,7 +1802,7 @@ def run_bridge_loop(
|
||||||
config: BridgeConfig,
|
config: BridgeConfig,
|
||||||
pairing: PairingState,
|
pairing: PairingState,
|
||||||
contexts: Dict[int, ControllerContext],
|
contexts: Dict[int, ControllerContext],
|
||||||
uarts: List[PicoUART],
|
links: Dict[str, UartLink],
|
||||||
hotkey: Optional[HotkeyMonitor] = None,
|
hotkey: Optional[HotkeyMonitor] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main event loop for bridging controllers to UART and handling rumble."""
|
"""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)
|
handle_sensor_update(event, contexts, config)
|
||||||
elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED:
|
elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED:
|
||||||
handle_device_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:
|
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()
|
now = time.monotonic()
|
||||||
if now - last_port_scan > port_scan_interval:
|
if now - last_port_scan > port_scan_interval:
|
||||||
# Periodically rescan for new UARTs to auto-pair hotplugged devices.
|
# 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
|
last_port_scan = now
|
||||||
pair_waiting_contexts(args, pairing, contexts, uarts, console)
|
pair_waiting_contexts(args, pairing, contexts, links, console)
|
||||||
else:
|
service_contexts(now, args, config, contexts, links, console)
|
||||||
pair_waiting_contexts(args, pairing, contexts, uarts, console)
|
|
||||||
service_contexts(now, args, config, contexts, uarts, console)
|
|
||||||
if hotkey:
|
if hotkey:
|
||||||
for key in hotkey.poll_keys():
|
for key in hotkey.poll_keys():
|
||||||
if key == config.zero_hotkey:
|
if key == config.zero_hotkey:
|
||||||
|
|
@ -1744,12 +1848,13 @@ def run_bridge_loop(
|
||||||
sdl3.SDL_Delay(1)
|
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."""
|
"""Gracefully close controllers, UARTs, and SDL subsystems."""
|
||||||
for ctx in contexts.values():
|
for ctx in contexts.values():
|
||||||
sdl3.SDL_CloseGamepad(ctx.controller)
|
sdl3.SDL_CloseGamepad(ctx.controller)
|
||||||
for uart in uarts:
|
for link in links.values():
|
||||||
uart.close()
|
if link.uart:
|
||||||
|
link.uart.close()
|
||||||
sdl3.SDL_Quit()
|
sdl3.SDL_Quit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1764,7 +1869,7 @@ def main() -> None:
|
||||||
config = build_bridge_config(console, args)
|
config = build_bridge_config(console, args)
|
||||||
initialize_sdl(parser)
|
initialize_sdl(parser)
|
||||||
contexts: Dict[int, ControllerContext] = {}
|
contexts: Dict[int, ControllerContext] = {}
|
||||||
uarts: List[PicoUART] = []
|
links: Dict[str, UartLink] = {}
|
||||||
hotkey_monitor: Optional[HotkeyMonitor] = None
|
hotkey_monitor: Optional[HotkeyMonitor] = None
|
||||||
try:
|
try:
|
||||||
if args.list_controllers:
|
if args.list_controllers:
|
||||||
|
|
@ -1790,18 +1895,18 @@ def main() -> None:
|
||||||
candidate = HotkeyMonitor(console, hotkey_messages)
|
candidate = HotkeyMonitor(console, hotkey_messages)
|
||||||
if candidate.start():
|
if candidate.start():
|
||||||
hotkey_monitor = candidate
|
hotkey_monitor = candidate
|
||||||
contexts, uarts = open_initial_contexts(
|
contexts, links = open_initial_contexts(
|
||||||
args, pairing, controller_indices, console, config
|
args, pairing, controller_indices, console, config
|
||||||
)
|
)
|
||||||
if not contexts:
|
if not contexts:
|
||||||
console.print(
|
console.print(
|
||||||
"[yellow]No controllers opened; waiting for hotplug events...[/yellow]"
|
"[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:
|
finally:
|
||||||
if hotkey_monitor:
|
if hotkey_monitor:
|
||||||
hotkey_monitor.stop()
|
hotkey_monitor.stop()
|
||||||
cleanup(contexts, uarts)
|
cleanup(contexts, links)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
Host -> Pico : UART v3 controller report
|
||||||
Pico -> Host : 0xBB, 0x02, low-frequency magnitude, high-frequency magnitude,
|
0xAA, 0x03, payload_len, slot, payload..., checksum
|
||||||
checksum (sum of the first 4 bytes)
|
(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
|
from __future__ import annotations
|
||||||
|
|
@ -25,10 +27,13 @@ import serial
|
||||||
from serial.tools import list_ports, list_ports_common
|
from serial.tools import list_ports, list_ports_common
|
||||||
|
|
||||||
UART_HEADER = 0xAA
|
UART_HEADER = 0xAA
|
||||||
UART_PROTOCOL_VERSION = 0x02
|
UART_PROTOCOL_VERSION = 0x03
|
||||||
RUMBLE_HEADER = 0xBB
|
RUMBLE_HEADER = 0xBB
|
||||||
|
# Legacy 5-byte frame (no slot) from firmware before multi-controller support.
|
||||||
RUMBLE_TYPE_DECODED = 0x02
|
RUMBLE_TYPE_DECODED = 0x02
|
||||||
|
RUMBLE_TYPE_SLOT = 0x03
|
||||||
UART_BAUD = 921600
|
UART_BAUD = 921600
|
||||||
|
UART_SLOT_COUNT = 4
|
||||||
IMU_SAMPLES_PER_REPORT = 3
|
IMU_SAMPLES_PER_REPORT = 3
|
||||||
|
|
||||||
MS2_PER_G = 9.80665
|
MS2_PER_G = 9.80665
|
||||||
|
|
@ -242,8 +247,10 @@ class SwitchReport:
|
||||||
ry: int = 128
|
ry: int = 128
|
||||||
imu_samples: List[IMUSample] = field(default_factory=list)
|
imu_samples: List[IMUSample] = field(default_factory=list)
|
||||||
|
|
||||||
def to_bytes(self) -> bytes:
|
def to_bytes(self, slot: int = 0) -> bytes:
|
||||||
"""Serialize the report into UART v2 framed packet format."""
|
"""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)
|
count = min(len(self.imu_samples), IMU_SAMPLES_PER_REPORT)
|
||||||
payload = struct.pack(
|
payload = struct.pack(
|
||||||
"<HBBBBBB",
|
"<HBBBBBB",
|
||||||
|
|
@ -269,7 +276,7 @@ class SwitchReport:
|
||||||
)
|
)
|
||||||
|
|
||||||
payload_len = len(payload)
|
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)])
|
return frame + bytes([compute_checksum(frame)])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -290,20 +297,21 @@ class PicoUART:
|
||||||
)
|
)
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
|
|
||||||
def send_report(self, report: SwitchReport) -> None:
|
def send_report(self, report: SwitchReport, slot: int = 0) -> None:
|
||||||
"""Send a controller report to the Pico."""
|
"""Send a controller report to one of the Pico's controller slots."""
|
||||||
self.serial.write(report.to_bytes())
|
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:
|
Frame format:
|
||||||
0: 0xBB (RUMBLE_HEADER)
|
0: 0xBB (RUMBLE_HEADER)
|
||||||
1: type (0x02 for decoded rumble)
|
1: type (0x03 slot frame; legacy 0x02 has no slot byte and means slot 0)
|
||||||
2: low-frequency magnitude (0-255)
|
2: slot (0x03 only)
|
||||||
3: high-frequency magnitude (0-255)
|
then low-frequency magnitude, high-frequency magnitude (0-255)
|
||||||
4: checksum (sum of first 4 bytes) & 0xFF
|
and checksum (sum of the preceding bytes) & 0xFF
|
||||||
"""
|
"""
|
||||||
waiting = self.serial.in_waiting
|
waiting = self.serial.in_waiting
|
||||||
if waiting:
|
if waiting:
|
||||||
|
|
@ -317,19 +325,25 @@ class PicoUART:
|
||||||
if start < 0:
|
if start < 0:
|
||||||
self._buffer.clear()
|
self._buffer.clear()
|
||||||
return None
|
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:
|
if start > 0:
|
||||||
del self._buffer[:start]
|
del self._buffer[:start]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
frame = self._buffer[start : start + 5]
|
frame = bytes(self._buffer[start : start + length])
|
||||||
checksum = compute_checksum(bytes(frame[:4]))
|
if compute_checksum(frame[:-1]) == frame[-1]:
|
||||||
|
if frame_type == RUMBLE_TYPE_SLOT and frame[2] < UART_SLOT_COUNT:
|
||||||
if frame[1] == RUMBLE_TYPE_DECODED and checksum == frame[4]:
|
del self._buffer[: start + length]
|
||||||
rumble = (frame[2] / 255.0, frame[3] / 255.0)
|
return frame[2], frame[3] / 255.0, frame[4] / 255.0
|
||||||
del self._buffer[: start + 5]
|
if frame_type == RUMBLE_TYPE_DECODED:
|
||||||
return rumble
|
del self._buffer[: start + length]
|
||||||
|
return 0, frame[2] / 255.0, frame[3] / 255.0
|
||||||
|
|
||||||
del self._buffer[: start + 1]
|
del self._buffer[: start + 1]
|
||||||
|
|
||||||
|
|
@ -408,6 +422,7 @@ class SwitchUARTClient:
|
||||||
baud: int = UART_BAUD,
|
baud: int = UART_BAUD,
|
||||||
send_interval: float = 1.0 / 500.0,
|
send_interval: float = 1.0 / 500.0,
|
||||||
auto_send: bool = True,
|
auto_send: bool = True,
|
||||||
|
slot: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -416,7 +431,11 @@ class SwitchUARTClient:
|
||||||
send_interval: Minimum interval between sends in seconds (defaults to 500 Hz).
|
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
|
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).
|
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.uart = PicoUART(port, baud)
|
||||||
self.state = SwitchControllerState()
|
self.state = SwitchControllerState()
|
||||||
self.send_interval = max(0.0, send_interval)
|
self.send_interval = max(0.0, send_interval)
|
||||||
|
|
@ -432,7 +451,7 @@ class SwitchUARTClient:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if self.send_interval and (now - self._last_send) < self.send_interval:
|
if self.send_interval and (now - self._last_send) < self.send_interval:
|
||||||
return
|
return
|
||||||
self.uart.send_report(self.state.report)
|
self.uart.send_report(self.state.report, self.slot)
|
||||||
self._last_send = now
|
self._last_send = now
|
||||||
|
|
||||||
def _start_auto_send_thread(self) -> None:
|
def _start_auto_send_thread(self) -> None:
|
||||||
|
|
@ -517,10 +536,17 @@ class SwitchUARTClient:
|
||||||
|
|
||||||
def poll_rumble(self) -> Optional[Tuple[float, float]]:
|
def poll_rumble(self) -> Optional[Tuple[float, float]]:
|
||||||
"""
|
"""
|
||||||
Poll for decoded low/high rumble magnitudes normalized to 0.0-1.0.
|
Poll for decoded low/high rumble magnitudes normalized to 0.0-1.0 for
|
||||||
Returns None if no rumble frame was available.
|
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:
|
def close(self) -> None:
|
||||||
if self._auto_thread:
|
if self._auto_thread:
|
||||||
|
|
|
||||||
|
|
@ -681,8 +681,10 @@ void test_uart_parser_is_pure() {
|
||||||
packet.back() = static_cast<uint8_t>(packet.back() + packet[i]);
|
packet.back() = static_cast<uint8_t>(packet.back() + packet[i]);
|
||||||
}
|
}
|
||||||
ControllerState parsed{};
|
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");
|
"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 &&
|
expect(parsed.button_east && parsed.button_left_shoulder && parsed.dpad_down &&
|
||||||
parsed.dpad_left,
|
parsed.dpad_left,
|
||||||
"UART buttons or hat were parsed incorrectly");
|
"UART buttons or hat were parsed incorrectly");
|
||||||
|
|
@ -707,12 +709,44 @@ void test_uart_parser_is_pure() {
|
||||||
ControllerState unchanged{};
|
ControllerState unchanged{};
|
||||||
unchanged.button_system = true;
|
unchanged.button_system = true;
|
||||||
unchanged.left_stick_x = 123;
|
unchanged.left_stick_x = 123;
|
||||||
|
uint8_t unchanged_slot = 0xff;
|
||||||
packet.back() ^= 0xffu;
|
packet.back() ^= 0xffu;
|
||||||
expect(!switch_pro_apply_uart_packet(packet.data(), packet.size(),
|
expect(!switch_pro_apply_uart_packet(packet.data(), packet.size(),
|
||||||
unchanged),
|
unchanged, unchanged_slot),
|
||||||
"invalid UART checksum was accepted");
|
"invalid UART checksum was accepted");
|
||||||
expect(unchanged.button_system && unchanged.left_stick_x == 123,
|
expect(unchanged.button_system && unchanged.left_stick_x == 123 &&
|
||||||
"failed UART parse modified its output reference");
|
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() {
|
void test_motion_backpressure_retries_without_advancing_state() {
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ class RecordingUART:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.sent_imu: list[tuple[IMUSample, ...]] = []
|
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))
|
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
|
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:
|
def test_service_republishes_latest_imu_window(monkeypatch: MonkeyPatch) -> None:
|
||||||
uart = RecordingUART()
|
uart = RecordingUART()
|
||||||
controller = cast(sdl3.SDL_Gamepad, object())
|
controller = cast(sdl3.SDL_Gamepad, object())
|
||||||
ctx = bridge.ControllerContext(
|
ctx = bridge.ControllerContext(controller, 7, 0, "dualsense", "/dev/null")
|
||||||
controller, 7, 0, "dualsense", "/dev/null", cast(PicoUART, cast(object, uart))
|
links = {"/dev/null": bridge.UartLink("/dev/null", cast(PicoUART, cast(object, uart)))}
|
||||||
)
|
|
||||||
ctx.sensors_enabled = True
|
ctx.sensors_enabled = True
|
||||||
samples = [
|
samples = [
|
||||||
IMUSample(1, 2, 3, 4, 5, 6),
|
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)
|
args = Namespace(baud=UART_BAUD)
|
||||||
console = Console(file=StringIO())
|
console = Console(file=StringIO())
|
||||||
bridge.service_contexts(1.0, args, config, contexts, [], console)
|
bridge.service_contexts(1.0, args, config, contexts, links, console)
|
||||||
bridge.service_contexts(2.0, args, config, contexts, [], console)
|
bridge.service_contexts(2.0, args, config, contexts, links, console)
|
||||||
|
|
||||||
assert uart.sent_imu == [tuple(samples), tuple(samples)]
|
assert uart.sent_imu == [tuple(samples), tuple(samples)]
|
||||||
assert ctx.imu_samples == samples
|
assert ctx.imu_samples == samples
|
||||||
|
|
|
||||||
|
|
@ -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 struct
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -9,8 +9,10 @@ from switch_pico_bridge.switch_pico_uart import (
|
||||||
PicoUART,
|
PicoUART,
|
||||||
UART_HEADER,
|
UART_HEADER,
|
||||||
UART_PROTOCOL_VERSION,
|
UART_PROTOCOL_VERSION,
|
||||||
|
UART_SLOT_COUNT,
|
||||||
RUMBLE_HEADER,
|
RUMBLE_HEADER,
|
||||||
RUMBLE_TYPE_DECODED,
|
RUMBLE_TYPE_DECODED,
|
||||||
|
RUMBLE_TYPE_SLOT,
|
||||||
ACCEL_LSB_PER_G,
|
ACCEL_LSB_PER_G,
|
||||||
GYRO_LSB_PER_RAD_S,
|
GYRO_LSB_PER_RAD_S,
|
||||||
MS2_PER_G,
|
MS2_PER_G,
|
||||||
|
|
@ -35,7 +37,12 @@ class BufferedSerial:
|
||||||
self._data.extend(data)
|
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])
|
frame = bytes([RUMBLE_HEADER, RUMBLE_TYPE_DECODED, low, high])
|
||||||
return frame + bytes([compute_checksum(frame)])
|
return frame + bytes([compute_checksum(frame)])
|
||||||
|
|
||||||
|
|
@ -48,8 +55,8 @@ def make_uart(data: bytes = b"") -> tuple[PicoUART, BufferedSerial]:
|
||||||
return uart, serial_port
|
return uart, serial_port
|
||||||
|
|
||||||
|
|
||||||
def test_v2_frame_with_imu_samples():
|
def test_v3_frame_with_imu_samples():
|
||||||
"""V2 frame with 3 IMU samples should be 48 bytes with correct layout."""
|
"""V3 frame with 3 IMU samples should be 49 bytes with correct layout."""
|
||||||
r = SwitchReport(
|
r = SwitchReport(
|
||||||
buttons=0,
|
buttons=0,
|
||||||
imu_samples=[
|
imu_samples=[
|
||||||
|
|
@ -59,35 +66,49 @@ def test_v2_frame_with_imu_samples():
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
data = r.to_bytes()
|
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[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[2] == 44 # payload_len
|
||||||
assert data[10] == 3 # imu_count
|
assert data[3] == 0 # slot
|
||||||
|
assert data[11] == 3 # imu_count
|
||||||
# Verify checksum
|
# Verify checksum
|
||||||
assert data[-1] == compute_checksum(data[:-1])
|
assert data[-1] == compute_checksum(data[:-1])
|
||||||
# Verify first sample accel_x (int16 LE at byte 11)
|
# Verify first sample accel_x (int16 LE at byte 12)
|
||||||
ax0 = struct.unpack_from("<h", data, 11)[0]
|
ax0 = struct.unpack_from("<h", data, 12)[0]
|
||||||
assert ax0 == 100, f"Expected accel_x=100, got {ax0}"
|
assert ax0 == 100, f"Expected accel_x=100, got {ax0}"
|
||||||
# Verify first sample gyro_z (int16 LE at bytes 21-22)
|
# Verify first sample gyro_z (int16 LE at bytes 22-23)
|
||||||
gz0 = struct.unpack_from("<h", data, 21)[0]
|
gz0 = struct.unpack_from("<h", data, 22)[0]
|
||||||
assert gz0 == 0, f"Expected gyro_z=0, got {gz0}"
|
assert gz0 == 0, f"Expected gyro_z=0, got {gz0}"
|
||||||
|
|
||||||
|
|
||||||
def test_v2_frame_no_imu():
|
def test_v3_frame_no_imu():
|
||||||
"""V2 frame with no IMU samples should be 12 bytes."""
|
"""V3 frame with no IMU samples should be 13 bytes."""
|
||||||
r = SwitchReport(
|
r = SwitchReport(
|
||||||
buttons=0x0004, hat=SwitchDpad.CENTER, lx=128, ly=128, rx=128, ry=128
|
buttons=0x0004, hat=SwitchDpad.CENTER, lx=128, ly=128, rx=128, ry=128
|
||||||
)
|
)
|
||||||
data = r.to_bytes()
|
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[0] == UART_HEADER
|
||||||
assert data[1] == UART_PROTOCOL_VERSION
|
assert data[1] == UART_PROTOCOL_VERSION
|
||||||
assert data[2] == 8 # payload_len
|
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])
|
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():
|
def test_checksum_validation():
|
||||||
"""Checksum should match sum of all preceding bytes & 0xFF."""
|
"""Checksum should match sum of all preceding bytes & 0xFF."""
|
||||||
r = SwitchReport(buttons=0x0001)
|
r = SwitchReport(buttons=0x0001)
|
||||||
|
|
@ -96,7 +117,7 @@ def test_checksum_validation():
|
||||||
assert data[-1] == expected_checksum
|
assert data[-1] == expected_checksum
|
||||||
# Corrupt a byte and verify mismatch
|
# Corrupt a byte and verify mismatch
|
||||||
corrupted = bytearray(data)
|
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
|
recalculated = sum(corrupted[:-1]) & 0xFF
|
||||||
assert corrupted[-1] != recalculated, "Checksum should not match corrupted data"
|
assert corrupted[-1] != recalculated, "Checksum should not match corrupted data"
|
||||||
|
|
||||||
|
|
@ -126,21 +147,21 @@ def test_imu_sample_dataclass():
|
||||||
s2 = IMUSample(accel_x=99999)
|
s2 = IMUSample(accel_x=99999)
|
||||||
r = SwitchReport(imu_samples=[s2])
|
r = SwitchReport(imu_samples=[s2])
|
||||||
data = r.to_bytes()
|
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}"
|
assert ax == 32767, f"Expected clamped value 32767, got {ax}"
|
||||||
|
|
||||||
|
|
||||||
def test_backward_compat_switch_report():
|
def test_switch_report_payload_layout():
|
||||||
"""SwitchReport with no imu_samples produces valid v2 frame (backward compat)."""
|
"""Buttons and axes land at the documented v3 payload offsets."""
|
||||||
r = SwitchReport(buttons=0x000A, lx=200, ly=50, rx=128, ry=128)
|
r = SwitchReport(buttons=0x000A, lx=200, ly=50, rx=128, ry=128)
|
||||||
data = r.to_bytes()
|
data = r.to_bytes()
|
||||||
assert len(data) == 12
|
assert len(data) == 13
|
||||||
assert data[1] == 0x02 # still v2
|
assert data[1] == 0x03
|
||||||
# Buttons at bytes 3-4
|
# Buttons at bytes 4-5
|
||||||
buttons = struct.unpack_from("<H", data, 3)[0]
|
buttons = struct.unpack_from("<H", data, 4)[0]
|
||||||
assert buttons == 0x000A
|
assert buttons == 0x000A
|
||||||
# lx at byte 6
|
# lx at byte 7
|
||||||
assert data[6] == 200
|
assert data[7] == 200
|
||||||
|
|
||||||
|
|
||||||
def test_max_imu_samples_capped():
|
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)]
|
samples = [IMUSample(i, 0, 0, 0, 0, 0) for i in range(5)]
|
||||||
r = SwitchReport(imu_samples=samples)
|
r = SwitchReport(imu_samples=samples)
|
||||||
data = r.to_bytes()
|
data = r.to_bytes()
|
||||||
assert len(data) == 48 # 3 samples, not 5
|
assert len(data) == 49 # 3 samples, not 5
|
||||||
assert data[10] == 3
|
assert data[11] == 3
|
||||||
assert data[2] == 44 # payload_len for 3 samples
|
assert data[2] == 44 # payload_len for 3 samples
|
||||||
|
|
||||||
|
|
||||||
def test_decoded_rumble_frame_survives_fragmented_input():
|
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])
|
uart, serial_port = make_uart(frame[:3])
|
||||||
|
|
||||||
assert uart.read_rumble() is None
|
assert uart.read_rumble() is None
|
||||||
|
|
||||||
serial_port.feed(frame[3:])
|
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():
|
def test_decoded_rumble_frame_resynchronizes_after_garbage():
|
||||||
uart, _ = make_uart(b"\x00\xffnot-a-frame" + make_rumble_frame(12, 34))
|
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():
|
def test_decoded_rumble_frame_rejects_bad_checksum():
|
||||||
corrupted = bytearray(make_rumble_frame(25, 50))
|
corrupted = bytearray(make_rumble_frame(25, 50))
|
||||||
corrupted[-1] ^= 0x01
|
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():
|
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() == (0, 0.0, 0.0)
|
||||||
assert uart.read_rumble() == (1.0, 1.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))
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,22 @@ from switch_pico_bridge.switch_pico_uart import PicoUART, SwitchReport, UART_BAU
|
||||||
|
|
||||||
class RecordingUART:
|
class RecordingUART:
|
||||||
def __init__(self) -> None:
|
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:
|
def send_report(self, report: SwitchReport, slot: int = 0) -> None:
|
||||||
pass
|
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:
|
if not self.rumble:
|
||||||
return None
|
return None
|
||||||
return self.rumble.pop(0)
|
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:
|
def make_config() -> bridge.BridgeConfig:
|
||||||
return bridge.BridgeConfig(
|
return bridge.BridgeConfig(
|
||||||
interval=10.0,
|
interval=10.0,
|
||||||
|
|
@ -88,29 +93,87 @@ def test_repeated_constant_rumble_stays_active_until_idle_timeout(
|
||||||
|
|
||||||
uart = RecordingUART()
|
uart = RecordingUART()
|
||||||
controller = cast(sdl3.SDL_Gamepad, object())
|
controller = cast(sdl3.SDL_Gamepad, object())
|
||||||
ctx = bridge.ControllerContext(
|
ctx = bridge.ControllerContext(controller, 7, 0, "controller", "/dev/null")
|
||||||
controller,
|
links = make_links("/dev/null", uart)
|
||||||
7,
|
|
||||||
0,
|
|
||||||
"controller",
|
|
||||||
"/dev/null",
|
|
||||||
cast(PicoUART, cast(object, uart)),
|
|
||||||
)
|
|
||||||
contexts = {ctx.instance_id: ctx}
|
contexts = {ctx.instance_id: ctx}
|
||||||
args = Namespace(baud=UART_BAUD)
|
args = Namespace(baud=UART_BAUD)
|
||||||
console = Console(file=StringIO())
|
console = Console(file=StringIO())
|
||||||
|
|
||||||
magnitude = (64 / 255.0, 192 / 255.0)
|
magnitude = (0, 64 / 255.0, 192 / 255.0)
|
||||||
uart.rumble.append(magnitude)
|
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)
|
uart.rumble.append(magnitude)
|
||||||
bridge.service_contexts(1.7, 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, [], console)
|
bridge.service_contexts(1.71, args, make_config(), contexts, links, console)
|
||||||
|
|
||||||
assert calls == [(16448, 49344, 50), (16448, 49344, 50)]
|
assert calls == [(16448, 49344, 50), (16448, 49344, 50)]
|
||||||
assert ctx.rumble_active
|
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 calls[-1] == (0, 0, 0)
|
||||||
assert not ctx.rumble_active
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue