Compare commits

..

14 commits

Author SHA1 Message Date
fa8ccf0b12 Refresh normalized AIO firmware 2026-08-31 13:57:30 -06:00
11eb44d3d1 Normalize Wii and PS Move motion 2026-08-31 12:12:44 -06:00
d9f0761949 Align AIO release documentation 2026-08-31 09:15:28 -06:00
23a3cbade5 Assign colors per controller slot 2026-08-31 09:06:00 -06:00
4a73680bd2 Gate controller pairing behind BOOTSEL 2026-08-31 08:25:08 -06:00
fac919a798 Scale AIO transport to four controllers 2026-08-30 23:23:29 -06:00
18c27b06ae Verify and publish dual-controller AIO firmware 2026-08-30 22:40:03 -06:00
edf6ecaae1 Add dual-controller AIO USB transport 2026-08-30 20:54:59 -06:00
7941294a8d Isolate Switch Pro state by HID instance 2026-08-30 20:35:23 -06:00
04df6b88ad Remove rumble activity from Pico status LED 2026-08-30 16:30:10 -06:00
7bd3244006 Add Pico 2 W Bluetooth status diagnostics
Expose scanning, connecting, ready, and nonzero rumble activity on the onboard LED; publish the physically verified AIO firmware.
2026-08-30 16:21:24 -06:00
bce94d38ce Add stateful HD-rumble decoder with TinyUSB report normalization
Decode Nintendo HD-rumble words once in firmware into conventional low/high magnitudes and normalize stripped control SET_REPORT vs complete interrupt OUT reports through one path.
2026-08-29 22:19:31 -06:00
20c9c98f89 Document and publish Bluepad32 AIO firmware 2026-08-29 17:46:54 -06:00
f451e27f8c Add Pico 2 W Bluepad32 AIO backend 2026-08-29 17:37:48 -06:00
62 changed files with 6674 additions and 682 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Unified diffs require a one-character context marker on blank lines.
*.patch -whitespace

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
Switch-Fightstick
GP2040-CE
build
build-aio
debug
.pycache
*.egg-info

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "external/bluepad32"]
path = external/bluepad32
url = https://github.com/ricardoquesada/bluepad32.git

View file

@ -24,7 +24,20 @@ if (EXISTS ${picoVscode})
endif()
# ====================================================================================
option(SWITCH_PICO_LOG "Enable UART debug logging" OFF)
set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend")
set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32)
if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART"
AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
message(FATAL_ERROR
"Unknown SWITCH_PICO_INPUT_BACKEND='${SWITCH_PICO_INPUT_BACKEND}'. "
"Expected UART or BLUEPAD32.")
endif()
set(PICO_BOARD pico CACHE STRING "Board type")
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32"
AND NOT PICO_BOARD STREQUAL "pico2_w")
message(FATAL_ERROR
"SWITCH_PICO_INPUT_BACKEND=BLUEPAD32 requires PICO_BOARD=pico2_w")
endif()
# Pull in Raspberry Pi Pico SDK (must be before project)
include(pico_sdk_import.cmake)
@ -34,12 +47,63 @@ project(switch-pico C CXX ASM)
# Initialise the Raspberry Pi Pico SDK
pico_sdk_init()
# Configure BLUEPAD32 input backend if selected
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
# Ensure Python3 is available and execute patch preparation
find_package(Python3 REQUIRED COMPONENTS Interpreter)
set(BLUEPAD32_PREP_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/tools/prepare_bluepad32.py)
execute_process(
COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT}
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
RESULT_VARIABLE BLUEPAD32_PREP_RESULT
OUTPUT_VARIABLE BLUEPAD32_PREP_OUTPUT
ERROR_VARIABLE BLUEPAD32_PREP_ERROR
)
if(NOT BLUEPAD32_PREP_RESULT EQUAL 0)
message(FATAL_ERROR
"Failed to prepare Bluepad32: Patch application or validation failed. "
"Details: ${BLUEPAD32_PREP_ERROR}")
endif()
# Configure Bluepad32 include paths and subdirectory
set(BLUEPAD32_ROOT ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32)
set(BTSTACK_ROOT ${PICO_SDK_PATH}/lib/btstack)
include_directories(
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config
${BTSTACK_ROOT}/3rd-party/bluedroid/encoder/include
${BTSTACK_ROOT}/3rd-party/bluedroid/decoder/include
${BTSTACK_ROOT}/src
)
add_subdirectory(
${BLUEPAD32_ROOT}/src/components/bluepad32
${CMAKE_CURRENT_BINARY_DIR}/libbluepad32
)
endif()
# Add executable. Default name is the project name, version 0.1
add_executable(switch-pico
switch-pico.cpp
switch_pro_driver.cpp
switch_haptics.cpp
)
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
target_sources(switch-pico PRIVATE
bluepad32_input_backend.cpp
bootsel_pairing_button.cpp
)
target_compile_definitions(switch-pico PRIVATE
SWITCH_PICO_BLUEPAD32=1
SWITCH_PICO_HID_INSTANCE_COUNT=4
PICO_FLASH_ASSUME_CORE1_SAFE=0
)
else()
target_compile_definitions(switch-pico PRIVATE
SWITCH_PICO_HID_INSTANCE_COUNT=1
)
endif()
pico_set_program_name(switch-pico "switch-pico")
pico_set_program_version(switch-pico "0.1")
@ -57,6 +121,17 @@ target_link_libraries(switch-pico
hardware_uart
pico_rand
)
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
target_link_libraries(switch-pico
bluepad32
pico_cyw43_arch_none
pico_btstack_ble
pico_btstack_classic
pico_btstack_cyw43
pico_multicore
pico_flash
)
endif()
if (SWITCH_PICO_LOG)
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_LOG=1)

148
README.md
View file

@ -1,11 +1,12 @@
# Switch Pico Controller Bridge
Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB and a host bridge that forwards real gamepad input over UART (with rumble round-trip).
Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from Bluetooth controllers through Bluepad32.
## What you get
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a wired Switch Pro. Takes controller reports over UART1 and passes rumble from the Switch back over UART.
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): 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.
- **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).
- **Colour override** (`controller_color_config.h`): compile‑time RGB overrides for body/buttons/grips as seen by the Switch.
- **Color configuration** (`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.
## Quick start
1. Flash the Pico with `firmware/switch-pico.uf2` (or build your own) using BOOTSEL drag-and-drop (see “Manual UF2 flashing” below).
@ -14,12 +15,122 @@ Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB and a
4. Install the Python bridge (see “Python bridge”) and run `controller-uart-bridge --interactive`.
5. Connect the Pico to the Switch (dock USB-A or USB-C OTG); the Switch should see it as a wired Pro Controller.
## Pico 2 W all-in-one Bluetooth option
### Architecture
The AIO build accepts up to four concurrent Bluetooth controllers on a single Pico 2 W. TinyUSB and the four Switch report generators run on Core 0; Bluepad32, BTstack, and the CYW43439 radio run on Core 1. Each Bluetooth device index maps directly to one always-present USB Pro HID interface. Per-slot state snapshots and generation-tagged latest-value rumble mailboxes are the only cross-core data paths.
All four USB interfaces are always present to the Switch as separate Pro Controllers on one physical USB device. Input, motion, rumble, lifecycle, and displayed grip color remain isolated per slot.
### Build and flash
Initialize the pinned Bluepad32 dependency once:
```sh
git submodule update --init external/bluepad32
```
Build and flash a Pico 2 W in BOOTSEL mode:
```sh
python3 build.py --aio
```
This uses an isolated `build-aio/` CMake cache and publishes:
- `firmware/switch-pico-aio.elf`
- `firmware/switch-pico-aio.uf2`
The default `python3 build.py` command and `firmware/switch-pico.*` artifacts remain the UART/Pico build. The AIO build requires `PICO_BOARD=pico2_w`; it is not interchangeable with the original non-wireless Pico firmware.
Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0.
### Pairing up to four controllers
1. Flash and connect the Pico 2 W to the Switch.
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
3. Hold the Pico's BOOTSEL button for about two seconds, until the onboard LED starts double-blinking. This opens a 60-second pairing window.
4. Put a controller into Bluetooth pairing mode:
- DualSense: hold Create + PS.
- DualShock 4: hold Share + PS.
- Switch Pro: press its sync button.
- Xbox Bluetooth controller: hold its pair button.
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
5. Wait for the controller's player light to settle. Repeat step 4 for additional controllers while the window remains open. Holding BOOTSEL again extends the window by 60 seconds from that point.
Pairing order determines the initial USB slot assignment. Up to four controllers map 1:1 to the four emulated Switch Pro Controller interfaces.
Outside the BOOTSEL-open window, Bluetooth discovery and incoming connections are disabled. The Pico does not scan for or reconnect disconnected controllers while locked. Pairing keys still persist, but reconnecting a previously paired controller also requires opening the BOOTSEL window before pressing its normal power button.
### LED meanings and device state
The Pico 2 W onboard LED reports the overall Bluetooth state:
- **Double blink**: the bounded pairing window is open.
- **Fast blink**: a controller connection is still completing its handshake.
- **Solid**: at least one controller is active.
- **Slow blink**: no controller is active and pairing is locked.
- **Solid immediately after boot that never transitions**: Bluepad32 initialization did not complete; check firmware flashing and UART logs.
### Managing controller disconnect and reconnect
- **Disconnect a controller**: its slot immediately publishes neutral buttons, sticks, and motion. Other connected controllers are unaffected.
- **Reconnect a paired controller**: hold BOOTSEL until the LED double-blinks, then power on the controller normally.
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
- **Pairing window expires**: scanning and incoming connections stop; already connected controllers remain connected.
### Per-slot controller colors
Each AIO slot has one color shared by its emulated Switch Pro grips and its physical Bluetooth controller:
1. Blue `#0089EB`
2. Red `#E63946`
3. Yellow `#F6C945`
4. Green `#2ECC71`
When a controller becomes ready, RGB-capable devices such as DualSense and DualShock 4 receive a darker, more saturated RGB value derived automatically from the slot's Switch grip color. Controllers without an RGB light use player indicator 1, 2, 3, or 4 when Bluepad32 exposes player-LED control. Devices without either capability are left unchanged. Edit only the four grip colors in `controller_color_config.h`; rebuilding automatically recalibrates their lightbar colors.
### Controller capabilities
| Controller | Buttons/sticks | Rumble | Motion |
|---|---:|---:|---:|
| DualSense / DualShock 4 | Yes | Yes | Yes |
| Switch Pro / Joy-Con | Yes | Yes | Yes |
| PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration |
| Wii Remote | Mode-dependent | Yes | Accelerometer |
| 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU |
| Xbox Bluetooth controller | Yes | Yes | No hardware IMU |
Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and 8192 units per g in SDL-oriented axes before conversion to Nintendo samples. PS Move motion remains neutral until all model-specific calibration blocks have been received and validated; buttons and rumble remain available while calibration is pending or unavailable. The latest normalized sample is duplicated across the report's three nominal 5 ms slots and remains pending until a regular `0x30` USB report successfully consumes it.
### Rumble per controller
Rumble effects are per-slot and independent. The Switch sends rumble commands to a specific USB interface, and the Pico routes each command to the Bluetooth controller in the matching slot. Each slot has a critical-section-protected latest-value mailbox tagged with its connection generation; a newer pending command replaces the older one, and disconnect invalidates commands from the prior controller.
### Hardware validation
The four-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned independent controller slots, and buttons, sticks, calibrated motion, rumble, and disconnect isolation worked per controller. Fresh DualSense pairing through the BOOTSEL-open window has also been verified on hardware.
To reproduce the validation:
1. **Verify USB enumeration**: Connect the Pico 2 W to a USB host or analyzer. Confirm that four HID interfaces are present, using IN/OUT endpoint pairs `0x81/0x01` through `0x84/0x04`.
2. **Verify Bluetooth pairing**: Hold BOOTSEL until the LED double-blinks, put a controller into explicit pairing mode, and confirm its player light settles.
3. **Verify input on one controller**: Move sticks and press buttons; confirm only its assigned Switch slot changes.
4. **Verify input on two controllers**: Move the second controller independently and confirm the first controller's slot is unaffected.
5. **Verify the pairing gate**: Disconnect a controller and confirm it does not reconnect while locked. Open the BOOTSEL window, power it on, and confirm it can connect.
6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates.
7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output).
On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware.
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.
## Planned features
## Limitations
- No NFC/amiibo/IR support.
- Rumble is best-effort: it depends on the Switch sending rumble and SDL3 being able to drive haptics on your specific controller.
- Requires a host computer running the bridge; the Pico is not a Bluetooth/USB host for controllers.
- Rumble is best-effort: the UART build depends on SDL3 haptics; the AIO build depends on the connected controller's Bluepad32 rumble implementation.
- The UART firmware requires a host computer running the bridge. The Pico 2 W AIO firmware does not; it hosts controllers over Bluetooth, not USB.
## Uses
- **Remote couch co-op**: friends connect via Parsec while the host streams the Switch via a low-latency capture device (e.g., Magewell Pro Capture) and runs the bridge (see setup below).
@ -47,6 +158,18 @@ RUMBLE (force feedback)
-> [SDL3 haptics] -> [Any controller motors]
```
### HD rumble translation
Nintendo sends two stateful four-byte HD-rumble actuator words. Each word can carry full or relative high/low frequency and amplitude commands with up to three subsamples; amplitude uses a logarithmic curve. The Pico decodes both words once in `SwitchHapticsDecoder`, retains actuator state across packets, and reduces the result to conventional low/strong and high/weak motor magnitudes. SDL3 and Bluepad32 cannot reproduce the original linear-actuator frequencies or left/right spatial effects, but they receive the correct nonlinear band amplitudes.
The UART return frame carries the decoded result rather than raw HD-rumble bytes:
```text
0xBB, 0x02, 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.
## Hardware wiring (Pico)
- UART1 pins (fixed in firmware):
- **TX**: GPIO4 (Pico pin 6) → RX of your USB-serial adapter.
@ -126,20 +249,21 @@ The generated files are:
- `firmware/switch-pico.elf` and `firmware/switch-pico.uf2`, refreshed from the
corresponding `build/` artifacts after every successful build.
To customize the controller grip color while building, pass one of these mutually
exclusive options:
To assign one color to every emulated controller slot while building, pass one
of these mutually exclusive options:
```sh
# Use a random color for both grips
# Use one random color for all slots
python3 build.py --random-grip-color
# Use a specific six-digit RGB color for both grips
# Use one specific six-digit RGB color for all slots
python3 build.py --grip-color FF00AA
```
Both options update `controller_color_config.h` before building. With no color
option, that file is left unchanged. Run `python3 build.py --help` to see the
available command-line options.
Both options update all four slot definitions in
`controller_color_config.h` before building. With no color option, the
per-slot blue/red/yellow/green palette is left unchanged. Run
`python3 build.py --help` to see the available command-line options.
If the tools or artifacts are in non-default locations, use these environment
variables:

View file

@ -0,0 +1,83 @@
#ifndef _PICO_BTSTACK_BTSTACK_CONFIG_H
#define _PICO_BTSTACK_BTSTACK_CONFIG_H
// Based on Bluepad32's official Pico W configuration. ENABLE_BLE and
// ENABLE_CLASSIC are supplied by the corresponding Pico SDK BTstack targets.
#define ENABLE_LOG_INFO
#define ENABLE_LOG_ERROR
#define ENABLE_PRINTF_HEXDUMP
#define ENABLE_SCO_OVER_HCI
#ifdef ENABLE_BLE
#define ENABLE_GATT_CLIENT_PAIRING
#define ENABLE_L2CAP_LE_CREDIT_BASED_FLOW_CONTROL_MODE
#define ENABLE_LE_CENTRAL
#define ENABLE_LE_DATA_LENGTH_EXTENSION
#define ENABLE_LE_PERIPHERAL
#define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION
#define ENABLE_LE_RESOLVING_LIST
#define ENABLE_LE_SECURE_CONNECTIONS
#else
#error "BP32: ENABLE_BLE should be defined"
#endif
#ifdef ENABLE_CLASSIC
#define ENABLE_L2CAP_ENHANCED_RETRANSMISSION_MODE
#define ENABLE_GOEP_L2CAP
#else
#error "BP32: ENABLE_CLASSIC should be defined"
#endif
#if defined(ENABLE_CLASSIC) && defined(ENABLE_BLE)
#define ENABLE_CROSS_TRANSPORT_KEY_DERIVATION
#endif
#define HCI_OUTGOING_PRE_BUFFER_SIZE 4
#define HCI_ACL_PAYLOAD_SIZE (1691 + 4)
#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4
#define MAX_NR_AVDTP_CONNECTIONS 1
#define MAX_NR_AVDTP_STREAM_ENDPOINTS 1
#define MAX_NR_AVRCP_CONNECTIONS 2
#define MAX_NR_BNEP_CHANNELS 1
#define MAX_NR_BNEP_SERVICES 1
#define MAX_NR_BTSTACK_LINK_KEY_DB_MEMORY_ENTRIES 2
#define MAX_NR_GATT_CLIENTS 4
#define MAX_NR_HCI_CONNECTIONS 4
#define MAX_NR_HID_HOST_CONNECTIONS 4
#define MAX_NR_HIDS_CLIENTS 4
#define MAX_NR_HFP_CONNECTIONS 1
#define MAX_NR_L2CAP_CHANNELS 10
#define MAX_NR_L2CAP_SERVICES 5
#define MAX_NR_RFCOMM_CHANNELS 1
#define MAX_NR_RFCOMM_MULTIPLEXERS 1
#define MAX_NR_RFCOMM_SERVICES 1
#define MAX_NR_SERVICE_RECORD_ITEMS 4
#define MAX_NR_SM_LOOKUP_ENTRIES 3
#define MAX_NR_WHITELIST_ENTRIES 16
#define MAX_NR_LE_DEVICE_DB_ENTRIES 16
// Keep controller buffers and controller-to-host flow control enabled to avoid
// overrunning the shared CYW43 bus.
#define MAX_NR_CONTROLLER_ACL_BUFFERS 3
#define MAX_NR_CONTROLLER_SCO_PACKETS 3
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
#define HCI_HOST_ACL_PACKET_LEN 1024
#define HCI_HOST_ACL_PACKET_NUM 3
#define HCI_HOST_SCO_PACKET_LEN 120
#define HCI_HOST_SCO_PACKET_NUM 3
// Persistent Classic and BLE pairing databases use Pico flash-backed TLV.
#define NVM_NUM_DEVICE_DB_ENTRIES 16
#define NVM_NUM_LINK_KEYS 16
// Bluepad32 does not provide malloc to BTstack.
#define MAX_ATT_DB_SIZE 512
#define HAVE_EMBEDDED_TIME_MS
#define HAVE_ASSERT
#define HCI_RESET_RESEND_TIMEOUT_MS 1000
#define ENABLE_SOFTWARE_AES128
#define ENABLE_MICRO_ECC_FOR_LE_SECURE_CONNECTIONS
#define HAVE_BTSTACK_STDIN
#endif // _PICO_BTSTACK_BTSTACK_CONFIG_H

View file

@ -0,0 +1,13 @@
#pragma once
// The AIO firmware exposes one fixed Bluepad32 device slot per USB interface.
#define CONFIG_BLUEPAD32_MAX_DEVICES 4
#define CONFIG_BLUEPAD32_MAX_ALLOWLIST 4
#define CONFIG_BLUEPAD32_GAP_SECURITY 1
#define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1
#define CONFIG_BLUEPAD32_PLATFORM_CUSTOM
#define CONFIG_TARGET_PICO_W
// 2 == Info
#define CONFIG_BLUEPAD32_LOG_LEVEL 2

706
bluepad32_input_backend.cpp Normal file
View file

@ -0,0 +1,706 @@
#include "bluepad32_input_backend.h"
#include <limits.h>
#include <stddef.h>
#include <string.h>
#include <btstack_run_loop.h>
#include <pico/critical_section.h>
#include <pico/cyw43_arch.h>
#include <pico/flash.h>
#include <pico/multicore.h>
#include <pico/stdlib.h>
#include <uni.h>
namespace {
constexpr uint16_t kStickMidpoint = 32768;
constexpr int32_t kAxisMinimum = -512;
constexpr int32_t kAxisMaximum = 511;
constexpr int32_t kTriggerMaximum = 1023;
constexpr int32_t kTriggerThreshold = (kTriggerMaximum * 35) / 100;
constexpr uint16_t kRumbleDurationMs = 50;
constexpr uint32_t kRumblePollIntervalMs = 5;
constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT;
constexpr uint32_t kPairingWindowDurationMs = 60000;
static_assert(kSlotCount == 4);
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount);
enum class ConnectionStatus {
Initializing,
Scanning,
Connecting,
Ready,
};
enum class ConnectionPolicyState {
Uninitialized,
Open,
Locked,
Paused,
FailedClosed,
};
struct RumbleEnvelope {
uint8_t slot;
uint32_t connection_generation;
SwitchRumbleOutput rumble;
};
struct BackendSlot {
SwitchInputState state;
// Non-null with active=false is a connected device still becoming ready.
uni_hid_device_t* device;
uint32_t state_generation;
uint32_t connection_generation;
bool active;
bool rumble_pending;
RumbleEnvelope pending_rumble;
};
critical_section_t g_state_lock;
BackendSlot g_slots[kSlotCount];
// These acknowledgement generations and the pairing request producer are only
// used by Core 0. The request is transferred under the cross-core state lock.
uint32_t g_consumed_generation[kSlotCount]{};
uint32_t g_last_snapshot_generation[kSlotCount]{};
bool g_pairing_window_requested = false;
bool g_initialized = false;
bool g_started = false;
// These fields are only read or written by Core 1 / BTstack.
btstack_timer_source_t g_rumble_timer{};
ConnectionStatus g_connection_status = ConnectionStatus::Initializing;
ConnectionPolicyState g_connection_policy_state =
ConnectionPolicyState::Uninitialized;
uint32_t g_pairing_window_deadline_ms = 0;
uint16_t g_status_led_tick = 0;
bool g_pairing_window_open = false;
bool g_status_led_on = false;
SwitchInputState make_neutral_state() {
SwitchInputState state{};
state.lx = kStickMidpoint;
state.ly = kStickMidpoint;
state.rx = kStickMidpoint;
state.ry = kStickMidpoint;
return state;
}
bool valid_slot(uint8_t slot) {
return slot < kSlotCount;
}
bool has_free_slot() {
critical_section_enter_blocking(&g_state_lock);
bool free_slot = false;
for (const BackendSlot& slot : g_slots) {
free_slot = free_slot || slot.device == nullptr;
}
critical_section_exit(&g_state_lock);
return free_slot;
}
bool has_active_controller() {
critical_section_enter_blocking(&g_state_lock);
bool active_controller = false;
for (const BackendSlot& slot : g_slots) {
active_controller = active_controller || slot.active;
}
critical_section_exit(&g_state_lock);
return active_controller;
}
int slot_for_device(const uni_hid_device_t* device) {
if (device == nullptr) {
return -1;
}
const int slot = uni_hid_device_get_idx_for_instance(device);
return slot >= 0 && slot < kSlotCount ? slot : -1;
}
void apply_slot_lighting(uint8_t slot_index, uni_hid_device_t* device) {
const SwitchRgbColor color =
switch_pro_get_slot_light_color(slot_index);
if (device->report_parser.set_lightbar_color != nullptr) {
device->report_parser.set_lightbar_color(
device, color.red, color.green, color.blue);
} else if (device->report_parser.set_player_leds != nullptr) {
device->report_parser.set_player_leds(
device, static_cast<uint8_t>(1u << slot_index));
}
}
ConnectionStatus compute_connection_status() {
critical_section_enter_blocking(&g_state_lock);
bool all_ready = true;
bool any_connecting = false;
for (const BackendSlot& slot : g_slots) {
const bool has_device = slot.device != nullptr;
all_ready = all_ready && slot.active && has_device;
any_connecting = any_connecting || (!slot.active && has_device);
}
critical_section_exit(&g_state_lock);
if (all_ready) {
return ConnectionStatus::Ready;
}
return any_connecting ? ConnectionStatus::Connecting
: ConnectionStatus::Scanning;
}
void publish_device_state(uint8_t slot, uni_hid_device_t* device,
const SwitchInputState& state) {
critical_section_enter_blocking(&g_state_lock);
BackendSlot& target = g_slots[slot];
if (target.active && target.device == device) {
target.state = state;
++target.state_generation;
}
critical_section_exit(&g_state_lock);
}
void publish_all_neutral() {
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
slot.state = make_neutral_state();
slot.device = nullptr;
slot.active = false;
slot.rumble_pending = false;
++slot.state_generation;
++slot.connection_generation;
}
critical_section_exit(&g_state_lock);
g_connection_status = ConnectionStatus::Initializing;
g_connection_policy_state = ConnectionPolicyState::FailedClosed;
g_pairing_window_open = false;
g_status_led_tick = 0;
}
constexpr int32_t clamp_axis(int32_t value) {
if (value < kAxisMinimum) {
return kAxisMinimum;
}
if (value > kAxisMaximum) {
return kAxisMaximum;
}
return value;
}
constexpr uint16_t scale_stick(int32_t value) {
value = clamp_axis(value);
if (value <= 0) {
return static_cast<uint16_t>(
(static_cast<int64_t>(value - kAxisMinimum) * kStickMidpoint) / -kAxisMinimum);
}
return static_cast<uint16_t>(
kStickMidpoint + (static_cast<int64_t>(value) * (UINT16_MAX - kStickMidpoint)) / kAxisMaximum);
}
constexpr int16_t clamp_int16(int64_t value) {
if (value < INT16_MIN) {
return INT16_MIN;
}
if (value > INT16_MAX) {
return INT16_MAX;
}
return static_cast<int16_t>(value);
}
constexpr int64_t divide_round_nearest(int64_t numerator, int64_t denominator) {
if (numerator >= 0) {
return (numerator + denominator / 2) / denominator;
}
return -((-numerator + denominator / 2) / denominator);
}
constexpr int16_t convert_accel(int64_t q13_value) {
return clamp_int16(q13_value / 2);
}
constexpr int16_t convert_gyro(int64_t q10_value) {
constexpr int64_t kNumeratorScale = 13371;
constexpr int64_t kDenominator = 1024 * 936;
return clamp_int16(divide_round_nearest(q10_value * kNumeratorScale, kDenominator));
}
static_assert(scale_stick(-512) == 0);
static_assert(scale_stick(0) == 32768);
static_assert(scale_stick(511) == UINT16_MAX);
static_assert(convert_accel(8192) == 4096);
static_assert(convert_accel(-8192) == -4096);
static_assert(convert_gyro(1024) == 14);
static_assert(convert_gyro(-1024) == -14);
bool has_motion(const uni_gamepad_t& gamepad) {
for (size_t i = 0; i < 3; ++i) {
if (gamepad.accel[i] != 0 || gamepad.gyro[i] != 0) {
return true;
}
}
return false;
}
SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
SwitchInputState state = make_neutral_state();
state.dpad_up = (gamepad.dpad & DPAD_UP) != 0;
state.dpad_down = (gamepad.dpad & DPAD_DOWN) != 0;
state.dpad_left = (gamepad.dpad & DPAD_LEFT) != 0;
state.dpad_right = (gamepad.dpad & DPAD_RIGHT) != 0;
// Bluepad32's A/B/X/Y are positional: south/east/west/north.
state.button_b = (gamepad.buttons & BUTTON_A) != 0;
state.button_a = (gamepad.buttons & BUTTON_B) != 0;
state.button_y = (gamepad.buttons & BUTTON_X) != 0;
state.button_x = (gamepad.buttons & BUTTON_Y) != 0;
state.button_l = (gamepad.buttons & BUTTON_SHOULDER_L) != 0;
state.button_r = (gamepad.buttons & BUTTON_SHOULDER_R) != 0;
state.button_zl = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 || gamepad.brake >= kTriggerThreshold;
state.button_zr = (gamepad.buttons & BUTTON_TRIGGER_R) != 0 || gamepad.throttle >= kTriggerThreshold;
state.button_l3 = (gamepad.buttons & BUTTON_THUMB_L) != 0;
state.button_r3 = (gamepad.buttons & BUTTON_THUMB_R) != 0;
state.button_minus = (gamepad.misc_buttons & MISC_BUTTON_SELECT) != 0;
state.button_plus = (gamepad.misc_buttons & MISC_BUTTON_START) != 0;
state.button_home = (gamepad.misc_buttons & MISC_BUTTON_SYSTEM) != 0;
state.button_capture = (gamepad.misc_buttons & MISC_BUTTON_CAPTURE) != 0;
state.lx = scale_stick(gamepad.axis_x);
state.ly = scale_stick(gamepad.axis_y);
state.rx = scale_stick(gamepad.axis_rx);
state.ry = scale_stick(gamepad.axis_ry);
if (has_motion(gamepad)) {
// Dependency patches normalize both arrays to SDL3 PlayStation axes.
SwitchImuSample sample{};
sample.accel_x = convert_accel(-static_cast<int64_t>(gamepad.accel[2]));
sample.accel_y = convert_accel(-static_cast<int64_t>(gamepad.accel[0]));
sample.accel_z = convert_accel(gamepad.accel[1]);
sample.gyro_x = convert_gyro(-static_cast<int64_t>(gamepad.gyro[2]));
sample.gyro_y = convert_gyro(-static_cast<int64_t>(gamepad.gyro[0]));
sample.gyro_z = convert_gyro(gamepad.gyro[1]);
state.imu_sample_count = 3;
for (SwitchImuSample& destination : state.imu_samples) {
destination = sample;
}
}
return state;
}
bool pairing_window_active_at(uint32_t now_ms) {
return g_pairing_window_open &&
static_cast<int32_t>(now_ms - g_pairing_window_deadline_ms) < 0;
}
bool update_pairing_window(uint32_t now_ms) {
critical_section_enter_blocking(&g_state_lock);
const bool requested = g_pairing_window_requested;
g_pairing_window_requested = false;
critical_section_exit(&g_state_lock);
if (requested) {
g_pairing_window_open = true;
g_pairing_window_deadline_ms = now_ms + kPairingWindowDurationMs;
g_status_led_tick = 0;
return true;
}
if (g_pairing_window_open && !pairing_window_active_at(now_ms)) {
g_pairing_window_open = false;
g_status_led_tick = 0;
return true;
}
return false;
}
void apply_connection_policy(uint32_t now_ms) {
const bool free_slot = has_free_slot();
const bool pairing_open = pairing_window_active_at(now_ms);
if ((!free_slot &&
g_connection_policy_state == ConnectionPolicyState::Paused) ||
(free_slot && pairing_open &&
g_connection_policy_state == ConnectionPolicyState::Open) ||
(free_slot && !pairing_open &&
g_connection_policy_state == ConnectionPolicyState::Locked)) {
return;
}
uni_bt_allow_incoming_connections(false);
uni_bt_stop_scanning_unsafe();
if (!free_slot) {
g_connection_policy_state = ConnectionPolicyState::Paused;
return;
}
if (!pairing_open) {
g_connection_policy_state = ConnectionPolicyState::Locked;
return;
}
// Use Bluepad32's normal pairing/autoconnect path while the physical
// BOOTSEL gesture has explicitly opened the pairing window.
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
g_connection_policy_state = ConnectionPolicyState::Open;
}
void update_status_led() {
++g_status_led_tick;
bool led_on = false;
if (pairing_window_active_at(btstack_run_loop_get_time_ms())) {
const uint16_t phase = g_status_led_tick % 200;
led_on = phase < 20 || (phase >= 40 && phase < 60);
} else if (g_connection_status == ConnectionStatus::Connecting) {
led_on = (g_status_led_tick % 40) < 20;
} else if (g_connection_status == ConnectionStatus::Initializing ||
has_active_controller()) {
led_on = true;
} else {
led_on = (g_status_led_tick % 200) < 100;
}
if (led_on != g_status_led_on) {
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, led_on);
g_status_led_on = led_on;
}
}
void process_rumble_timer(btstack_timer_source_t* timer) {
const uint32_t now_ms = btstack_run_loop_get_time_ms();
if (update_pairing_window(now_ms)) {
apply_connection_policy(now_ms);
}
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
RumbleEnvelope envelope{};
uni_hid_device_t* device = nullptr;
bool dispatch = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.rumble_pending) {
envelope = slot.pending_rumble;
slot.rumble_pending = false;
dispatch = envelope.slot == slot_index && slot.active &&
slot.device != nullptr &&
envelope.connection_generation ==
slot.connection_generation;
if (dispatch) {
device = slot.device;
}
}
critical_section_exit(&g_state_lock);
if (dispatch &&
device->report_parser.play_dual_rumble != nullptr) {
device->report_parser.play_dual_rumble(
device, 0, kRumbleDurationMs,
envelope.rumble.high_frequency_magnitude,
envelope.rumble.low_frequency_magnitude);
}
}
update_status_led();
btstack_run_loop_set_timer(timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(timer);
}
void recompute_connection_status() {
const uint32_t now_ms = btstack_run_loop_get_time_ms();
update_pairing_window(now_ms);
g_connection_status = compute_connection_status();
g_status_led_tick = 0;
apply_connection_policy(now_ms);
}
void platform_init(int argc, const char** argv) {
(void)argc;
(void)argv;
}
void platform_on_init_complete() {
// Discovery and incoming connections remain disabled until BOOTSEL opens
// the bounded pairing window.
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(&g_rumble_timer);
recompute_connection_status();
}
uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name,
uint16_t cod, uint8_t rssi) {
(void)addr;
(void)name;
(void)cod;
(void)rssi;
return has_free_slot() &&
g_connection_policy_state == ConnectionPolicyState::Open
? UNI_ERROR_SUCCESS
: UNI_ERROR_IGNORE_DEVICE;
}
void platform_on_device_connected(uni_hid_device_t* device) {
if (device == nullptr) {
return;
}
if (g_connection_policy_state != ConnectionPolicyState::Open) {
uni_hid_device_disconnect(device);
return;
}
const int slot_index = slot_for_device(device);
if (slot_index < 0) {
return;
}
bool tracked_connection = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (!slot.active && slot.device == nullptr) {
slot.device = device;
slot.rumble_pending = false;
tracked_connection = true;
} else {
tracked_connection = slot.device == device;
}
critical_section_exit(&g_state_lock);
if (tracked_connection) {
recompute_connection_status();
}
}
void platform_on_device_disconnected(uni_hid_device_t* device) {
const int slot_index = slot_for_device(device);
if (slot_index < 0) {
return;
}
bool disconnected_tracked_device = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.device == device) {
if (slot.active) {
slot.state = make_neutral_state();
++slot.state_generation;
}
slot.device = nullptr;
slot.active = false;
slot.rumble_pending = false;
++slot.connection_generation;
disconnected_tracked_device = true;
}
critical_section_exit(&g_state_lock);
if (disconnected_tracked_device) {
recompute_connection_status();
}
}
uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
if (device == nullptr || !uni_hid_device_is_gamepad(device)) {
return UNI_ERROR_INVALID_CONTROLLER;
}
const int slot_index = slot_for_device(device);
if (slot_index < 0) {
return UNI_ERROR_NO_SLOTS;
}
bool occupied_mismatch = false;
bool became_active = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
occupied_mismatch = slot.device != nullptr && slot.device != device;
if (!occupied_mismatch) {
slot.device = device;
if (!slot.active) {
slot.state = make_neutral_state();
slot.active = true;
slot.rumble_pending = false;
++slot.state_generation;
became_active = true;
}
}
critical_section_exit(&g_state_lock);
if (occupied_mismatch) {
return UNI_ERROR_NO_SLOTS;
}
if (became_active) {
apply_slot_lighting(static_cast<uint8_t>(slot_index), device);
}
recompute_connection_status();
return UNI_ERROR_SUCCESS;
}
void platform_on_controller_data(uni_hid_device_t* device, uni_controller_t* controller) {
const int slot_index = slot_for_device(device);
if (slot_index < 0 || controller == nullptr ||
controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) {
return;
}
publish_device_state(static_cast<uint8_t>(slot_index), device,
map_gamepad(controller->gamepad));
}
const uni_property_t* platform_get_property(uni_property_idx_t index) {
(void)index;
return nullptr;
}
void platform_on_oob_event(uni_platform_oob_event_t event, void* data) {
(void)event;
(void)data;
}
uni_platform* get_platform() {
static uni_platform platform = {
"Switch Pico",
platform_init,
platform_on_init_complete,
platform_on_device_discovered,
platform_on_device_connected,
platform_on_device_disconnected,
platform_on_device_ready,
nullptr,
platform_on_controller_data,
platform_get_property,
platform_on_oob_event,
nullptr,
nullptr,
};
return &platform;
}
[[noreturn]] void halt_wireless_backend() {
publish_all_neutral();
while (true) {
tight_loop_contents();
}
}
[[noreturn]] void core1_main() {
if (!flash_safe_execute_core_init()) {
halt_wireless_backend();
}
if (cyw43_arch_init() != 0) {
halt_wireless_backend();
}
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, true);
g_status_led_on = true;
uni_platform_set_custom(get_platform());
if (uni_init(0, nullptr) != 0) {
halt_wireless_backend();
}
btstack_run_loop_execute();
while (true) {
tight_loop_contents();
}
}
} // namespace
void bluepad32_input_backend_init() {
if (g_initialized) {
return;
}
critical_section_init(&g_state_lock);
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
BackendSlot& slot = g_slots[slot_index];
slot = {};
slot.state = make_neutral_state();
slot.pending_rumble.slot = slot_index;
g_consumed_generation[slot_index] = 0;
g_last_snapshot_generation[slot_index] = 0;
}
g_pairing_window_requested = false;
g_connection_status = ConnectionStatus::Initializing;
g_connection_policy_state = ConnectionPolicyState::Uninitialized;
g_pairing_window_deadline_ms = 0;
g_pairing_window_open = false;
g_initialized = true;
}
void bluepad32_input_backend_start() {
if (!g_initialized) {
bluepad32_input_backend_init();
}
if (g_started) {
return;
}
// Core 0 services USB from flash while Core 1 owns BTstack. Register both
// cores before either side can initiate a flash-backed BTstack TLV write.
if (!flash_safe_execute_core_init()) {
g_connection_policy_state = ConnectionPolicyState::FailedClosed;
return;
}
g_started = true;
multicore_launch_core1(core1_main);
}
void bluepad32_input_backend_open_pairing_window() {
if (!g_initialized) {
bluepad32_input_backend_init();
}
critical_section_enter_blocking(&g_state_lock);
g_pairing_window_requested = true;
critical_section_exit(&g_state_lock);
}
bool bluepad32_input_backend_snapshot(uint8_t slot_index, SwitchInputState* out) {
if (out == nullptr || !valid_slot(slot_index)) {
return false;
}
if (!g_initialized) {
*out = make_neutral_state();
return false;
}
critical_section_enter_blocking(&g_state_lock);
*out = g_slots[slot_index].state;
const bool controller_active = g_slots[slot_index].active;
const uint32_t generation = g_slots[slot_index].state_generation;
critical_section_exit(&g_state_lock);
if (generation == g_consumed_generation[slot_index]) {
out->imu_sample_count = 0;
}
g_last_snapshot_generation[slot_index] = generation;
return controller_active;
}
void bluepad32_input_backend_report_sent(uint8_t slot_index) {
if (!g_initialized || !valid_slot(slot_index)) {
return;
}
g_consumed_generation[slot_index] = g_last_snapshot_generation[slot_index];
}
void bluepad32_input_backend_queue_rumble(uint8_t slot_index,
const SwitchRumbleOutput& rumble) {
if (!g_initialized || !valid_slot(slot_index)) {
return;
}
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device != nullptr) {
slot.pending_rumble = {slot_index, slot.connection_generation, rumble};
slot.rumble_pending = true;
}
critical_section_exit(&g_state_lock);
}

16
bluepad32_input_backend.h Normal file
View file

@ -0,0 +1,16 @@
#pragma once
#include <stdint.h>
#include "switch_haptics.h"
#include "switch_pro_driver.h"
constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4;
void bluepad32_input_backend_init();
void bluepad32_input_backend_start();
void bluepad32_input_backend_open_pairing_window();
bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out);
void bluepad32_input_backend_report_sent(uint8_t slot);
void bluepad32_input_backend_queue_rumble(uint8_t slot,
const SwitchRumbleOutput& rumble);

View file

@ -0,0 +1,99 @@
#include "bootsel_pairing_button.h"
#include "hardware/gpio.h"
#include "hardware/structs/ioqspi.h"
#include "hardware/structs/sio.h"
#include "pico/flash.h"
#include "pico/time.h"
#if PICO_RP2350
#include "hardware/regs/sio.h"
#endif
namespace {
constexpr uint32_t kPollIntervalMs = 100;
constexpr uint32_t kFlashSafeTimeoutMs = 100;
constexpr uint32_t kQspiCsPinIndex = 1;
BootselPairingButtonHoldFsm g_hold_fsm;
uint32_t g_last_sample_ms = 0;
// QSPI CSn sampling adapted from awalol/DS5Dongle's button_functions.cpp:
// https://github.com/awalol/DS5Dongle/blob/master/src/button_functions.cpp
// Copyright (c) 2026 awalol; used under the MIT License.
//
// This callback and everything it executes while CSn is floated must remain in
// SRAM or be an inlined hardware-register operation. In particular, do not add
// logging or ordinary flash-backed data access here.
void __no_inline_not_in_flash_func(read_bootsel_callback)(void* parameter) {
auto* pressed = static_cast<bool*>(parameter);
hw_write_masked(
&ioqspi_hw->io[kQspiCsPinIndex].ctrl,
GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB,
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS);
for (volatile uint32_t delay = 0; delay < 1000; ++delay) {
}
#if PICO_RP2350
*pressed =
(sio_hw->gpio_hi_in & SIO_GPIO_HI_IN_QSPI_CSN_BITS) == 0;
#else
*pressed = (sio_hw->gpio_hi_in & (1u << kQspiCsPinIndex)) == 0;
#endif
hw_write_masked(
&ioqspi_hw->io[kQspiCsPinIndex].ctrl,
GPIO_OVERRIDE_NORMAL << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB,
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS);
}
BootselPairingButtonSample sample_bootsel() {
bool pressed = false;
const int result = flash_safe_execute(read_bootsel_callback, &pressed,
kFlashSafeTimeoutMs);
if (result != PICO_OK) {
return BootselPairingButtonSample::kUnread;
}
return pressed ? BootselPairingButtonSample::kPressed
: BootselPairingButtonSample::kReleased;
}
} // namespace
bool BootselPairingButtonHoldFsm::update(
BootselPairingButtonSample sample) {
if (sample == BootselPairingButtonSample::kUnread) {
return false;
}
if (sample == BootselPairingButtonSample::kReleased) {
pressed_samples_ = 0;
hold_reported_ = false;
return false;
}
if (hold_reported_) {
return false;
}
++pressed_samples_;
if (pressed_samples_ < kHoldSamples) {
return false;
}
hold_reported_ = true;
return true;
}
bool bootsel_pairing_button_task() {
const uint32_t now_ms =
static_cast<uint32_t>(to_ms_since_boot(get_absolute_time()));
if (now_ms - g_last_sample_ms < kPollIntervalMs) {
return false;
}
g_last_sample_ms = now_ms;
return g_hold_fsm.update(sample_bootsel());
}

23
bootsel_pairing_button.h Normal file
View file

@ -0,0 +1,23 @@
#pragma once
#include <cstdint>
enum class BootselPairingButtonSample : uint8_t {
kUnread,
kReleased,
kPressed,
};
class BootselPairingButtonHoldFsm {
public:
static constexpr uint8_t kHoldSamples = 20;
bool update(BootselPairingButtonSample sample);
private:
uint8_t pressed_samples_ = 0;
bool hold_reported_ = false;
};
// Polls BOOTSEL at 10 Hz. Returns true once when a 20-sample hold completes.
bool bootsel_pairing_button_task();

110
build.py
View file

@ -12,20 +12,20 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "controller_color_config.h"
BUILD_DIR = SCRIPT_DIR / "build"
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf"
AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2"
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
MACROS = (
"SWITCH_COLOR_LEFT_GRIP_R",
"SWITCH_COLOR_LEFT_GRIP_G",
"SWITCH_COLOR_LEFT_GRIP_B",
"SWITCH_COLOR_RIGHT_GRIP_R",
"SWITCH_COLOR_RIGHT_GRIP_G",
"SWITCH_COLOR_RIGHT_GRIP_B",
MACROS = tuple(
f"SWITCH_COLOR_SLOT_{slot}_{component}"
for slot in range(1, 5)
for component in ("R", "G", "B")
)
def parse_args():
@ -34,16 +34,21 @@ def parse_args():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Default behavior leaves controller_color_config.h unchanged.",
)
parser.add_argument(
"--aio",
action="store_true",
help="Build and flash the Pico 2 W Bluepad32 all-in-one firmware.",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--random-grip-color",
action="store_true",
help="Randomize both grip colors before building.",
help="Assign one random color to every emulated controller slot.",
)
group.add_argument(
"--grip-color",
metavar="RRGGBB",
help="Set both grip colors to the provided hex value.",
help="Set every emulated controller slot to the provided hex color.",
)
return parser.parse_args()
@ -76,7 +81,7 @@ def update_grip_colors(rgb_hex):
sys.exit(1)
return updated
values = (r, g, b, r, g, b)
values = (r, g, b) * 4
for macro, val in zip(MACROS, values):
text = replace(macro, val, text)
@ -111,41 +116,68 @@ def resolve_picotool():
sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n")
sys.exit(1)
def build():
def build(
aio,
build_dir,
elf_path,
uf2_path,
firmware_elf_path,
firmware_uf2_path,
):
if aio:
run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")])
definitions = [
"-DSWITCH_PICO_LOG=OFF",
"-DPICO_BOARD=pico2_w",
"-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32",
]
else:
definitions = [
"-DSWITCH_PICO_LOG=OFF",
"-DPICO_BOARD=pico",
"-DSWITCH_PICO_INPUT_BACKEND=UART",
]
run_cmd(
[
"cmake",
"-S",
str(SCRIPT_DIR),
"-B",
str(BUILD_DIR),
"-DSWITCH_PICO_LOG=OFF",
str(build_dir),
*definitions,
]
)
run_cmd(["cmake", "--build", str(BUILD_DIR)])
run_cmd(["cmake", "--build", str(build_dir)])
missing_artifacts = [path for path in (ELF_PATH, UF2_PATH) if not path.is_file()]
missing_artifacts = [
path for path in (elf_path, uf2_path) if not path.is_file()
]
if missing_artifacts:
missing = ", ".join(str(path) for path in missing_artifacts)
sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n")
sys.exit(1)
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(ELF_PATH, FIRMWARE_ELF_PATH)
shutil.copy2(UF2_PATH, FIRMWARE_UF2_PATH)
shutil.copy2(elf_path, firmware_elf_path)
shutil.copy2(uf2_path, firmware_uf2_path)
print(f"Built ELF: {ELF_PATH}")
print(f"Built UF2: {UF2_PATH}")
print(f"Copied ELF: {FIRMWARE_ELF_PATH}")
print(f"Copied UF2: {FIRMWARE_UF2_PATH}")
print(f"Built ELF: {elf_path}")
print(f"Built UF2: {uf2_path}")
print(f"Copied ELF: {firmware_elf_path}")
print(f"Copied UF2: {firmware_uf2_path}")
def flash():
def flash(elf_path, allow_elf_override):
picotool = resolve_picotool()
if not ELF_PATH.exists():
sys.stderr.write(
f"Error: Cannot find ELF at {ELF_PATH}. Set ELF_PATH to override.\n"
)
if not elf_path.exists():
if allow_elf_override:
sys.stderr.write(
f"Error: Cannot find ELF at {elf_path}. Set ELF_PATH to override.\n"
)
else:
sys.stderr.write(f"Error: Cannot find ELF at {elf_path}.\n")
sys.exit(1)
run_cmd([str(picotool), "load", str(ELF_PATH), "-fx"])
run_cmd([str(picotool), "load", str(elf_path), "-fx"])
def main():
args = parse_args()
@ -164,8 +196,28 @@ def main():
update_grip_colors(color)
print(f"Grip color set to #{color} in {CONFIG_FILE.name}")
build()
flash()
if args.aio:
build_dir = AIO_BUILD_DIR
elf_path = AIO_BUILD_DIR / "switch-pico.elf"
uf2_path = AIO_BUILD_DIR / "switch-pico.uf2"
firmware_elf_path = AIO_FIRMWARE_ELF_PATH
firmware_uf2_path = AIO_FIRMWARE_UF2_PATH
else:
build_dir = BUILD_DIR
elf_path = ELF_PATH
uf2_path = UF2_PATH
firmware_elf_path = FIRMWARE_ELF_PATH
firmware_uf2_path = FIRMWARE_UF2_PATH
build(
args.aio,
build_dir,
elf_path,
uf2_path,
firmware_elf_path,
firmware_uf2_path,
)
flash(elf_path, allow_elf_override=not args.aio)
if __name__ == "__main__":
main()

View file

@ -1,25 +1,31 @@
// Optional override for Switch Pro colour fields.
// Copy/modify the values below and rebuild to change how the controller appears on the Switch.
// Each value is an 8-bit RGB component.
// Compile-time Switch grip colors. Physical controller lightbar values are
// derived automatically; each value here is an 8-bit RGB component.
#pragma once
// Body shell colour
// Body shell color
#define SWITCH_COLOR_BODY_R 0x1B
#define SWITCH_COLOR_BODY_G 0x1B
#define SWITCH_COLOR_BODY_B 0x1D
// Face/button cluster colour
// Face/button cluster color
#define SWITCH_COLOR_BUTTON_R 0xFF
#define SWITCH_COLOR_BUTTON_G 0xFF
#define SWITCH_COLOR_BUTTON_B 0xFF
// Left grip colour
#define SWITCH_COLOR_LEFT_GRIP_R 0x00
#define SWITCH_COLOR_LEFT_GRIP_G 0x89
#define SWITCH_COLOR_LEFT_GRIP_B 0xEB
// Per-slot Switch grip colors: blue, red, yellow, green.
#define SWITCH_COLOR_SLOT_1_R 0x00
#define SWITCH_COLOR_SLOT_1_G 0x89
#define SWITCH_COLOR_SLOT_1_B 0xEB
// Right grip colour
#define SWITCH_COLOR_RIGHT_GRIP_R 0x00
#define SWITCH_COLOR_RIGHT_GRIP_G 0x89
#define SWITCH_COLOR_RIGHT_GRIP_B 0xEB
#define SWITCH_COLOR_SLOT_2_R 0xE6
#define SWITCH_COLOR_SLOT_2_G 0x39
#define SWITCH_COLOR_SLOT_2_B 0x46
#define SWITCH_COLOR_SLOT_3_R 0xF6
#define SWITCH_COLOR_SLOT_3_G 0xC9
#define SWITCH_COLOR_SLOT_3_B 0x45
#define SWITCH_COLOR_SLOT_4_R 0x2E
#define SWITCH_COLOR_SLOT_4_G 0xCC
#define SWITCH_COLOR_SLOT_4_B 0x71

1
external/bluepad32 vendored Submodule

@ -0,0 +1 @@
Subproject commit 6efa7123fe8badf5a40ad1205743a80b31c00ea4

BIN
firmware/switch-pico-aio.elf Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,624 @@
diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_imu.h b/src/components/bluepad32/include/parser/uni_hid_parser_imu.h
new file mode 100644
index 0000000..dbd3024
--- /dev/null
+++ b/src/components/bluepad32/include/parser/uni_hid_parser_imu.h
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: Apache-2.0
+// Fixed-point IMU normalization helpers shared by controller parsers.
+
+#ifndef UNI_HID_PARSER_IMU_H
+#define UNI_HID_PARSER_IMU_H
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <string.h>
+
+#define UNI_IMU_ACCEL_RES_PER_G 8192
+#define UNI_IMU_GYRO_RES_PER_DEG_S 1024
+
+typedef struct {
+ int32_t accel[3];
+ int32_t gyro[3];
+} uni_imu_fixed_sample_t;
+
+static inline void uni_imu_normalize_wii_accel(int16_t x,
+ int16_t y,
+ int16_t z,
+ int32_t out[3]) {
+ // SDL's Wii convention is (-X, Z, Y), with about 100 raw counts per g.
+ out[0] = -(int32_t)x * UNI_IMU_ACCEL_RES_PER_G / 100;
+ out[1] = (int32_t)z * UNI_IMU_ACCEL_RES_PER_G / 100;
+ out[2] = (int32_t)y * UNI_IMU_ACCEL_RES_PER_G / 100;
+}
+
+typedef enum {
+ UNI_PSMOVE_IMU_MODEL_ZCM1,
+ UNI_PSMOVE_IMU_MODEL_ZCM2,
+} uni_psmove_imu_model_t;
+
+typedef enum {
+ UNI_PSMOVE_CALIBRATION_IGNORED,
+ UNI_PSMOVE_CALIBRATION_INCOMPLETE,
+ UNI_PSMOVE_CALIBRATION_COMPLETE,
+ UNI_PSMOVE_CALIBRATION_INVALID,
+} uni_psmove_calibration_result_t;
+
+#define UNI_PSMOVE_CALIBRATION_REPORT_SIZE 49
+#define UNI_PSMOVE_ZCM1_CALIBRATION_SIZE 143
+#define UNI_PSMOVE_ZCM2_CALIBRATION_SIZE 96
+
+typedef struct {
+ uint8_t blob[UNI_PSMOVE_ZCM1_CALIBRATION_SIZE];
+ uint8_t received_blocks;
+ bool valid;
+} uni_psmove_imu_calibration_t;
+
+static inline int32_t uni_psmove_decode_calibration_value(
+ const uni_psmove_imu_calibration_t* calibration,
+ uni_psmove_imu_model_t model,
+ uint8_t offset) {
+ const uint16_t value =
+ (uint16_t)calibration->blob[offset] |
+ ((uint16_t)calibration->blob[offset + 1] << 8u);
+ return model == UNI_PSMOVE_IMU_MODEL_ZCM1
+ ? (int32_t)value - 0x8000
+ : (int32_t)(int16_t)value;
+}
+
+static inline int32_t uni_psmove_decode_input_value(
+ uni_psmove_imu_model_t model,
+ uint16_t value) {
+ return model == UNI_PSMOVE_IMU_MODEL_ZCM1
+ ? (int32_t)value - 0x8000
+ : (int32_t)(int16_t)value;
+}
+
+static inline void uni_psmove_get_accel_bounds(
+ const uni_psmove_imu_calibration_t* calibration,
+ uni_psmove_imu_model_t model,
+ uint8_t axis,
+ int32_t* low,
+ int32_t* high) {
+ static const uint8_t zcm1_low_offsets[3] = {0x0a, 0x24, 0x14};
+ static const uint8_t zcm1_high_offsets[3] = {0x16, 0x1e, 0x08};
+ static const uint8_t zcm2_low_offsets[3] = {0x08, 0x16, 0x24};
+ static const uint8_t zcm2_high_offsets[3] = {0x02, 0x10, 0x1e};
+ const uint8_t* low_offsets =
+ model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_low_offsets
+ : zcm2_low_offsets;
+ const uint8_t* high_offsets =
+ model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_high_offsets
+ : zcm2_high_offsets;
+ *low = uni_psmove_decode_calibration_value(calibration, model,
+ low_offsets[axis]);
+ *high = uni_psmove_decode_calibration_value(calibration, model,
+ high_offsets[axis]);
+}
+
+static inline void uni_psmove_get_gyro_calibration(
+ const uni_psmove_imu_calibration_t* calibration,
+ uni_psmove_imu_model_t model,
+ uint8_t axis,
+ int32_t* offset,
+ int32_t* divisor,
+ int32_t* full_scale) {
+ static const uint8_t zcm1_bias_offsets[3] = {0x2a, 0x2c, 0x2e};
+ static const uint8_t zcm1_high_offsets[3] = {0x46, 0x50, 0x5a};
+ static const uint8_t zcm2_bias_offsets[3] = {0x26, 0x28, 0x2a};
+ static const uint8_t zcm2_low_offsets[3] = {0x42, 0x4a, 0x52};
+ static const uint8_t zcm2_high_offsets[3] = {0x30, 0x38, 0x40};
+
+ if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
+ *offset = uni_psmove_decode_calibration_value(
+ calibration, model, zcm1_bias_offsets[axis]);
+ const int32_t high = uni_psmove_decode_calibration_value(
+ calibration, model, zcm1_high_offsets[axis]);
+ *divisor = high - *offset;
+ // ZCM1 gyro points are measured at +80 RPM = +480 degrees/s.
+ *full_scale = 480 * UNI_IMU_GYRO_RES_PER_DEG_S;
+ return;
+ }
+
+ *offset = uni_psmove_decode_calibration_value(
+ calibration, model, zcm2_bias_offsets[axis]);
+ const int32_t low = uni_psmove_decode_calibration_value(
+ calibration, model, zcm2_low_offsets[axis]);
+ const int32_t high = uni_psmove_decode_calibration_value(
+ calibration, model, zcm2_high_offsets[axis]);
+ *divisor = high - low;
+ // ZCM2 points span -90 to +90 RPM = 1080 degrees/s total.
+ *full_scale = 1080 * UNI_IMU_GYRO_RES_PER_DEG_S;
+}
+
+static inline bool uni_psmove_validate_calibration(
+ const uni_psmove_imu_calibration_t* calibration,
+ uni_psmove_imu_model_t model) {
+ for (uint8_t axis = 0; axis < 3; ++axis) {
+ int32_t low;
+ int32_t high;
+ uni_psmove_get_accel_bounds(calibration, model, axis, &low, &high);
+ if (high <= low) {
+ return false;
+ }
+
+ int32_t offset;
+ int32_t divisor;
+ int32_t full_scale;
+ uni_psmove_get_gyro_calibration(calibration, model, axis, &offset,
+ &divisor, &full_scale);
+ (void)offset;
+ (void)full_scale;
+ if (divisor <= 0) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static inline uni_psmove_calibration_result_t
+uni_psmove_add_calibration_report(
+ uni_psmove_imu_calibration_t* calibration,
+ uni_psmove_imu_model_t model,
+ const uint8_t* report,
+ uint16_t len) {
+ if (calibration == NULL || report == NULL ||
+ len < UNI_PSMOVE_CALIBRATION_REPORT_SIZE || report[0] != 0x10) {
+ return UNI_PSMOVE_CALIBRATION_IGNORED;
+ }
+
+ size_t destination;
+ size_t source;
+ uint8_t block_mask;
+ if (report[1] == 0x00) {
+ destination = 0;
+ source = 0;
+ block_mask = 0x01;
+ } else if (model == UNI_PSMOVE_IMU_MODEL_ZCM1 && report[1] == 0x01) {
+ destination = UNI_PSMOVE_CALIBRATION_REPORT_SIZE;
+ source = 2;
+ block_mask = 0x02;
+ } else if (model == UNI_PSMOVE_IMU_MODEL_ZCM1 && report[1] == 0x82) {
+ destination = 2 * UNI_PSMOVE_CALIBRATION_REPORT_SIZE - 2;
+ source = 2;
+ block_mask = 0x04;
+ } else if (model == UNI_PSMOVE_IMU_MODEL_ZCM2 && report[1] == 0x81) {
+ destination = UNI_PSMOVE_CALIBRATION_REPORT_SIZE;
+ source = 2;
+ block_mask = 0x02;
+ } else {
+ calibration->valid = false;
+ return UNI_PSMOVE_CALIBRATION_INVALID;
+ }
+
+ memcpy(calibration->blob + destination, report + source,
+ UNI_PSMOVE_CALIBRATION_REPORT_SIZE - source);
+ calibration->received_blocks |= block_mask;
+
+ const uint8_t required_blocks =
+ model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? 0x07 : 0x03;
+ if ((calibration->received_blocks & required_blocks) != required_blocks) {
+ return UNI_PSMOVE_CALIBRATION_INCOMPLETE;
+ }
+
+ calibration->valid =
+ uni_psmove_validate_calibration(calibration, model);
+ return calibration->valid ? UNI_PSMOVE_CALIBRATION_COMPLETE
+ : UNI_PSMOVE_CALIBRATION_INVALID;
+}
+
+static inline int32_t uni_psmove_scale_accel(int32_t raw,
+ int32_t low,
+ int32_t high) {
+ const int64_t numerator =
+ (int64_t)(raw - low) * (2 * UNI_IMU_ACCEL_RES_PER_G);
+ return (int32_t)(numerator / (high - low)) - UNI_IMU_ACCEL_RES_PER_G;
+}
+
+static inline int32_t uni_imu_clamp_i64(int64_t value) {
+ if (value > INT32_MAX) {
+ return INT32_MAX;
+ }
+ if (value < INT32_MIN) {
+ return INT32_MIN;
+ }
+ return (int32_t)value;
+}
+
+static inline int32_t uni_psmove_scale_gyro(int32_t raw,
+ int32_t offset,
+ int32_t divisor,
+ int32_t full_scale) {
+ const int64_t scaled =
+ ((int64_t)(raw - offset) * full_scale) / divisor;
+ return uni_imu_clamp_i64(scaled);
+}
+
+static inline bool uni_psmove_normalize_imu(
+ uni_psmove_imu_model_t model,
+ const uni_psmove_imu_calibration_t* calibration,
+ const uint16_t accel_first[3],
+ const uint16_t accel_second[3],
+ const uint16_t gyro_first[3],
+ const uint16_t gyro_second[3],
+ uni_imu_fixed_sample_t* out) {
+ if (out == NULL) {
+ return false;
+ }
+ memset(out, 0, sizeof(*out));
+ if (calibration == NULL || !calibration->valid || accel_first == NULL ||
+ accel_second == NULL || gyro_first == NULL || gyro_second == NULL) {
+ return false;
+ }
+
+ for (uint8_t axis = 0; axis < 3; ++axis) {
+ int32_t accel = uni_psmove_decode_input_value(model,
+ accel_first[axis]);
+ int32_t gyro = uni_psmove_decode_input_value(model,
+ gyro_first[axis]);
+ if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
+ accel = (accel + uni_psmove_decode_input_value(
+ model, accel_second[axis])) /
+ 2;
+ gyro = (gyro + uni_psmove_decode_input_value(
+ model, gyro_second[axis])) /
+ 2;
+ }
+
+ int32_t low;
+ int32_t high;
+ uni_psmove_get_accel_bounds(calibration, model, axis, &low, &high);
+ out->accel[axis] = uni_psmove_scale_accel(accel, low, high);
+
+ int32_t offset;
+ int32_t divisor;
+ int32_t full_scale;
+ uni_psmove_get_gyro_calibration(calibration, model, axis, &offset,
+ &divisor, &full_scale);
+ out->gyro[axis] = uni_psmove_scale_gyro(
+ gyro, offset, divisor, full_scale);
+ }
+ return true;
+}
+
+#endif // UNI_HID_PARSER_IMU_H
diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h
index 6af4969..0aebb0a 100644
--- a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h
+++ b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h
@@ -14,6 +14,8 @@
void uni_hid_parser_psmove_setup(struct uni_hid_device_s* d);
void uni_hid_parser_psmove_init_report(struct uni_hid_device_s* d);
void uni_hid_parser_psmove_parse_input_report(struct uni_hid_device_s* d, const uint8_t* report, uint16_t len);
+void uni_hid_parser_psmove_parse_feature_report(
+ struct uni_hid_device_s* d, const uint8_t* report, uint16_t len);
void uni_hid_parser_psmove_set_lightbar_color(struct uni_hid_device_s* d, uint8_t r, uint8_t g, uint8_t b);
void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d,
uint16_t start_delay_ms,
diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c
index ea063b8..7670caf 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c
@@ -297,17 +297,17 @@ void uni_hid_parser_ds4_parse_feature_report(uni_hid_device_t* d, const uint8_t*
// Set gyroscope calibration and normalization parameters.
// Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s.
speed_2x = r->gyro_speed_plus + r->gyro_speed_minus;
- ins->gyro_calib_data[0].bias = 0;
+ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias;
ins->gyro_calib_data[0].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[0].sens_denom =
abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias);
- ins->gyro_calib_data[1].bias = 0;
+ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias;
ins->gyro_calib_data[1].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[1].sens_denom =
abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias);
- ins->gyro_calib_data[2].bias = 0;
+ ins->gyro_calib_data[2].bias = r->gyro_roll_bias;
ins->gyro_calib_data[2].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[2].sens_denom =
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
@@ -476,7 +476,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor
// Gyro
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
- int32_t raw_data = (int16_t)r->gyro[i];
+ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom);
ctl->gamepad.gyro[i] = calib_data;
@@ -484,7 +484,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor
// Accel
for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) {
- int32_t raw_data = (int16_t)r->accel[i];
+ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom);
ctl->gamepad.accel[i] = calib_data;
diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c
index a22ef26..3d5ecef 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c
@@ -487,17 +487,17 @@ void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t*
// Set gyroscope calibration and normalization parameters.
// Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s.
speed_2x = r->gyro_speed_plus + r->gyro_speed_minus;
- ins->gyro_calib_data[0].bias = 0;
+ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias;
ins->gyro_calib_data[0].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[0].sens_denom =
abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias);
- ins->gyro_calib_data[1].bias = 0;
+ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias;
ins->gyro_calib_data[1].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[1].sens_denom =
abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias);
- ins->gyro_calib_data[2].bias = 0;
+ ins->gyro_calib_data[2].bias = r->gyro_roll_bias;
ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[2].sens_denom =
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
// Gyro
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
- int32_t raw_data = (int16_t)r->gyro[i];
+ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom);
ctl->gamepad.gyro[i] = calib_data;
@@ -630,7 +630,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
// Accel
for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) {
- int32_t raw_data = (int16_t)r->accel[i];
+ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom);
ctl->gamepad.accel[i] = calib_data;
diff --git a/src/components/bluepad32/parser/uni_hid_parser_psmove.c b/src/components/bluepad32/parser/uni_hid_parser_psmove.c
index 0265f93..5c0f2bb 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_psmove.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_psmove.c
@@ -8,6 +8,7 @@
*/
#include "parser/uni_hid_parser_psmove.h"
+#include "parser/uni_hid_parser_imu.h"
#include <string.h>
@@ -27,11 +28,6 @@ typedef enum psmove_fsm {
PSMOVE_FSM_LED_UPDATED, // LED updated
} psmove_fsm_t;
-typedef enum psmove_model {
- PSMOVE_MODEL_UNK,
- PSMOVE_MODEL_ZCM1,
- PSMOVE_MODEL_ZCM2,
-} psmove_model_t;
typedef enum {
PSMOVE_STATE_RUMBLE_DISABLED,
@@ -41,9 +37,10 @@ typedef enum {
// psmove_instance_t represents data used by the psmove driver instance.
typedef struct psmove_instance_s {
- psmove_model_t model;
+ uni_psmove_imu_model_t model;
psmove_fsm_t state;
uint8_t led_rgb[3];
+ uni_psmove_imu_calibration_t imu_calibration;
btstack_timer_source_t rumble_timer_duration;
btstack_timer_source_t rumble_timer_delayed_start;
@@ -127,6 +124,7 @@ static void psmove_send_output_report(uni_hid_device_t* d, psmove_output_report_
static void on_psmove_set_rumble_on(btstack_timer_source_t* ts);
static void on_psmove_set_rumble_off(btstack_timer_source_t* ts);
static void psmove_play_dual_rumble_now(uni_hid_device_t* d, uint16_t duration_ms, uint8_t magnitude);
+static void psmove_request_calibration_report(uni_hid_device_t* d);
void uni_hid_parser_psmove_init_report(uni_hid_device_t* d) {
uni_controller_t* ctl = &d->controller;
@@ -154,6 +152,7 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t
}
uni_controller_t* ctl = &d->controller;
+ psmove_instance_t* ins = get_psmove_instance(d);
// Buttons
if (r->buttons[0] & 0x01)
@@ -187,18 +186,39 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t
ctl->gamepad.throttle = r->trigger * 4;
- ctl->gamepad.accel[0] = r->accel_x;
- ctl->gamepad.accel[1] = r->accel_y;
- ctl->gamepad.accel[2] = r->accel_z;
-
- ctl->gamepad.gyro[0] = r->gyro_x;
- ctl->gamepad.gyro[1] = r->gyro_y;
- ctl->gamepad.gyro[2] = r->gyro_z;
+ const uint16_t accel_first[3] = {r->accel_x, r->accel_y, r->accel_z};
+ const uint16_t accel_second[3] = {
+ r->accel_x2, r->accel_y2, r->accel_z2};
+ const uint16_t gyro_first[3] = {r->gyro_x, r->gyro_y, r->gyro_z};
+ const uint16_t gyro_second[3] = {
+ r->gyro_x2, r->gyro_y2, r->gyro_z2};
+ uni_imu_fixed_sample_t motion;
+ if (uni_psmove_normalize_imu(
+ ins->model, &ins->imu_calibration, accel_first, accel_second,
+ gyro_first, gyro_second, &motion)) {
+ memcpy(ctl->gamepad.accel, motion.accel, sizeof(motion.accel));
+ memcpy(ctl->gamepad.gyro, motion.gyro, sizeof(motion.gyro));
+ }
if (r->battery <= 5)
ctl->battery = r->battery * 51;
}
+void uni_hid_parser_psmove_parse_feature_report(
+ uni_hid_device_t* d, const uint8_t* report, uint16_t len) {
+ psmove_instance_t* ins = get_psmove_instance(d);
+ const uni_psmove_calibration_result_t result =
+ uni_psmove_add_calibration_report(
+ &ins->imu_calibration, ins->model, report, len);
+ if (result == UNI_PSMOVE_CALIBRATION_INCOMPLETE) {
+ psmove_request_calibration_report(d);
+ } else if (result == UNI_PSMOVE_CALIBRATION_COMPLETE) {
+ logi("psmove: IMU calibration ready\n");
+ } else if (result == UNI_PSMOVE_CALIBRATION_INVALID) {
+ loge("psmove: invalid IMU calibration; motion disabled\n");
+ }
+}
+
void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d,
uint16_t start_delay_ms,
uint16_t duration_ms,
@@ -261,25 +281,34 @@ void uni_hid_parser_psmove_setup(struct uni_hid_device_s* d) {
switch (d->product_id) {
case ZCM1_PID:
- ins->model = PSMOVE_MODEL_ZCM1;
+ ins->model = UNI_PSMOVE_IMU_MODEL_ZCM1;
logi("psmove: Detected ZCM1 model\n");
break;
case ZCM2_PID:
- ins->model = PSMOVE_MODEL_ZCM2;
+ ins->model = UNI_PSMOVE_IMU_MODEL_ZCM2;
logi("psmove: Detected ZCM2 model\n");
break;
default:
- loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", ins->model);
- ins->model = PSMOVE_MODEL_ZCM1;
+ loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", d->product_id);
+ ins->model = UNI_PSMOVE_IMU_MODEL_ZCM1;
break;
}
+ psmove_request_calibration_report(d);
uni_hid_device_set_ready_complete(d);
}
//
// Helpers
//
+static void psmove_request_calibration_report(uni_hid_device_t* d) {
+ static const uint8_t report[] = {
+ ((HID_MESSAGE_TYPE_GET_REPORT << 4) | HID_REPORT_TYPE_FEATURE),
+ 0x10,
+ };
+ uni_hid_device_send_ctrl_report(d, report, sizeof(report));
+}
+
static psmove_instance_t* get_psmove_instance(uni_hid_device_t* d) {
return (psmove_instance_t*)&d->parser_data[0];
}
diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/components/bluepad32/parser/uni_hid_parser_switch.c
index 599fc35..c72f056 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_switch.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_switch.c
@@ -51,7 +51,8 @@ static const int16_t DEFAULT_ACCEL_OFFSET = 0;
static const int16_t DEFAULT_ACCEL_SCALE = 16384;
static const int16_t DEFAULT_GYRO_OFFSET = 0;
static const int16_t DEFAULT_GYRO_SCALE = 13371;
-#define SWITCH_IMU_PREC_RANGE_SCALE 1000
+#define SWITCH_IMU_GYRO_RES_PER_DEG_S 1024
+#define SWITCH_IMU_ACCEL_RES_PER_G 8192
#define SWITCH_FACTORY_IMU_CAL_DATA_SIZE 24
static const uint16_t SWITCH_FACTORY_IMU_CAL_DATA_ADDR = 0x6020;
@@ -823,19 +824,26 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
switch_instance_t* ins = get_switch_instance(d);
uni_controller_t* ctl = &d->controller;
- int accel[3];
- int gyro[3];
+ int32_t accel[3];
+ int32_t gyro[3];
for (int i = 0; i < 3; i++) {
- if (ins->imu_cal_accel_divisor[i] == 0)
- accel[i] = r->accel[i];
- else
- accel[i] = (r->accel[i] * ins->cal_accel.scale[i]) / ins->imu_cal_accel_divisor[i];
- gyro[i] = mult_frac((SWITCH_IMU_PREC_RANGE_SCALE * (r->gyro[i] - ins->cal_gyro.offset[i])),
- ins->cal_gyro.scale[i], ins->imu_cal_gyro_divisor[i]);
+ if (ins->imu_cal_accel_divisor[i] == 0) {
+ accel[i] = r->accel[i] * 2;
+ } else {
+ accel[i] = mult_frac(r->accel[i], 4 * SWITCH_IMU_ACCEL_RES_PER_G, ins->imu_cal_accel_divisor[i]);
+ }
+
+ if (ins->imu_cal_gyro_divisor[i] == 0) {
+ gyro[i] = mult_frac(r->gyro[i], 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S, DEFAULT_GYRO_SCALE);
+ } else {
+ gyro[i] = mult_frac(r->gyro[i] - ins->cal_gyro.offset[i],
+ 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S,
+ ins->imu_cal_gyro_divisor[i]);
+ }
}
- // Right joycon has Y and Z axes negated.
+ // Right Joy-Con has native Y and Z axes negated.
if (ins->controller_type == SWITCH_CONTROLLER_TYPE_JCR) {
accel[1] = -accel[1];
accel[2] = -accel[2];
@@ -843,10 +851,13 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
gyro[2] = -gyro[2];
}
- for (int i = 0; i < 3; i++) {
- ctl->gamepad.accel[i] = accel[i];
- ctl->gamepad.gyro[i] = gyro[i];
- }
+ // Match SDL3's PlayStation-oriented sensor coordinate convention.
+ ctl->gamepad.accel[0] = -accel[1];
+ ctl->gamepad.accel[1] = accel[2];
+ ctl->gamepad.accel[2] = -accel[0];
+ ctl->gamepad.gyro[0] = -gyro[1];
+ ctl->gamepad.gyro[1] = gyro[2];
+ ctl->gamepad.gyro[2] = -gyro[0];
}
// Process 0x30 input report: SWITCH_INPUT_IMU_DATA
diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c
index be2103e..4819639 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_wii.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_wii.c
@@ -19,6 +19,7 @@
#endif // ENABLE_EEPROM_DUMP
#include "parser/uni_hid_parser_wii.h"
+#include "parser/uni_hid_parser_imu.h"
#include "controller/uni_controller.h"
#include "hid_usage.h"
@@ -585,9 +586,7 @@ static void process_drm_ka(uni_hid_device_t* d, const uint8_t* report, uint16_t
uni_controller_t* ctl = &d->controller;
- ctl->gamepad.accel[0] = sx;
- ctl->gamepad.accel[1] = sy;
- ctl->gamepad.accel[2] = sz;
+ uni_imu_normalize_wii_accel(sx, sy, sz, ctl->gamepad.accel);
// Dpad works as dpad, useful to navigate menus.
ctl->gamepad.dpad |= (report[1] & 0x01) ? DPAD_DOWN : 0;
diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad32/uni_hid_device.c
index 67841e8..9fe7134 100644
--- a/src/components/bluepad32/uni_hid_device.c
+++ b/src/components/bluepad32/uni_hid_device.c
@@ -655,6 +655,7 @@ void uni_hid_device_guess_controller_type_from_pid_vid(uni_hid_device_t* d) {
d->report_parser.setup = uni_hid_parser_psmove_setup;
d->report_parser.init_report = uni_hid_parser_psmove_init_report;
d->report_parser.parse_input_report = uni_hid_parser_psmove_parse_input_report;
+ d->report_parser.parse_feature_report = uni_hid_parser_psmove_parse_feature_report;
d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color;
d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble;
logi("Device detected as PS Move: 0x%02x\n", type);

View file

@ -10,7 +10,6 @@ from .switch_pico_uart import ( # noqa: F401
SwitchDpad,
SwitchUARTClient,
axis_to_stick,
decode_rumble,
discover_serial_ports,
first_serial_port,
str_to_dpad,
@ -24,7 +23,6 @@ __all__ = [
"discover_serial_ports",
"first_serial_port",
"axis_to_stick",
"decode_rumble",
"str_to_dpad",
"trigger_to_button",
]

View file

@ -1,16 +1,17 @@
#!/usr/bin/env python3
"""
Bridge multiple SDL2 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``:
- Host -> Pico : 0xAA, buttons (LE16), hat, lx, ly, rx, ry
- Pico -> Host : 0xBB, 0x01, 8 rumble bytes, checksum (sum of first 10 bytes)
- Host -> Pico : UART v2 controller report
- Pico -> Host : 0xBB, 0x02, low-frequency magnitude,
high-frequency magnitude, checksum
Features inspired by ``host/controller_bridge.py``:
- Multiple controllers paired to multiple UART ports
- Rich-powered interactive pairing UI
- Adjustable send frequency, deadzone, and trigger thresholds
- Rumble feedback delivered to SDL2 controllers
- Rumble feedback delivered to SDL3 controllers
"""
from __future__ import annotations
@ -48,15 +49,12 @@ from .switch_pico_uart import (
SwitchReport,
axis_to_stick,
str_to_dpad,
decode_rumble,
discover_serial_ports,
trigger_to_button,
)
RUMBLE_IDLE_TIMEOUT = 0.25 # seconds without packets before forcing rumble off
RUMBLE_STUCK_TIMEOUT = 0.60 # continuous same-energy rumble will be stopped after this
RUMBLE_MIN_ACTIVE = 0.40 # below this, rumble is treated as off/noise
RUMBLE_SCALE = 1.0
RUMBLE_DURATION_MS = 50
CONTROLLER_DB_URL_DEFAULT = "https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt"
SDL_TRUE = True
SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658)
@ -199,21 +197,16 @@ def interactive_pairing(
return mappings
def apply_rumble(controller: sdl3.SDL_Gamepad, payload: bytes) -> float:
"""Apply rumble payload to SDL controller and return max normalized energy."""
left_norm, right_norm = decode_rumble(payload)
max_norm = max(left_norm, right_norm)
# Treat small rumble as "off" to avoid idle buzz.
if max_norm < RUMBLE_MIN_ACTIVE:
sdl3.SDL_RumbleGamepad(controller, 0, 0, 0)
return 0.0
# Attenuate to feel closer to a real controller; cap at ~25% strength.
scale = RUMBLE_SCALE
low = int(min(1.0, left_norm * scale) * 0xFFFF) # SDL: low_frequency_rumble
high = int(min(1.0, right_norm * scale) * 0xFFFF) # SDL: high_frequency_rumble
duration = 10
sdl3.SDL_RumbleGamepad(controller, low, high, duration)
return max_norm
def apply_rumble(
controller: sdl3.SDL_Gamepad,
low_frequency: float,
high_frequency: float,
) -> bool:
"""Apply normalized low/high rumble magnitudes to an SDL controller."""
low = int(max(0.0, min(1.0, low_frequency)) * 0xFFFF)
high = int(max(0.0, min(1.0, high_frequency)) * 0xFFFF)
sdl3.SDL_RumbleGamepad(controller, low, high, RUMBLE_DURATION_MS)
return low != 0 or high != 0
@dataclass
@ -239,9 +232,7 @@ class ControllerContext:
)
last_send: float = 0.0
last_reopen_attempt: float = 0.0
last_rumble: float = 0.0
last_rumble_change: float = 0.0
last_rumble_energy: float = 0.0
last_rumble_at: float = 0.0
rumble_active: bool = False
axis_offsets: Dict[int, int] = field(default_factory=dict)
swap_abxy: bool = False
@ -1134,7 +1125,6 @@ def handle_removed_port(
ctx.uart = None
ctx.port = None
ctx.rumble_active = False
ctx.last_rumble_energy = 0.0
ctx.last_reopen_attempt = time.monotonic()
console.print(
f"[yellow]UART {path} removed; controller {ctx.controller_index} waiting for reassignment[/yellow]"
@ -1574,32 +1564,25 @@ def service_contexts(
ctx.uart.send_report(ctx.report)
ctx.last_send = now
last_payload = None
latest_rumble = None
while True:
p = ctx.uart.read_rumble_payload()
if not p:
rumble = ctx.uart.read_rumble()
if rumble is None:
break
last_payload = p
latest_rumble = rumble
if last_payload is not None:
# Apply only the freshest rumble payload seen during this tick.
energy = apply_rumble(ctx.controller, last_payload)
ctx.rumble_active = energy >= RUMBLE_MIN_ACTIVE
if ctx.rumble_active and energy != ctx.last_rumble_energy:
ctx.last_rumble_change = now
ctx.last_rumble_energy = energy
ctx.last_rumble = now
elif ctx.rumble_active and (now - ctx.last_rumble) > RUMBLE_IDLE_TIMEOUT:
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.rumble_active = False
ctx.last_rumble_energy = 0.0
if latest_rumble is not None:
# Apply only the freshest rumble command seen during this tick.
ctx.rumble_active = apply_rumble(
ctx.controller, latest_rumble[0], latest_rumble[1]
)
ctx.last_rumble_at = now
elif (
ctx.rumble_active
and (now - ctx.last_rumble_change) > RUMBLE_STUCK_TIMEOUT
and (now - ctx.last_rumble_at) > RUMBLE_IDLE_TIMEOUT
):
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.rumble_active = False
ctx.last_rumble_energy = 0.0
except SerialException as exc:
console.print(f"[yellow]UART {ctx.port} disconnected: {exc}[/yellow]")
try:
@ -1609,7 +1592,6 @@ def service_contexts(
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
ctx.uart = None
ctx.rumble_active = False
ctx.last_rumble_energy = 0.0
ctx.last_reopen_attempt = now
except Exception as exc:
console.print(f"[red]UART error on {ctx.port}: {exc}[/red]")

View file

@ -2,12 +2,13 @@
"""
Lightweight helpers for talking to the switch-pico firmware over UART.
This module exposes the raw 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
depending on SDL. It mirrors the framing in ``switch-pico.cpp``:
Host -> Pico : 0xAA, buttons (LE16), hat, lx, ly, rx, ry
Pico -> Host : 0xBB, 0x01, 8 rumble bytes, checksum (sum of first 10 bytes)
Host -> Pico : UART v2 controller report
Pico -> Host : 0xBB, 0x02, low-frequency magnitude, high-frequency magnitude,
checksum (sum of the first 4 bytes)
"""
from __future__ import annotations
@ -26,7 +27,7 @@ from serial.tools import list_ports, list_ports_common
UART_HEADER = 0xAA
UART_PROTOCOL_VERSION = 0x02
RUMBLE_HEADER = 0xBB
RUMBLE_TYPE_RUMBLE = 0x01
RUMBLE_TYPE_DECODED = 0x02
UART_BAUD = 921600
IMU_SAMPLES_PER_REPORT = 3
@ -300,15 +301,16 @@ class PicoUART:
"""Send a controller report to the Pico."""
self.serial.write(report.to_bytes())
def read_rumble_payload(self) -> Optional[bytes]:
def read_rumble(self) -> Optional[Tuple[float, float]]:
"""
Drain available UART bytes into an internal buffer, then extract one rumble frame.
Extract one decoded rumble frame as normalized low/high magnitudes.
Frame format:
0: 0xBB (RUMBLE_HEADER)
1: type (0x01 for rumble)
2-9: 8-byte rumble payload
10: checksum (sum of first 10 bytes) & 0xFF
1: type (0x02 for decoded rumble)
2: low-frequency magnitude (0-255)
3: high-frequency magnitude (0-255)
4: checksum (sum of first 4 bytes) & 0xFF
"""
waiting = self.serial.in_waiting
if waiting:
@ -323,18 +325,18 @@ class PicoUART:
self._buffer.clear()
return None
if len(self._buffer) - start < 11:
if len(self._buffer) - start < 5:
if start > 0:
del self._buffer[:start]
return None
frame = self._buffer[start : start + 11]
checksum = compute_checksum(bytes(frame[:10]))
frame = self._buffer[start : start + 5]
checksum = compute_checksum(bytes(frame[:4]))
if frame[1] == RUMBLE_TYPE_RUMBLE and checksum == frame[10]:
payload = bytes(frame[2:10])
del self._buffer[: start + 11]
return payload
if frame[1] == RUMBLE_TYPE_DECODED and checksum == frame[4]:
rumble = (frame[2] / 255.0, frame[3] / 255.0)
del self._buffer[: start + 5]
return rumble
del self._buffer[: start + 1]
@ -343,21 +345,6 @@ class PicoUART:
self.serial.close()
def decode_rumble(payload: bytes) -> Tuple[float, float]:
"""Return normalized rumble amplitudes (0.0-1.0) for left/right."""
if len(payload) < 8:
return 0.0, 0.0
if payload == b"\x00\x01\x40\x40\x00\x01\x40\x40":
return 0.0, 0.0
right_raw = ((payload[1] & 0x03) << 8) | payload[0]
left_raw = ((payload[5] & 0x03) << 8) | payload[4]
if left_raw < 8 and right_raw < 8:
return 0.0, 0.0
left = min(max(left_raw / 1023.0, 0.0), 1.0)
right = min(max(right_raw / 1023.0, 0.0), 1.0)
return left, right
@dataclass
class SwitchControllerState:
"""Mutable controller state with helpers for building reports."""
@ -537,13 +524,10 @@ class SwitchUARTClient:
def poll_rumble(self) -> Optional[Tuple[float, float]]:
"""
Poll for the latest rumble payload and return normalized amplitudes.
Poll for decoded low/high rumble magnitudes normalized to 0.0-1.0.
Returns None if no rumble frame was available.
"""
payload = self.uart.read_rumble_payload()
if payload:
return decode_rumble(payload)
return None
return self.uart.read_rumble()
def close(self) -> None:
if self._auto_thread:

View file

@ -1,10 +1,14 @@
#include <stdio.h>
#include <string.h>
#include "bsp/board.h"
#include "hardware/uart.h"
#include "pico/stdlib.h"
#include "tusb.h"
#include "switch_pro_driver.h"
#ifndef SWITCH_PICO_BLUEPAD32
#include "hardware/uart.h"
#else
#include "bluepad32_input_backend.h"
#include "bootsel_pairing_button.h"
#endif
#ifdef SWITCH_PICO_LOG
#define LOG_PRINTF(...) printf(__VA_ARGS__)
@ -12,26 +16,38 @@
#define LOG_PRINTF(...) ((void)0)
#endif
#ifndef SWITCH_PICO_BLUEPAD32
// UART1 is reserved for external input frames from the host PC.
#define UART_ID uart1
#define BAUD_RATE 921600
#define UART_TX_PIN 4
#define UART_RX_PIN 5
#define UART_RUMBLE_HEADER 0xBB
#define UART_RUMBLE_RUMBLE_TYPE 0x01
#define UART_RUMBLE_TYPE 0x02
#endif
#ifdef SWITCH_PICO_BLUEPAD32
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT ==
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT);
static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
static SwitchInputState
g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
#else
static constexpr uint8_t SWITCH_HID_INSTANCE = 0;
static bool g_last_ready = false;
static SwitchInputState g_user_state;
#endif
static bool g_last_mounted = false;
static bool g_last_ready = false;
// Track the latest state provided by UART or the autopilot.
static SwitchInputState g_user_state;
#ifndef SWITCH_PICO_BLUEPAD32
static void init_uart_input() {
uart_init(UART_ID, BAUD_RATE);
gpio_set_function(UART_TX_PIN, GPIO_FUNC_UART);
gpio_set_function(UART_RX_PIN, GPIO_FUNC_UART);
uart_set_format(UART_ID, 8, 1, UART_PARITY_NONE);
}
#endif
static SwitchInputState neutral_input() {
SwitchInputState state{};
@ -42,24 +58,39 @@ static SwitchInputState neutral_input() {
return state;
}
static void send_rumble_uart_frame(const uint8_t rumble[8]) {
uint8_t frame[11];
frame[0] = UART_RUMBLE_HEADER;
frame[1] = UART_RUMBLE_RUMBLE_TYPE;
memcpy(&frame[2], rumble, 8);
#ifndef SWITCH_PICO_BLUEPAD32
static void send_rumble_uart_frame(const SwitchRumbleOutput& rumble) {
uint8_t frame[5] = {
UART_RUMBLE_HEADER,
UART_RUMBLE_TYPE,
rumble.low_frequency_magnitude,
rumble.high_frequency_magnitude,
0,
};
uint8_t checksum = 0;
for (int i = 0; i < 10; ++i) {
checksum = static_cast<uint8_t>(checksum + frame[i]);
for (uint8_t i = 0; i < 4; ++i) {
frame[4] = static_cast<uint8_t>(frame[4] + frame[i]);
}
frame[10] = checksum;
uart_write_blocking(UART_ID, frame, sizeof(frame));
}
#endif
static void on_rumble_from_switch(const uint8_t rumble[8]) {
static void on_rumble_from_switch(uint8_t instance,
const SwitchRumbleOutput& rumble) {
#ifdef SWITCH_PICO_BLUEPAD32
if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) {
return;
}
bluepad32_input_backend_queue_rumble(instance, rumble);
#else
if (instance != SWITCH_HID_INSTANCE) {
return;
}
send_rumble_uart_frame(rumble);
#endif
}
#ifndef SWITCH_PICO_BLUEPAD32
// Consume UART bytes and forward complete frames to the Switch Pro driver.
static bool poll_uart_frames() {
static uint8_t buffer[64];
@ -103,7 +134,7 @@ static bool poll_uart_frames() {
if (expected_len > 0 && index >= expected_len) {
SwitchInputState parsed{};
if (switch_pro_apply_uart_packet(buffer, expected_len, &parsed)) {
if (switch_pro_apply_uart_packet(buffer, expected_len, parsed)) {
g_user_state = parsed;
new_data = true;
LOG_PRINTF("[UART] packet buttons=0x%04x hat=%u lx=%u ly=%u rx=%u ry=%u\n",
@ -134,44 +165,93 @@ static bool poll_uart_frames() {
return new_data;
}
#endif
static void log_usb_state() {
bool mounted = tud_mounted();
bool ready = switch_pro_is_ready();
if (mounted != g_last_mounted) {
g_last_mounted = mounted;
LOG_PRINTF("[USB] %s\n", mounted ? "mounted" : "unmounted");
}
#ifdef SWITCH_PICO_BLUEPAD32
for (uint8_t instance = 0;
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
const bool ready = switch_pro_is_ready(instance);
if (ready != g_last_ready[instance]) {
g_last_ready[instance] = ready;
LOG_PRINTF("[SWITCH %u] driver %s\n", instance,
ready ? "ready (handshake OK)" : "not ready");
}
}
#else
const bool ready = switch_pro_is_ready(SWITCH_HID_INSTANCE);
if (ready != g_last_ready) {
g_last_ready = ready;
LOG_PRINTF("[SWITCH] driver %s\n", ready ? "ready (handshake OK)" : "not ready");
LOG_PRINTF("[SWITCH] driver %s\n",
ready ? "ready (handshake OK)" : "not ready");
}
#endif
}
int main() {
board_init();
stdio_init_all();
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_init();
#else
init_uart_input();
#endif
tusb_init();
switch_pro_init();
switch_pro_set_rumble_callback(on_rumble_from_switch);
#ifdef SWITCH_PICO_BLUEPAD32
for (uint8_t instance = 0;
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
switch_pro_init(instance);
switch_pro_set_rumble_callback(instance, on_rumble_from_switch);
g_user_states[instance] = neutral_input();
switch_pro_set_input(instance, g_user_states[instance]);
}
#else
switch_pro_init(SWITCH_HID_INSTANCE);
switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE,
on_rumble_from_switch);
g_user_state = neutral_input();
switch_pro_set_input(g_user_state);
switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state);
#endif
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_start();
LOG_PRINTF("[BOOT] switch-pico starting (Bluepad32 wireless @ 115200)\n");
#else
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n",
UART_TX_PIN, UART_RX_PIN, BAUD_RATE);
#endif
while (true) {
tud_task(); // USB device tasks
#ifdef SWITCH_PICO_BLUEPAD32
if (bootsel_pairing_button_task()) {
bluepad32_input_backend_open_pairing_window();
}
for (uint8_t instance = 0;
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
bluepad32_input_backend_snapshot(instance,
&g_user_states[instance]);
switch_pro_set_input(instance, g_user_states[instance]);
if (switch_pro_task(instance)) {
bluepad32_input_backend_report_sent(instance);
}
}
#else
bool new_data = poll_uart_frames(); // Pull controller state from UART1
(void)new_data;
SwitchInputState state = g_user_state;
switch_pro_set_input(state);
switch_pro_task(); // Push state to the Switch host
switch_pro_set_input(SWITCH_HID_INSTANCE, state);
(void)switch_pro_task(SWITCH_HID_INSTANCE);
#endif
log_usb_state();
}
}

306
switch_haptics.cpp Normal file
View file

@ -0,0 +1,306 @@
#include "switch_haptics.h"
#include <cmath>
#include <cstring>
namespace {
enum class CommandAction : uint8_t {
Ignore,
Default,
Substitute,
Sum,
};
struct HapticCommand {
CommandAction amplitude_action;
CommandAction frequency_action;
int16_t amplitude_offset;
int16_t frequency_offset;
};
constexpr HapticCommand kCommands[32] = {
{CommandAction::Default, CommandAction::Default, 0, 0},
{CommandAction::Substitute, CommandAction::Ignore, 0, 0},
{CommandAction::Substitute, CommandAction::Ignore, 240, 0},
{CommandAction::Substitute, CommandAction::Ignore, 224, 0},
{CommandAction::Substitute, CommandAction::Ignore, 208, 0},
{CommandAction::Substitute, CommandAction::Ignore, 192, 0},
{CommandAction::Substitute, CommandAction::Ignore, 176, 0},
{CommandAction::Substitute, CommandAction::Ignore, 160, 0},
{CommandAction::Substitute, CommandAction::Ignore, 144, 0},
{CommandAction::Substitute, CommandAction::Ignore, 128, 0},
{CommandAction::Substitute, CommandAction::Ignore, 112, 0},
{CommandAction::Substitute, CommandAction::Ignore, 96, 0},
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
{CommandAction::Ignore, CommandAction::Substitute, 0, 0},
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
{CommandAction::Sum, CommandAction::Sum, 4, 1},
{CommandAction::Sum, CommandAction::Ignore, 4, 0},
{CommandAction::Sum, CommandAction::Sum, 4, -1},
{CommandAction::Sum, CommandAction::Sum, 1, 1},
{CommandAction::Sum, CommandAction::Ignore, 1, 0},
{CommandAction::Sum, CommandAction::Sum, 1, -1},
{CommandAction::Ignore, CommandAction::Sum, 0, 1},
{CommandAction::Ignore, CommandAction::Ignore, 0, 0},
{CommandAction::Ignore, CommandAction::Sum, 0, -1},
{CommandAction::Sum, CommandAction::Sum, -1, 1},
{CommandAction::Sum, CommandAction::Ignore, -1, 0},
{CommandAction::Sum, CommandAction::Sum, -1, -1},
{CommandAction::Sum, CommandAction::Sum, -4, 1},
{CommandAction::Sum, CommandAction::Ignore, -4, 0},
{CommandAction::Sum, CommandAction::Sum, -4, -1},
};
constexpr uint32_t kNeutralWord = 0x40400100u;
constexpr uint8_t kDefaultFrequency = 64;
template <unsigned Shift, uint32_t Mask>
constexpr uint8_t extract(uint32_t word) {
static_assert(Shift < 32u, "32-bit word extraction shift must be bounded");
static_assert(Mask <= 0xffu && Mask <= (0xffffffffu >> Shift),
"word extraction mask must fit the shifted byte");
return static_cast<uint8_t>((word >> Shift) & Mask);
}
uint8_t apply_command(CommandAction action, int16_t offset, uint8_t current,
uint8_t default_value, uint8_t maximum) {
switch (action) {
case CommandAction::Ignore:
return current;
case CommandAction::Default:
return default_value;
case CommandAction::Substitute:
return static_cast<uint8_t>(offset);
case CommandAction::Sum: {
int result = static_cast<int>(current) + static_cast<int>(offset);
if (result < 0) {
result = 0;
} else if (result > maximum) {
result = maximum;
}
return static_cast<uint8_t>(result);
}
}
return default_value;
}
uint8_t host_amplitude_to_lut_index(uint8_t host_index) {
const unsigned index = host_index & 0x7fu;
if (index == 0) {
return 0;
}
if (index < 16) {
return static_cast<uint8_t>(7u + 8u * index);
}
if (index < 32) {
return static_cast<uint8_t>(97u + 2u * index);
}
return static_cast<uint8_t>(128u + index);
}
uint32_t load_little_endian_word(const uint8_t* bytes) {
return static_cast<uint32_t>(bytes[0]) |
(static_cast<uint32_t>(bytes[1]) << 8u) |
(static_cast<uint32_t>(bytes[2]) << 16u) |
(static_cast<uint32_t>(bytes[3]) << 24u);
}
} // namespace
size_t normalize_switch_output_report(uint8_t report_id,
const uint8_t* payload,
size_t payload_size,
uint8_t output[64]) {
if (payload == nullptr || output == nullptr) {
return 0;
}
if (report_id == 0) {
if (payload_size > 64) {
return 0;
}
std::memcpy(output, payload, payload_size);
return payload_size;
}
if (payload_size >= 64) {
return 0;
}
output[0] = report_id;
std::memcpy(output + 1, payload, payload_size);
return payload_size + 1;
}
SwitchHapticsDecoder::SwitchHapticsDecoder() {
reset();
}
void SwitchHapticsDecoder::reset_actuator(ActuatorState& state) {
state.high_amplitude = 0;
state.low_amplitude = 0;
state.high_frequency = kDefaultFrequency;
state.low_frequency = kDefaultFrequency;
state.last_word = 0;
state.have_last_word = false;
}
void SwitchHapticsDecoder::reset() {
reset_actuator(actuators_[0]);
reset_actuator(actuators_[1]);
}
SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator(
ActuatorState& state, uint32_t word) {
if (word == 0 || word == kNeutralWord) {
reset_actuator(state);
state.last_word = word;
state.have_last_word = true;
return {0, 0};
}
if (state.have_last_word && state.last_word == word) {
return {state.low_amplitude, state.high_amplitude};
}
state.last_word = word;
state.have_last_word = true;
AmplitudePeak peak{0, 0};
bool decoded = false;
const uint8_t frame_count = extract<30u, 0x03u>(word);
const uint32_t data = word & 0x3fffffffu;
if (frame_count == 0) {
state.high_amplitude = 0;
return {state.low_amplitude, 0};
}
const auto record_sample = [&]() {
if (state.low_amplitude > peak.low) {
peak.low = state.low_amplitude;
}
if (state.high_amplitude > peak.high) {
peak.high = state.high_amplitude;
}
};
const auto apply_pair = [&](bool high_band, uint8_t command_index) {
const HapticCommand& command = kCommands[command_index & 0x1fu];
uint8_t& amplitude = high_band ? state.high_amplitude : state.low_amplitude;
uint8_t& frequency = high_band ? state.high_frequency : state.low_frequency;
amplitude = apply_command(command.amplitude_action, command.amplitude_offset,
amplitude, 0, 255);
frequency = apply_command(command.frequency_action, command.frequency_offset,
frequency, kDefaultFrequency, 127);
};
const auto decode_type_1 = [&]() {
const uint8_t high_commands[3] = {
extract<20u, 0x1fu>(word),
extract<10u, 0x1fu>(word),
extract<0u, 0x1fu>(word),
};
const uint8_t low_commands[3] = {
extract<25u, 0x1fu>(word),
extract<15u, 0x1fu>(word),
extract<5u, 0x1fu>(word),
};
for (uint8_t sample = 0; sample < frame_count; ++sample) {
apply_pair(true, high_commands[sample]);
apply_pair(false, low_commands[sample]);
record_sample();
}
decoded = true;
};
if (frame_count == 1) {
if ((data & 0x000fffffu) == 0) {
decode_type_1();
} else if ((data & 0x03u) == 0) {
state.high_frequency = extract<2u, 0x7fu>(word);
state.high_amplitude = host_amplitude_to_lut_index(extract<9u, 0x7fu>(word));
state.low_frequency = extract<16u, 0x7fu>(word);
state.low_amplitude = host_amplitude_to_lut_index(extract<23u, 0x7fu>(word));
record_sample();
decoded = true;
} else if ((data & 0x02u) != 0) {
const bool high_band = extract<0u, 0x01u>(word) != 0;
const bool frequency_selected = extract<2u, 0x01u>(word) != 0;
const uint8_t value = extract<23u, 0x7fu>(word);
if (frequency_selected) {
if (high_band) {
state.high_frequency = value;
} else {
state.low_frequency = value;
}
} else if (high_band) {
state.high_amplitude = host_amplitude_to_lut_index(value);
} else {
state.low_amplitude = host_amplitude_to_lut_index(value);
}
record_sample();
decoded = true;
}
} else if (frame_count == 2) {
if ((data & 0x03ffu) == 0) {
decode_type_1();
} else {
const bool high_band = extract<0u, 0x01u>(word) != 0;
const uint8_t frequency = extract<1u, 0x7fu>(word);
const uint8_t command = extract<18u, 0x1fu>(word);
const uint8_t amplitude = host_amplitude_to_lut_index(extract<23u, 0x7fu>(word));
if (high_band) {
state.high_frequency = frequency;
state.high_amplitude = amplitude;
apply_pair(false, command);
} else {
state.low_frequency = frequency;
state.low_amplitude = amplitude;
apply_pair(true, command);
}
record_sample();
apply_pair(true, extract<8u, 0x1fu>(word));
apply_pair(false, extract<13u, 0x1fu>(word));
record_sample();
decoded = true;
}
} else if (frame_count == 3) {
decode_type_1();
}
if (!decoded) {
return {state.low_amplitude, state.high_amplitude};
}
return peak;
}
uint8_t SwitchHapticsDecoder::amplitude_to_magnitude(uint8_t amplitude_index) {
if (amplitude_index < 2) {
return 0;
}
const double exponent = -8.0 + static_cast<double>(amplitude_index) / 32.0;
const double scaled = std::exp2(exponent) * 255.0;
unsigned magnitude = static_cast<unsigned>(scaled + 0.5);
if (magnitude > 255u) {
magnitude = 255u;
}
return static_cast<uint8_t>(magnitude);
}
SwitchRumbleOutput SwitchHapticsDecoder::decode(const uint8_t payload[8]) {
AmplitudePeak peaks[2] = {
{actuators_[0].low_amplitude, actuators_[0].high_amplitude},
{actuators_[1].low_amplitude, actuators_[1].high_amplitude},
};
if (payload != nullptr) {
peaks[0] = decode_actuator(actuators_[0], load_little_endian_word(payload));
peaks[1] = decode_actuator(actuators_[1], load_little_endian_word(payload + 4));
}
const uint8_t low_peak = peaks[0].low > peaks[1].low ? peaks[0].low : peaks[1].low;
const uint8_t high_peak = peaks[0].high > peaks[1].high ? peaks[0].high : peaks[1].high;
return {amplitude_to_magnitude(low_peak), amplitude_to_magnitude(high_peak)};
}

46
switch_haptics.h Normal file
View file

@ -0,0 +1,46 @@
#ifndef SWITCH_HAPTICS_H
#define SWITCH_HAPTICS_H
#include <stddef.h>
#include <stdint.h>
struct SwitchRumbleOutput {
uint8_t low_frequency_magnitude;
uint8_t high_frequency_magnitude;
};
size_t normalize_switch_output_report(uint8_t report_id,
const uint8_t* payload,
size_t payload_size,
uint8_t output[64]);
class SwitchHapticsDecoder {
public:
SwitchHapticsDecoder();
void reset();
SwitchRumbleOutput decode(const uint8_t payload[8]);
private:
struct ActuatorState {
uint8_t high_amplitude;
uint8_t low_amplitude;
uint8_t high_frequency;
uint8_t low_frequency;
uint32_t last_word;
bool have_last_word;
};
struct AmplitudePeak {
uint8_t low;
uint8_t high;
};
static void reset_actuator(ActuatorState& state);
static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word);
static uint8_t amplitude_to_magnitude(uint8_t amplitude_index);
ActuatorState actuators_[2];
};
#endif

View file

@ -8,6 +8,14 @@
#pragma once
#include <stdint.h>
#ifndef SWITCH_PICO_HID_INSTANCE_COUNT
#define SWITCH_PICO_HID_INSTANCE_COUNT 1
#endif
#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4
#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4"
#endif
#define SWITCH_PRO_ENDPOINT_SIZE 64
@ -81,7 +89,7 @@ typedef enum {
GET_VOLTAGE = 0x50,
} SwitchCommands;
typedef struct {
struct SwitchAnalog {
uint8_t data[3];
void setX(uint16_t x) {
@ -101,10 +109,10 @@ typedef struct {
uint16_t getY() {
return static_cast<uint16_t>((data[1] >> 4)) | (data[2] << 4);
}
} SwitchAnalog;
};
// left and right calibration are stored differently for some reason, so two structs
typedef struct {
struct SwitchLeftCalibration {
uint8_t data[9];
void getMin(uint16_t& x, uint16_t& y) const { packCalib(6, x, y); }
@ -137,9 +145,9 @@ typedef struct {
x = static_cast<uint16_t>(data[offset]) | ((data[offset + 1] & 0x0F) << 8);
y = static_cast<uint16_t>(data[offset + 2] << 4) | (data[offset + 1] >> 4);
}
} SwitchLeftCalibration;
};
typedef struct {
struct SwitchRightCalibration {
uint8_t data[9];
void getMin(uint16_t& x, uint16_t& y) const { packCalib(3, x, y); }
@ -172,7 +180,7 @@ typedef struct {
x = static_cast<uint16_t>(data[offset]) | ((data[offset + 1] & 0x0F) << 8);
y = static_cast<uint16_t>(data[offset + 2] << 4) | (data[offset + 1] >> 4);
}
} SwitchRightCalibration;
};
typedef struct
{
@ -369,8 +377,19 @@ static const uint8_t switch_pro_configuration_descriptor[] =
{
0x09, // bLength
0x02, // bDescriptorType (Configuration)
#if SWITCH_PICO_HID_INSTANCE_COUNT == 1
0x29, 0x00, // wTotalLength 41
0x01, // bNumInterfaces 1
#elif SWITCH_PICO_HID_INSTANCE_COUNT == 2
0x49, 0x00, // wTotalLength 73
0x02, // bNumInterfaces 2
#elif SWITCH_PICO_HID_INSTANCE_COUNT == 3
0x69, 0x00, // wTotalLength 105
0x03, // bNumInterfaces 3
#else
0x89, 0x00, // wTotalLength 137
0x04, // bNumInterfaces 4
#endif
0x01, // bConfigurationValue
0x00, // iConfiguration (String Index)
0xA0, // bmAttributes Remote Wakeup
@ -407,6 +426,108 @@ static const uint8_t switch_pro_configuration_descriptor[] =
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
#if SWITCH_PICO_HID_INSTANCE_COUNT >= 2
0x09, // bLength
0x04, // bDescriptorType (Interface)
0x01, // bInterfaceNumber 1
0x00, // bAlternateSetting
0x02, // bNumEndpoints 2
0x03, // bInterfaceClass
0x00, // bInterfaceSubClass
0x00, // bInterfaceProtocol
0x00, // iInterface (String Index)
0x09, // bLength
0x21, // bDescriptorType (HID)
0x11, 0x01, // bcdHID 1.11
0x00, // bCountryCode
0x01, // bNumDescriptors
0x22, // bDescriptorType[0] (HID)
0xCB, 0x00, // wDescriptorLength[0] 203
0x07, // bLength
0x05, // bDescriptorType (Endpoint)
0x82, // bEndpointAddress (IN/D2H)
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
0x07, // bLength
0x05, // bDescriptorType (Endpoint)
0x02, // bEndpointAddress (OUT/H2D)
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
#endif
#if SWITCH_PICO_HID_INSTANCE_COUNT >= 3
0x09, // bLength
0x04, // bDescriptorType (Interface)
0x02, // bInterfaceNumber 2
0x00, // bAlternateSetting
0x02, // bNumEndpoints 2
0x03, // bInterfaceClass
0x00, // bInterfaceSubClass
0x00, // bInterfaceProtocol
0x00, // iInterface (String Index)
0x09, // bLength
0x21, // bDescriptorType (HID)
0x11, 0x01, // bcdHID 1.11
0x00, // bCountryCode
0x01, // bNumDescriptors
0x22, // bDescriptorType[0] (HID)
0xCB, 0x00, // wDescriptorLength[0] 203
0x07, // bLength
0x05, // bDescriptorType (Endpoint)
0x83, // bEndpointAddress (IN/D2H)
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
0x07, // bLength
0x05, // bDescriptorType (Endpoint)
0x03, // bEndpointAddress (OUT/H2D)
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
#endif
#if SWITCH_PICO_HID_INSTANCE_COUNT >= 4
0x09, // bLength
0x04, // bDescriptorType (Interface)
0x03, // bInterfaceNumber 3
0x00, // bAlternateSetting
0x02, // bNumEndpoints 2
0x03, // bInterfaceClass
0x00, // bInterfaceSubClass
0x00, // bInterfaceProtocol
0x00, // iInterface (String Index)
0x09, // bLength
0x21, // bDescriptorType (HID)
0x11, 0x01, // bcdHID 1.11
0x00, // bCountryCode
0x01, // bNumDescriptors
0x22, // bDescriptorType[0] (HID)
0xCB, 0x00, // wDescriptorLength[0] 203
0x07, // bLength
0x05, // bDescriptorType (Endpoint)
0x84, // bEndpointAddress (IN/D2H)
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
0x07, // bLength
0x05, // bDescriptorType (Endpoint)
0x04, // bEndpointAddress (OUT/H2D)
0x03, // bmAttributes (Interrupt)
0x40, 0x00, // wMaxPacketSize 64
0x08, // bInterval 8 (unit depends on device speed)
#endif
};
static const uint8_t switch_pro_report_descriptor[] =

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,10 @@
#include <stdbool.h>
#include <stdint.h>
#include "switch_haptics.h"
#include "switch_pro_descriptors.h"
typedef struct {
int16_t accel_x;
int16_t accel_y;
@ -48,23 +50,68 @@ typedef struct {
uint8_t imu_sample_count; // 0-3
SwitchImuSample imu_samples[3];
} SwitchInputState;
typedef struct {
uint8_t red;
uint8_t green;
uint8_t blue;
} SwitchRgbColor;
constexpr SwitchRgbColor switch_pro_calibrate_light_color(
SwitchRgbColor grip) {
const uint8_t minimum =
grip.red < grip.green
? (grip.red < grip.blue ? grip.red : grip.blue)
: (grip.green < grip.blue ? grip.green : grip.blue);
const uint8_t maximum =
grip.red > grip.green
? (grip.red > grip.blue ? grip.red : grip.blue)
: (grip.green > grip.blue ? grip.green : grip.blue);
const uint16_t chroma = static_cast<uint16_t>(maximum - minimum);
const uint16_t peak =
static_cast<uint16_t>((static_cast<uint16_t>(maximum) * 2u + 1u) /
3u);
if (chroma == 0) {
const uint8_t gray = static_cast<uint8_t>(peak);
return {gray, gray, gray};
}
// Initialize USB state and calibration before entering the main loop.
void switch_pro_init();
const auto calibrate = [minimum, chroma, peak](uint8_t component) {
const uint32_t delta =
static_cast<uint32_t>(component - minimum);
return static_cast<uint8_t>(
(static_cast<uint32_t>(peak) * delta * delta) /
(static_cast<uint32_t>(chroma) * chroma));
};
return {calibrate(grip.red), calibrate(grip.green),
calibrate(grip.blue)};
}
// Update the desired controller state for the next USB report.
void switch_pro_set_input(const SwitchInputState& state);
// Drive the Switch Pro USB state machine; call this frequently in the main loop.
void switch_pro_task();
// Return the configured Switch grip color and its automatically calibrated
// physical LED color for one HID/controller slot.
SwitchRgbColor switch_pro_get_slot_color(uint8_t instance);
SwitchRgbColor switch_pro_get_slot_light_color(uint8_t instance);
// Initialize one HID instance before entering the main loop.
void switch_pro_init(uint8_t instance);
// Update the desired controller state for one HID instance.
void switch_pro_set_input(uint8_t instance, const SwitchInputState& state);
// Drive one Switch Pro USB state machine; returns true only when a regular
// 0x30 input report was successfully queued.
bool switch_pro_task(uint8_t instance);
// Convert a packed UART message into controller state (returns true if parsed).
// If out_state is null the parsed state is written directly to the driver.
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, SwitchInputState* out_state = nullptr);
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
SwitchInputState& out_state);
// Driver state helpers
bool switch_pro_is_ready();
bool switch_pro_is_ready(uint8_t instance);
// Optional callback fired when the host sends a rumble payload (the raw 8 rumble bytes).
typedef void (*SwitchRumbleCallback)(const uint8_t rumble_data[8]);
void switch_pro_set_rumble_callback(SwitchRumbleCallback cb);
// Optional callback fired with decoded rumble intensities from one host
// interface.
typedef void (*SwitchRumbleCallback)(uint8_t instance,
const SwitchRumbleOutput& rumble);
void switch_pro_set_rumble_callback(uint8_t instance,
SwitchRumbleCallback callback);

View file

@ -0,0 +1,764 @@
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <uni.h>
#include "../controller_color_config.h"
namespace {
bool incoming_connections = false;
int scan_starts = 0;
int scan_stops = 0;
bool scanning_enabled = false;
int classic_scan_starts = 0;
int classic_scan_stops = 0;
bool classic_scanning_enabled = false;
uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false;
int observed_status_led_writes = 0;
uint32_t now_ms = 0;
bool flash_core_init_result = true;
int flash_core_init_calls = 0;
int core1_launch_calls = 0;
int cyw43_init_calls = 0;
int uni_init_calls = 0;
int device_disconnect_calls = 0;
uni_hid_device_t* last_disconnected_device = nullptr;
struct CoreStopped {};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void play_rumble(uni_hid_device_t* device, uint16_t, uint16_t,
uint8_t high, uint8_t low) {
++device->rumble_calls;
device->last_high = high;
device->last_low = low;
}
void set_lightbar(uni_hid_device_t* device, uint8_t red, uint8_t green,
uint8_t blue) {
++device->lightbar_calls;
device->lightbar_red = red;
device->lightbar_green = green;
device->lightbar_blue = blue;
}
void set_player_leds(uni_hid_device_t* device, uint8_t leds) {
++device->player_led_calls;
device->player_leds = leds;
}
uni_hid_device_t device(
int idx, bool gamepad = true,
uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE) {
uni_hid_device_t result{};
result.idx = idx;
result.gamepad = gamepad;
result.conn.protocol = protocol;
result.conn.btaddr[5] = static_cast<uint8_t>(idx + 1);
result.report_parser.play_dual_rumble = play_rumble;
return result;
}
} // namespace
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) {
return device != nullptr && device->gamepad;
}
int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device) {
return device == nullptr ? -1 : device->idx;
}
void uni_hid_device_disconnect(uni_hid_device_t* device) {
++device_disconnect_calls;
last_disconnected_device = device;
}
void uni_bt_allow_incoming_connections(bool enabled) {
incoming_connections = enabled;
}
void uni_bt_bredr_scan_start() {
++classic_scan_starts;
classic_scanning_enabled = true;
}
void uni_bt_bredr_scan_stop() {
if (classic_scanning_enabled) {
++classic_scan_stops;
}
classic_scanning_enabled = false;
}
void uni_bt_le_scan_start() {
++scan_starts;
scanning_enabled = true;
}
void uni_bt_le_scan_stop() {
if (scanning_enabled) {
++scan_stops;
}
scanning_enabled = false;
}
void uni_bt_start_scanning_and_autoconnect_unsafe() {
uni_bt_bredr_scan_start();
uni_bt_le_scan_start();
}
void uni_bt_stop_scanning_unsafe() {
uni_bt_bredr_scan_stop();
uni_bt_le_scan_stop();
}
void uni_platform_set_custom(uni_platform* platform) {
installed_platform = platform;
}
int uni_init(int, const char**) {
++uni_init_calls;
return 0;
}
bool flash_safe_execute_core_init() {
++flash_core_init_calls;
return flash_core_init_result;
}
int cyw43_arch_init() {
++cyw43_init_calls;
return 0;
}
void cyw43_arch_gpio_put(int, bool enabled) {
observed_status_led_on = enabled;
++observed_status_led_writes;
}
void multicore_launch_core1(void (*)()) {
++core1_launch_calls;
}
void tight_loop_contents() {
throw CoreStopped{};
}
uint32_t btstack_run_loop_get_time_ms() {
return now_ms;
}
#include "../bluepad32_input_backend.cpp"
SwitchRgbColor switch_pro_get_slot_light_color(uint8_t instance) {
static constexpr SwitchRgbColor grips[] = {
{SWITCH_COLOR_SLOT_1_R, SWITCH_COLOR_SLOT_1_G,
SWITCH_COLOR_SLOT_1_B},
{SWITCH_COLOR_SLOT_2_R, SWITCH_COLOR_SLOT_2_G,
SWITCH_COLOR_SLOT_2_B},
{SWITCH_COLOR_SLOT_3_R, SWITCH_COLOR_SLOT_3_G,
SWITCH_COLOR_SLOT_3_B},
{SWITCH_COLOR_SLOT_4_R, SWITCH_COLOR_SLOT_4_G,
SWITCH_COLOR_SLOT_4_B},
};
return instance < sizeof(grips) / sizeof(grips[0])
? switch_pro_calibrate_light_color(grips[instance])
: SwitchRgbColor{};
}
namespace {
void start_backend() {
bluepad32_input_backend_init();
platform_on_init_complete();
require(!incoming_connections,
"initialization must keep incoming connections closed");
require(scan_starts == 0,
"initialization must not scan before a BOOTSEL request");
}
void start_pairing_backend() {
start_backend();
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_connection_policy_state == ConnectionPolicyState::Open &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections,
"test connection setup requires an open pairing window");
}
void tick_backend_timer(int ticks) {
for (int tick = 0; tick < ticks; ++tick) {
process_rumble_timer(&g_rumble_timer);
}
}
void test_ready_order(bool reverse) {
start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
uni_hid_device_t replacements[kSlotCount] = {
device(0), device(1), device(2), device(3)};
const int forward[kSlotCount] = {0, 1, 2, 3};
const int backward[kSlotCount] = {3, 2, 1, 0};
const int* order = reverse ? backward : forward;
platform_on_device_connected(&devices[order[0]]);
for (int position = 0; position < kSlotCount; ++position) {
const int slot = order[position];
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"ready device must bind to its Bluepad index");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
SwitchInputState snapshot{};
bool expected_active = false;
for (int ready = 0; ready <= position; ++ready) {
expected_active = expected_active || order[ready] == candidate;
}
require(bluepad32_input_backend_snapshot(candidate, &snapshot) ==
expected_active,
"only ready indexed slots may become active");
}
if (position + 1 < kSlotCount) {
require(scan_stops == 0,
"scanning must continue while any slot remains free");
require(incoming_connections,
"incoming connections must remain enabled before all slots are ready");
}
}
require(scan_stops == 1,
"scanning must stop exactly when all four slots are ready");
require(!incoming_connections,
"incoming connections must be disabled only when all slots are full");
for (int slot = 0; slot < kSlotCount; ++slot) {
const int starts_before_disconnect = scan_starts;
platform_on_device_disconnected(&devices[slot]);
require(scan_starts == starts_before_disconnect + 1 &&
scanning_enabled && incoming_connections,
"disconnecting any slot must resume connection policy");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
SwitchInputState snapshot{};
require(bluepad32_input_backend_snapshot(candidate, &snapshot) ==
(candidate != slot),
"disconnect must preserve every surviving slot");
}
require(platform_on_device_ready(&replacements[slot]) ==
UNI_ERROR_SUCCESS,
"replacement must bind to each freed indexed slot");
require(!scanning_enabled && !incoming_connections,
"restoring four ready slots must stop connection policy");
devices[slot] = replacements[slot];
}
bd_addr_t address{};
require(platform_on_device_discovered(address, "extra", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"discovery must reject devices while all four slots are occupied");
}
void test_rejections() {
start_backend();
uni_hid_device_t non_gamepad = device(0, false);
uni_hid_device_t out_of_range = device(4);
uni_hid_device_t slot_zero = device(0);
uni_hid_device_t collision = device(0);
require(platform_on_device_ready(&non_gamepad) ==
UNI_ERROR_INVALID_CONTROLLER,
"non-gamepad must be rejected");
require(platform_on_device_ready(&out_of_range) == UNI_ERROR_NO_SLOTS,
"Bluepad index 4 must be rejected");
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS,
"valid device must occupy its indexed slot");
require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS,
"different device cannot replace an occupied slot");
uni_controller_t collision_data{};
collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
collision_data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&collision, &collision_data);
SwitchInputState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot),
"occupied slot must stay active");
require(!snapshot.button_a,
"mismatched device input must not enter the occupied slot");
uni_controller_t slot_zero_data{};
slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
slot_zero_data.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &slot_zero_data);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count == 3,
"valid slot input must remain observable");
require(!bluepad32_input_backend_snapshot(4, &snapshot),
"public snapshot must reject slot 4");
bluepad32_input_backend_report_sent(4);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count == 3,
"slot 4 acknowledgement must not consume slot 0 IMU");
bluepad32_input_backend_queue_rumble(4, SwitchRumbleOutput{1, 2});
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 0,
"slot 4 rumble must not reach a valid controller");
}
void test_independent_lifecycle() {
start_pairing_backend();
uni_hid_device_t aborted = device(0);
const uint32_t aborted_generation = g_slots[0].connection_generation;
platform_on_device_connected(&aborted);
require(g_slots[0].device == &aborted && !g_slots[0].active,
"connected device must remain identifiable while becoming ready");
const int starts_before_aborted_disconnect = scan_starts;
platform_on_device_disconnected(&aborted);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear its pending slot identity");
require(g_slots[0].connection_generation == aborted_generation + 1,
"pre-ready disconnect must invalidate its connection generation");
require(g_connection_status == ConnectionStatus::Scanning &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections &&
scan_starts == starts_before_aborted_disconnect,
"pre-ready disconnect must preserve the open pairing scan");
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < kSlotCount; ++slot) {
platform_on_device_connected(&devices[slot]);
require(g_slots[slot].device == &devices[slot] &&
!g_slots[slot].active,
"each pending device must retain its indexed identity");
}
const uint32_t first_pending_generation =
g_slots[0].connection_generation;
const int starts_before_first_pending_disconnect = scan_starts;
platform_on_device_disconnected(&devices[0]);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear only its own pending identity");
for (int slot = 1; slot < kSlotCount; ++slot) {
require(g_slots[slot].device == &devices[slot] &&
!g_slots[slot].active,
"pre-ready disconnect must preserve all pending survivors");
}
require(g_slots[0].connection_generation ==
first_pending_generation + 1,
"pending disconnect beside peers must invalidate its generation");
require(g_connection_status == ConnectionStatus::Connecting &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections &&
scan_starts == starts_before_first_pending_disconnect + 1,
"open pairing slot must preserve pending peers and resume scanning");
for (int slot = 1; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"each surviving pending device must still become ready");
}
platform_on_device_connected(&devices[0]);
require(platform_on_device_ready(&devices[0]) == UNI_ERROR_SUCCESS,
"reconnected slot 0 device must complete all four slots");
require(g_connection_status == ConnectionStatus::Ready &&
!scanning_enabled && !incoming_connections,
"four ready lifecycle devices must stop connection policy");
const uint32_t buttons[kSlotCount] = {
BUTTON_B, BUTTON_A, BUTTON_X, BUTTON_Y};
uni_controller_t data[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
data[slot].klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data[slot].gamepad.buttons = buttons[slot];
data[slot].gamepad.accel[slot % 3] = 8192 + slot;
data[slot].gamepad.gyro[(slot + 1) % 3] = 1024 + slot;
platform_on_controller_data(&devices[slot], &data[slot]);
}
SwitchInputState states[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].imu_sample_count == 3,
"every slot must expose independent input and IMU");
}
require(states[0].button_a && !states[0].button_b &&
!states[0].button_y && !states[0].button_x,
"slot 0 must contain only slot 0 input");
require(states[1].button_b && !states[1].button_a &&
!states[1].button_y && !states[1].button_x,
"slot 1 must contain only slot 1 input");
require(states[2].button_y && !states[2].button_a &&
!states[2].button_b && !states[2].button_x,
"slot 2 must contain only slot 2 input");
require(states[3].button_x && !states[3].button_a &&
!states[3].button_b && !states[3].button_y,
"slot 3 must contain only slot 3 input");
bluepad32_input_backend_report_sent(3);
for (int slot = 0; slot < kSlotCount; ++slot) {
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].imu_sample_count == (slot == 3 ? 0 : 3),
"slot 3 acknowledgement must not consume slots 0-2 IMU");
}
for (int slot = 0; slot < 3; ++slot) {
bluepad32_input_backend_report_sent(slot);
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].imu_sample_count == 0,
"each slot acknowledgement must consume only its own IMU");
}
const SwitchRumbleOutput initial_rumble[kSlotCount] = {
{11, 21}, {12, 22}, {13, 23}, {14, 24}};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_queue_rumble(slot, initial_rumble[slot]);
}
process_rumble_timer(&g_rumble_timer);
for (int slot = 0; slot < kSlotCount; ++slot) {
require(devices[slot].rumble_calls == 1 &&
devices[slot].last_low == 11 + slot &&
devices[slot].last_high == 21 + slot,
"each slot rumble must reach only its indexed controller");
}
bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{55, 66});
const uint32_t disconnected_generation =
g_slots[3].connection_generation;
const int starts_before_slot_three_disconnect = scan_starts;
platform_on_device_disconnected(&devices[3]);
require(scan_starts == starts_before_slot_three_disconnect + 1 &&
scanning_enabled && incoming_connections,
"slot 3 disconnect must resume scanning and incoming connections");
require(!bluepad32_input_backend_snapshot(3, &states[3]) &&
!states[3].button_x && states[3].lx == 32768,
"slot 3 disconnect must neutralize only slot 3");
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
states[0].button_a &&
bluepad32_input_backend_snapshot(1, &states[1]) &&
states[1].button_b &&
bluepad32_input_backend_snapshot(2, &states[2]) &&
states[2].button_y,
"slot 3 disconnect must preserve slots 0-2");
platform_on_controller_data(&devices[0], &data[0]);
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
states[0].button_a,
"slot 0 input must continue while slot 3 is disconnected");
const int slot_zero_calls_while_scanning = devices[0].rumble_calls;
bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{115, 116});
tick_backend_timer(99);
require(devices[0].rumble_calls == slot_zero_calls_while_scanning + 1 &&
devices[0].last_low == 115 &&
devices[0].last_high == 116,
"slot 0 rumble must continue while slot 3 is disconnected");
uni_hid_device_t slot_three_replacement = device(3);
require(platform_on_device_ready(&slot_three_replacement) ==
UNI_ERROR_SUCCESS,
"slot 3 replacement must bind to the freed indexed slot");
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0,
"slot 3 replacement must not receive disconnected device rumble");
g_slots[3].pending_rumble = {
3, disconnected_generation, SwitchRumbleOutput{77, 88}};
g_slots[3].rumble_pending = true;
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0,
"stale slot 3 connection generation must be rejected");
uni_controller_t replacement_data{};
replacement_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
replacement_data.gamepad.buttons = BUTTON_Y;
replacement_data.gamepad.accel[0] = 9000;
platform_on_controller_data(&slot_three_replacement, &replacement_data);
require(bluepad32_input_backend_snapshot(3, &states[3]) &&
states[3].button_x && states[3].imu_sample_count == 3,
"replacement input and IMU must populate only slot 3");
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
states[0].button_a &&
bluepad32_input_backend_snapshot(1, &states[1]) &&
states[1].button_b &&
bluepad32_input_backend_snapshot(2, &states[2]) &&
states[2].button_y,
"slot 3 replacement must not disturb slots 0-2");
const int survivor_calls[kSlotCount - 1] = {
devices[0].rumble_calls,
devices[1].rumble_calls,
devices[2].rumble_calls};
bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{90, 91});
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 1 &&
slot_three_replacement.last_low == 90 &&
slot_three_replacement.last_high == 91,
"new-generation slot 3 rumble must reach its replacement");
for (int slot = 0; slot < 3; ++slot) {
require(devices[slot].rumble_calls == survivor_calls[slot],
"slot 3 rumble must not affect slots 0-2");
}
const int all_slot_calls[kSlotCount] = {
devices[0].rumble_calls,
devices[1].rumble_calls,
devices[2].rumble_calls,
slot_three_replacement.rumble_calls};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_queue_rumble(
slot, SwitchRumbleOutput{static_cast<uint8_t>(100 + slot),
static_cast<uint8_t>(110 + slot)});
}
process_rumble_timer(&g_rumble_timer);
for (int slot = 0; slot < kSlotCount; ++slot) {
const uni_hid_device_t& target =
slot == 3 ? slot_three_replacement : devices[slot];
require(target.rumble_calls == all_slot_calls[slot] + 1 &&
target.last_low == 100 + slot &&
target.last_high == 110 + slot,
"survivor rumble must continue after slot 3 replacement");
}
uni_hid_device_t replacements[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < 3; ++slot) {
const int starts_before_disconnect = scan_starts;
platform_on_device_disconnected(&devices[slot]);
require(scan_starts == starts_before_disconnect + 1 &&
scanning_enabled && incoming_connections,
"disconnecting slots 0-2 must resume connection policy");
require(!bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].lx == 32768,
"disconnect must neutralize its indexed slot");
for (int survivor = 0; survivor < kSlotCount; ++survivor) {
if (survivor == slot) {
continue;
}
require(bluepad32_input_backend_snapshot(survivor,
&states[survivor]),
"disconnect must preserve all three survivors");
}
require(platform_on_device_ready(&replacements[slot]) ==
UNI_ERROR_SUCCESS,
"replacement must bind to each freed slot");
require(g_connection_status == ConnectionStatus::Ready &&
!scanning_enabled && !incoming_connections,
"replacement must restore the full four-slot policy");
}
const int slot_zero_calls_before_mailboxes = replacements[0].rumble_calls;
const int slot_three_calls_before_mailboxes =
slot_three_replacement.rumble_calls;
bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{119, 120});
bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{121, 122});
bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{123, 124});
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls ==
slot_three_calls_before_mailboxes + 1 &&
slot_three_replacement.last_low == 121 &&
slot_three_replacement.last_high == 122,
"slot 3 mailbox must dispatch only its latest queued value");
require(replacements[0].rumble_calls ==
slot_zero_calls_before_mailboxes + 1 &&
replacements[0].last_low == 123 &&
replacements[0].last_high == 124,
"slot 0 activity must not evict the slot 3 mailbox");
}
void test_pairing_window_policy() {
bd_addr_t address = {1, 2, 3, 4, 5, 6};
bluepad32_input_backend_init();
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"discovery must remain closed before backend initialization");
start_backend();
require(g_connection_policy_state == ConnectionPolicyState::Locked &&
!classic_scanning_enabled && !scanning_enabled &&
!incoming_connections,
"boot must disable all discovery and incoming connections");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"locked policy must reject every discovery");
uni_hid_device_t rejected = device(0);
platform_on_device_connected(&rejected);
require(device_disconnect_calls == 1 &&
last_disconnected_device == &rejected &&
g_slots[0].device == nullptr,
"locked policy must disconnect every incoming controller");
bluepad32_input_backend_open_pairing_window();
require(!g_pairing_window_open,
"Core0 request must wait for Core1 consumption");
process_rumble_timer(&g_rumble_timer);
require(g_pairing_window_open &&
g_pairing_window_deadline_ms == 60000 &&
g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"BOOTSEL window must run Bluepad32's normal pairing scan");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_SUCCESS,
"open pairing window must accept a discovered controller");
uni_hid_device_t paired = device(0);
platform_on_device_connected(&paired);
require(device_disconnect_calls == 1 &&
g_slots[0].device == &paired,
"open pairing window must retain a connected controller");
platform_on_device_disconnected(&paired);
tick_backend_timer(20);
require(!observed_status_led_on,
"pairing double blink must finish its first pulse");
tick_backend_timer(20);
require(observed_status_led_on,
"pairing double blink must start its second pulse");
tick_backend_timer(20);
require(!observed_status_led_on,
"pairing double blink must finish its second pulse");
now_ms = 30000;
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_pairing_window_deadline_ms == 90000,
"pairing request must extend deadline from current Core1 time");
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"policy test devices must fill all slots");
}
require(g_connection_policy_state == ConnectionPolicyState::Paused &&
g_pairing_window_open && !scanning_enabled &&
!classic_scanning_enabled && !incoming_connections,
"full slots must pause pairing without closing the deadline");
now_ms = 90000;
process_rumble_timer(&g_rumble_timer);
require(!g_pairing_window_open &&
g_connection_policy_state == ConnectionPolicyState::Paused,
"deadline must expire while slots remain full");
platform_on_device_disconnected(&devices[3]);
require(g_connection_policy_state == ConnectionPolicyState::Locked &&
!classic_scanning_enabled && !scanning_enabled &&
!incoming_connections,
"a freed slot after expiry must remain closed");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"expired pairing policy must reject discovery");
}
void test_slot_lighting() {
start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
devices[slot].report_parser.set_lightbar_color = set_lightbar;
devices[slot].report_parser.set_player_leds = set_player_leds;
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"color-capable controller did not become ready");
const SwitchRgbColor expected =
switch_pro_get_slot_light_color(slot);
require(devices[slot].lightbar_calls == 1 &&
devices[slot].lightbar_red == expected.red &&
devices[slot].lightbar_green == expected.green &&
devices[slot].lightbar_blue == expected.blue &&
devices[slot].player_led_calls == 0,
"slot color did not reach the controller lightbar");
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS &&
devices[slot].lightbar_calls == 1,
"duplicate ready event rewrote controller lighting");
}
platform_on_device_disconnected(&devices[2]);
uni_hid_device_t fallback = device(2);
fallback.report_parser.set_player_leds = set_player_leds;
require(platform_on_device_ready(&fallback) == UNI_ERROR_SUCCESS &&
fallback.lightbar_calls == 0 &&
fallback.player_led_calls == 1 &&
fallback.player_leds == (1u << 2u),
"controller without RGB support did not receive its slot LED");
}
void test_flash_core_start_contract() {
bluepad32_input_backend_init();
flash_core_init_result = false;
bluepad32_input_backend_start();
require(flash_core_init_calls == 1 && core1_launch_calls == 0 &&
g_connection_policy_state ==
ConnectionPolicyState::FailedClosed,
"Core0 flash-safe init failure must prevent Core1 launch");
flash_core_init_result = true;
bluepad32_input_backend_start();
require(flash_core_init_calls == 2 && core1_launch_calls == 1,
"Core0 must register as a flash-safe victim before Core1 launch");
bluepad32_input_backend_start();
require(flash_core_init_calls == 2 && core1_launch_calls == 1,
"backend start must remain idempotent");
}
void test_flash_core_init_fatal() {
bluepad32_input_backend_init();
flash_core_init_result = false;
bool stopped = false;
try {
core1_main();
} catch (const CoreStopped&) {
stopped = true;
}
require(stopped && flash_core_init_calls == 1 &&
cyw43_init_calls == 0 && uni_init_calls == 0 &&
g_connection_policy_state ==
ConnectionPolicyState::FailedClosed,
"flash-safe Core1 init failure must halt before CYW43 init");
}
} // namespace
int main(int argc, char** argv) {
require(argc == 2, "scenario argument required");
const std::string scenario = argv[1];
if (scenario == "ready-forward") {
test_ready_order(false);
} else if (scenario == "ready-reverse") {
test_ready_order(true);
} else if (scenario == "rejections") {
test_rejections();
} else if (scenario == "lifecycle") {
test_independent_lifecycle();
} else if (scenario == "pairing-policy") {
test_pairing_window_policy();
} else if (scenario == "slot-lighting") {
test_slot_lighting();
} else if (scenario == "flash-core-start") {
test_flash_core_start_contract();
} else if (scenario == "flash-core-failure") {
test_flash_core_init_fatal();
} else {
require(false, "unknown scenario");
}
return 0;
}

View file

@ -0,0 +1,212 @@
#include "parser/uni_hid_parser_imu.h"
#include <array>
#include <cstdint>
#include <cstring>
#include <iostream>
namespace {
int failures = 0;
void expect(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
++failures;
}
}
void write_calibration_value(
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob,
uni_psmove_imu_model_t model, uint8_t offset, int32_t value) {
const uint16_t encoded =
model == UNI_PSMOVE_IMU_MODEL_ZCM1
? static_cast<uint16_t>(value + 0x8000)
: static_cast<uint16_t>(static_cast<int16_t>(value));
blob[offset] = static_cast<uint8_t>(encoded);
blob[offset + 1] = static_cast<uint8_t>(encoded >> 8u);
}
uint16_t encode_input(uni_psmove_imu_model_t model, int32_t value) {
return model == UNI_PSMOVE_IMU_MODEL_ZCM1
? static_cast<uint16_t>(value + 0x8000)
: static_cast<uint16_t>(static_cast<int16_t>(value));
}
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> first_report(
const std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob) {
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> report{};
std::memcpy(report.data(), blob.data(), report.size());
report[0] = 0x10;
report[1] = 0x00;
return report;
}
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> continuation_report(
const std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob,
uint8_t block, size_t blob_offset) {
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> report{};
report[0] = 0x10;
report[1] = block;
std::memcpy(report.data() + 2, blob.data() + blob_offset,
report.size() - 2);
return report;
}
void set_accel_calibration(
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob,
uni_psmove_imu_model_t model, int32_t low, int32_t high) {
const uint8_t* low_offsets;
const uint8_t* high_offsets;
static const uint8_t zcm1_low[] = {0x0a, 0x24, 0x14};
static const uint8_t zcm1_high[] = {0x16, 0x1e, 0x08};
static const uint8_t zcm2_low[] = {0x08, 0x16, 0x24};
static const uint8_t zcm2_high[] = {0x02, 0x10, 0x1e};
if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
low_offsets = zcm1_low;
high_offsets = zcm1_high;
} else {
low_offsets = zcm2_low;
high_offsets = zcm2_high;
}
for (uint8_t axis = 0; axis < 3; ++axis) {
write_calibration_value(blob, model, low_offsets[axis], low);
write_calibration_value(blob, model, high_offsets[axis], high);
}
}
void test_wii_accelerometer() {
int32_t output[3]{};
uni_imu_normalize_wii_accel(100, -50, 25, output);
expect(output[0] == -8192 && output[1] == 2048 &&
output[2] == -4096,
"Wii accelerometer scale or SDL axis mapping is wrong");
}
void test_zcm1_calibration_and_normalization() {
constexpr auto model = UNI_PSMOVE_IMU_MODEL_ZCM1;
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE> blob{};
set_accel_calibration(blob, model, -1000, 1000);
expect(uni_psmove_scale_gyro(32767, -32768, 1,
1080 * UNI_IMU_GYRO_RES_PER_DEG_S) ==
INT32_MAX,
"corrupt PS Move calibration overflow was not clamped");
const uint8_t bias_offsets[] = {0x2a, 0x2c, 0x2e};
const uint8_t high_offsets[] = {0x46, 0x50, 0x5a};
for (uint8_t axis = 0; axis < 3; ++axis) {
write_calibration_value(blob, model, bias_offsets[axis], 0);
write_calibration_value(blob, model, high_offsets[axis], 1000);
}
auto first = first_report(blob);
auto second = continuation_report(blob, 0x01, 49);
auto third = continuation_report(blob, 0x82, 96);
uni_psmove_imu_calibration_t calibration{};
expect(uni_psmove_add_calibration_report(
&calibration, model, second.data(), second.size()) ==
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
"ZCM1 second calibration block was not accepted out of order");
expect(uni_psmove_add_calibration_report(
&calibration, model, first.data(), first.size()) ==
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
"ZCM1 first calibration block completed too early");
expect(uni_psmove_add_calibration_report(
&calibration, model, third.data(), third.size()) ==
UNI_PSMOVE_CALIBRATION_COMPLETE,
"ZCM1 calibration did not complete");
const uint16_t accel_first[] = {
encode_input(model, 1000), encode_input(model, 0),
encode_input(model, -1000)};
const uint16_t accel_second[] = {
encode_input(model, 0), encode_input(model, 0),
encode_input(model, -1000)};
const uint16_t gyro_first[] = {
encode_input(model, 500), encode_input(model, 0),
encode_input(model, -500)};
const uint16_t gyro_second[] = {
encode_input(model, 500), encode_input(model, 0),
encode_input(model, -500)};
uni_imu_fixed_sample_t output{};
expect(uni_psmove_normalize_imu(
model, &calibration, accel_first, accel_second, gyro_first,
gyro_second, &output),
"ZCM1 calibrated sample was rejected");
expect(output.accel[0] == 4096 && output.accel[1] == 0 &&
output.accel[2] == -8192,
"ZCM1 accelerometer normalization is wrong");
expect(output.gyro[0] == 245760 && output.gyro[1] == 0 &&
output.gyro[2] == -245760,
"ZCM1 gyroscope normalization is wrong");
}
void test_zcm2_calibration_and_normalization() {
constexpr auto model = UNI_PSMOVE_IMU_MODEL_ZCM2;
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE> blob{};
set_accel_calibration(blob, model, -1000, 1000);
const uint8_t bias_offsets[] = {0x26, 0x28, 0x2a};
const uint8_t low_offsets[] = {0x42, 0x4a, 0x52};
const uint8_t high_offsets[] = {0x30, 0x38, 0x40};
for (uint8_t axis = 0; axis < 3; ++axis) {
write_calibration_value(blob, model, bias_offsets[axis], 100);
write_calibration_value(blob, model, low_offsets[axis], -900);
write_calibration_value(blob, model, high_offsets[axis], 1100);
}
auto first = first_report(blob);
auto second = continuation_report(blob, 0x81, 49);
uni_psmove_imu_calibration_t calibration{};
expect(uni_psmove_add_calibration_report(
&calibration, model, first.data(), first.size()) ==
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
"ZCM2 first calibration block completed too early");
expect(uni_psmove_add_calibration_report(
&calibration, model, second.data(), second.size()) ==
UNI_PSMOVE_CALIBRATION_COMPLETE,
"ZCM2 calibration did not complete");
const uint16_t accel[] = {
encode_input(model, -1000), encode_input(model, 0),
encode_input(model, 1000)};
const uint16_t gyro[] = {
encode_input(model, -900), encode_input(model, 100),
encode_input(model, 1100)};
uni_imu_fixed_sample_t output{};
expect(uni_psmove_normalize_imu(model, &calibration, accel, accel,
gyro, gyro, &output),
"ZCM2 calibrated sample was rejected");
expect(output.accel[0] == -8192 && output.accel[1] == 0 &&
output.accel[2] == 8192,
"ZCM2 signed accelerometer normalization is wrong");
expect(output.gyro[0] == -552960 && output.gyro[1] == 0 &&
output.gyro[2] == 552960,
"ZCM2 signed gyroscope normalization is wrong");
}
void test_uncalibrated_psmove_is_suppressed() {
uni_psmove_imu_calibration_t calibration{};
const uint16_t values[] = {0xffff, 0xffff, 0xffff};
uni_imu_fixed_sample_t output{{1, 2, 3}, {4, 5, 6}};
expect(!uni_psmove_normalize_imu(
UNI_PSMOVE_IMU_MODEL_ZCM1, &calibration, values, values,
values, values, &output),
"uncalibrated PS Move sample was accepted");
expect(output.accel[0] == 0 && output.accel[1] == 0 &&
output.accel[2] == 0 && output.gyro[0] == 0 &&
output.gyro[1] == 0 && output.gyro[2] == 0,
"uncalibrated PS Move motion was not neutralized");
}
} // namespace
int main() {
test_wii_accelerometer();
test_zcm1_calibration_and_normalization();
test_zcm2_calibration_and_normalization();
test_uncalibrated_psmove_is_suppressed();
if (failures != 0) {
std::cerr << failures << " IMU normalization test(s) failed\n";
return 1;
}
return 0;
}

View file

@ -0,0 +1,23 @@
#pragma once
#include <stdint.h>
struct btstack_timer_source_t {
void (*handler)(btstack_timer_source_t*);
uint32_t timeout_ms;
};
inline void btstack_run_loop_set_timer_handler(
btstack_timer_source_t* timer,
void (*handler)(btstack_timer_source_t*)) {
timer->handler = handler;
}
inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
uint32_t timeout_ms) {
timer->timeout_ms = timeout_ms;
}
inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {}
uint32_t btstack_run_loop_get_time_ms();
inline void btstack_run_loop_execute() {}

View file

@ -0,0 +1,7 @@
#pragma once
struct critical_section_t {};
inline void critical_section_init(critical_section_t*) {}
inline void critical_section_enter_blocking(critical_section_t*) {}
inline void critical_section_exit(critical_section_t*) {}

View file

@ -0,0 +1,6 @@
#pragma once
#define CYW43_WL_GPIO_LED_PIN 0
int cyw43_arch_init();
void cyw43_arch_gpio_put(int pin, bool value);

View file

@ -0,0 +1,3 @@
#pragma once
bool flash_safe_execute_core_init();

View file

@ -0,0 +1,3 @@
#pragma once
void multicore_launch_core1(void (*entry)());

View file

@ -0,0 +1,3 @@
#pragma once
void tight_loop_contents();

View file

@ -0,0 +1,151 @@
#pragma once
#include <stdint.h>
typedef uint8_t bd_addr_t[6];
typedef uint8_t link_key_t[16];
typedef uint8_t sm_key_t[16];
typedef int link_key_type_t;
enum bd_addr_type_t {
BD_ADDR_TYPE_LE_PUBLIC = 0,
BD_ADDR_TYPE_LE_RANDOM = 1,
BD_ADDR_TYPE_LE_PUBLIC_IDENTITY = 2,
BD_ADDR_TYPE_LE_RANDOM_IDENTITY = 3,
BD_ADDR_TYPE_UNKNOWN = 0xfe,
};
enum hci_link_type_t {
HCI_LINK_TYPE_SCO = 0,
HCI_LINK_TYPE_ACL = 1,
};
struct btstack_link_key_iterator_t {
int index;
};
enum {
ERROR_CODE_SUCCESS = 0,
};
typedef int uni_property_idx_t;
typedef int uni_platform_oob_event_t;
struct uni_property_t {};
enum uni_error_t {
UNI_ERROR_SUCCESS = 0,
UNI_ERROR_IGNORE_DEVICE = 1,
UNI_ERROR_INVALID_CONTROLLER = 2,
UNI_ERROR_NO_SLOTS = 3,
};
enum {
UNI_CONTROLLER_CLASS_GAMEPAD = 1,
DPAD_UP = 1 << 0,
DPAD_DOWN = 1 << 1,
DPAD_LEFT = 1 << 2,
DPAD_RIGHT = 1 << 3,
BUTTON_A = 1 << 0,
BUTTON_B = 1 << 1,
BUTTON_X = 1 << 2,
BUTTON_Y = 1 << 3,
BUTTON_SHOULDER_L = 1 << 4,
BUTTON_SHOULDER_R = 1 << 5,
BUTTON_TRIGGER_L = 1 << 6,
BUTTON_TRIGGER_R = 1 << 7,
BUTTON_THUMB_L = 1 << 8,
BUTTON_THUMB_R = 1 << 9,
MISC_BUTTON_SELECT = 1 << 0,
MISC_BUTTON_START = 1 << 1,
MISC_BUTTON_SYSTEM = 1 << 2,
MISC_BUTTON_CAPTURE = 1 << 3,
};
struct uni_gamepad_t {
uint32_t dpad;
uint32_t buttons;
uint32_t misc_buttons;
int32_t axis_x;
int32_t axis_y;
int32_t axis_rx;
int32_t axis_ry;
int32_t brake;
int32_t throttle;
int32_t accel[3];
int32_t gyro[3];
};
struct uni_controller_t {
int klass;
uni_gamepad_t gamepad;
};
struct uni_hid_device_t;
typedef void (*uni_play_dual_rumble_t)(uni_hid_device_t*, uint16_t,
uint16_t, uint8_t, uint8_t);
typedef void (*uni_set_player_leds_t)(uni_hid_device_t*, uint8_t);
typedef void (*uni_set_lightbar_color_t)(uni_hid_device_t*, uint8_t, uint8_t,
uint8_t);
struct uni_report_parser_t {
uni_set_player_leds_t set_player_leds;
uni_set_lightbar_color_t set_lightbar_color;
uni_play_dual_rumble_t play_dual_rumble;
};
enum uni_bt_conn_protocol_t {
UNI_BT_CONN_PROTOCOL_NONE,
UNI_BT_CONN_PROTOCOL_BR_EDR,
UNI_BT_CONN_PROTOCOL_BLE,
};
struct uni_bt_conn_t {
bd_addr_t btaddr;
uni_bt_conn_protocol_t protocol;
};
struct uni_hid_device_t {
uni_bt_conn_t conn;
int idx;
bool gamepad;
uni_report_parser_t report_parser;
int rumble_calls;
uint8_t last_high;
uint8_t last_low;
int lightbar_calls;
uint8_t lightbar_red;
uint8_t lightbar_green;
uint8_t lightbar_blue;
int player_led_calls;
uint8_t player_leds;
};
struct uni_platform {
const char* name;
void (*init)(int, const char**);
void (*on_init_complete)();
uni_error_t (*on_device_discovered)(bd_addr_t, const char*, uint16_t,
uint8_t);
void (*on_device_connected)(uni_hid_device_t*);
void (*on_device_disconnected)(uni_hid_device_t*);
uni_error_t (*on_device_ready)(uni_hid_device_t*);
void* on_device_oob_event;
void (*on_controller_data)(uni_hid_device_t*, uni_controller_t*);
const uni_property_t* (*get_property)(uni_property_idx_t);
void (*on_oob_event)(uni_platform_oob_event_t, void*);
void* on_device_dump;
void* on_gamepad_seat;
};
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device);
int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device);
void uni_hid_device_disconnect(uni_hid_device_t* device);
void uni_bt_allow_incoming_connections(bool enabled);
void uni_bt_start_scanning_and_autoconnect_unsafe();
void uni_bt_stop_scanning_unsafe();
void uni_bt_bredr_scan_start();
void uni_bt_bredr_scan_stop();
void uni_bt_le_scan_start();
void uni_bt_le_scan_stop();
void uni_platform_set_custom(uni_platform* platform);
int uni_init(int argc, const char** argv);

View file

@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
using io_rw_32 = volatile uint32_t;
enum gpio_override {
GPIO_OVERRIDE_NORMAL = 0,
GPIO_OVERRIDE_LOW = 2,
};
void bootsel_test_masked_write(io_rw_32* address, uint32_t values,
uint32_t mask);
inline void hw_write_masked(io_rw_32* address, uint32_t values,
uint32_t mask) {
*address = (*address & ~mask) | (values & mask);
bootsel_test_masked_write(address, values, mask);
}
#if PICO_RP2350
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB 14u
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS 0x0000c000u
#else
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB 12u
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS 0x00003000u
#endif

View file

@ -0,0 +1,3 @@
#pragma once
#define SIO_GPIO_HI_IN_QSPI_CSN_BITS 0x08000000u

View file

@ -0,0 +1,14 @@
#pragma once
#include "hardware/gpio.h"
struct ioqspi_status_ctrl_hw_t {
io_rw_32 status;
io_rw_32 ctrl;
};
struct ioqspi_hw_t {
ioqspi_status_ctrl_hw_t io[6];
};
extern ioqspi_hw_t* ioqspi_hw;

View file

@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
struct sio_hw_t {
volatile uint32_t gpio_in;
volatile uint32_t gpio_hi_in;
};
extern sio_hw_t* sio_hw;

View file

@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
#define __no_inline_not_in_flash_func(function_name) function_name
constexpr int PICO_OK = 0;
int flash_safe_execute(void (*function)(void*), void* parameter,
uint32_t enter_exit_timeout_ms);

View file

@ -0,0 +1,8 @@
#pragma once
#include <cstdint>
using absolute_time_t = uint64_t;
absolute_time_t get_absolute_time();
uint64_t to_ms_since_boot(absolute_time_t time);

View file

@ -0,0 +1,224 @@
#include "bootsel_pairing_button.h"
#include <cstdlib>
#include <cstdint>
#include <iostream>
#include <vector>
#include "hardware/gpio.h"
#include "hardware/regs/sio.h"
#include "hardware/structs/ioqspi.h"
#include "hardware/structs/sio.h"
#include "pico/flash.h"
#include "pico/time.h"
namespace {
#if PICO_RP2350
constexpr uint32_t kBootselInputMask = SIO_GPIO_HI_IN_QSPI_CSN_BITS;
#else
constexpr uint32_t kBootselInputMask = 1u << 1u;
#endif
struct FlashResponse {
int result;
bool pressed;
};
ioqspi_hw_t qspi_registers{};
sio_hw_t sio_registers{};
uint64_t now_ms = 0;
std::vector<FlashResponse> flash_responses;
std::size_t next_flash_response = 0;
std::vector<uint32_t> qspi_override_writes;
int flash_safe_calls = 0;
bool inside_flash_safe_callback = false;
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) {
int events = 0;
for (int sample = 0; sample < count; ++sample) {
if (fsm.update(BootselPairingButtonSample::kPressed)) {
++events;
}
}
return events;
}
void test_short_press() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 19) == 0,
"a 19-sample press must not complete the hold");
require(!fsm.update(BootselPairingButtonSample::kReleased),
"a short-press release must not report a hold");
require(apply_pressed(fsm, 19) == 0,
"a release must discard the previous short press");
}
void test_exact_and_long_hold_once() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 19) == 0,
"the hold must not fire before sample 20");
require(fsm.update(BootselPairingButtonSample::kPressed),
"the hold must fire on exactly sample 20");
require(apply_pressed(fsm, 100) == 0,
"a continuously held button must not repeat");
}
void test_release_and_rearm() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 20) == 1,
"the initial hold must fire once");
require(!fsm.update(BootselPairingButtonSample::kReleased),
"release must rearm without reporting an event");
require(apply_pressed(fsm, 20) == 1,
"a valid release must permit one later hold");
}
void test_unread_samples_do_not_transition() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 10) == 0,
"the first half of a hold must not fire");
for (int sample = 0; sample < 8; ++sample) {
require(!fsm.update(BootselPairingButtonSample::kUnread),
"unread press samples must not report or reset a hold");
}
require(apply_pressed(fsm, 9) == 0,
"valid pressed samples must resume after unread samples");
require(fsm.update(BootselPairingButtonSample::kPressed),
"20 valid pressed samples must fire despite unread samples");
require(!fsm.update(BootselPairingButtonSample::kUnread),
"an unread release must not rearm a completed hold");
require(apply_pressed(fsm, 20) == 0,
"the held state must persist until a valid release");
require(!fsm.update(BootselPairingButtonSample::kReleased),
"a valid release must only rearm");
require(apply_pressed(fsm, 20) == 1,
"the FSM must fire after the eventual valid release");
}
bool run_sample(uint64_t sample_time_ms, int result, bool pressed) {
flash_responses.push_back({result, pressed});
now_ms = sample_time_ms;
const std::size_t expected_consumed = flash_responses.size();
const bool event = bootsel_pairing_button_task();
require(next_flash_response == expected_consumed,
"a due poll must invoke flash_safe_execute exactly once");
return event;
}
void test_sampler_cadence_and_callback_failure() {
now_ms = 0;
require(!bootsel_pairing_button_task(),
"the sampler must wait for its first 100 ms cadence");
now_ms = 99;
require(!bootsel_pairing_button_task(),
"the sampler must not poll before 100 ms");
require(flash_safe_calls == 0,
"sub-cadence task calls must not enter flash-safe execution");
require(!run_sample(100, PICO_OK, true),
"the first valid pressed sample must only start the hold");
require(flash_safe_calls == 1 && qspi_override_writes.size() == 2,
"a successful sample must float and restore QSPI CSn once");
const uint32_t disabled =
GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB;
require(qspi_override_writes[0] == disabled,
"the callback must float QSPI CSn before reading BOOTSEL");
require(qspi_override_writes[1] == 0,
"the callback must restore normal QSPI CSn control");
now_ms = 199;
require(!bootsel_pairing_button_task(),
"the sampler must remain gated between 10 Hz polls");
require(flash_safe_calls == 1,
"an early task call must not sample BOOTSEL");
const std::size_t writes_before_failure = qspi_override_writes.size();
require(!run_sample(200, -1, true),
"flash-safe failure must be treated as unread");
require(qspi_override_writes.size() == writes_before_failure,
"a failed flash-safe entry must not invoke the callback");
for (uint64_t time = 300; time < 2100; time += 100) {
require(!run_sample(time, PICO_OK, true),
"the sampler must wait for 20 valid pressed samples");
}
require(run_sample(2100, PICO_OK, true),
"a failed sample must not reset the valid pressed count");
require(!run_sample(2200, PICO_OK, true),
"a held button must not repeat after firing");
require(!run_sample(2300, -1, false),
"a failed release sample must remain unread");
require(!run_sample(2400, PICO_OK, true),
"an unread release must not rearm the sampler FSM");
require(!run_sample(2500, PICO_OK, false),
"a valid release must rearm without firing");
for (uint64_t time = 2600; time < 4500; time += 100) {
require(!run_sample(time, PICO_OK, true),
"the rearmed sampler must count a fresh hold");
}
require(run_sample(4500, PICO_OK, true),
"a valid release must permit a second completed hold");
}
} // namespace
ioqspi_hw_t* ioqspi_hw = &qspi_registers;
sio_hw_t* sio_hw = &sio_registers;
absolute_time_t get_absolute_time() {
return now_ms;
}
uint64_t to_ms_since_boot(absolute_time_t time) {
return time;
}
void bootsel_test_masked_write(io_rw_32* address, uint32_t, uint32_t mask) {
require(inside_flash_safe_callback,
"QSPI override writes must occur inside flash_safe_execute");
require(address == &ioqspi_hw->io[1].ctrl,
"the callback must only override QSPI CSn");
qspi_override_writes.push_back(*address & mask);
}
int flash_safe_execute(void (*function)(void*), void* parameter,
uint32_t enter_exit_timeout_ms) {
require(enter_exit_timeout_ms == 100,
"BOOTSEL sampling must use the 100 ms flash-safe timeout");
require(next_flash_response < flash_responses.size(),
"flash-safe execution requires a queued test response");
++flash_safe_calls;
const FlashResponse response = flash_responses[next_flash_response++];
if (response.result != PICO_OK) {
return response.result;
}
sio_hw->gpio_hi_in = response.pressed ? 0 : kBootselInputMask;
inside_flash_safe_callback = true;
function(parameter);
inside_flash_safe_callback = false;
require((ioqspi_hw->io[1].ctrl &
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS) == 0,
"the callback must restore QSPI CSn before returning");
return PICO_OK;
}
int main() {
test_short_press();
test_exact_and_long_hold_once();
test_release_and_rearm();
test_unread_samples_do_not_transition();
test_sampler_cadence_and_callback_failure();
return 0;
}

View file

@ -0,0 +1,13 @@
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
uint32_t get_rand_32(void);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,18 @@
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
uint64_t milliseconds;
} absolute_time_t;
absolute_time_t get_absolute_time(void);
uint32_t to_ms_since_boot(absolute_time_t time);
#ifdef __cplusplus
}
#endif

45
tests/native_stubs/tusb.h Normal file
View file

@ -0,0 +1,45 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
HID_REPORT_TYPE_INVALID = 0,
HID_REPORT_TYPE_INPUT = 1,
HID_REPORT_TYPE_OUTPUT = 2,
HID_REPORT_TYPE_FEATURE = 3,
} hid_report_type_t;
typedef struct {
uint8_t bmRequestType;
uint8_t bRequest;
uint16_t wValue;
uint16_t wIndex;
uint16_t wLength;
} tusb_control_request_t;
bool tud_hid_n_ready(uint8_t instance);
bool tud_hid_n_report(uint8_t instance, uint8_t report_id,
const void* report, uint16_t length);
bool tud_suspended(void);
bool tud_remote_wakeup(void);
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type, uint8_t* buffer,
uint16_t requested_length);
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type,
const uint8_t* buffer, uint16_t buffer_size);
void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id,
const uint8_t* buffer, uint16_t buffer_size);
uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance);
void tud_mount_cb(void);
void tud_umount_cb(void);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,218 @@
#include "switch_haptics.h"
#include <array>
#include <cstdint>
#include <iostream>
namespace {
int failures = 0;
void expect_output(const char* scenario, SwitchRumbleOutput actual,
uint8_t expected_low, uint8_t expected_high) {
if (actual.low_frequency_magnitude == expected_low &&
actual.high_frequency_magnitude == expected_high) {
return;
}
std::cerr << scenario << ": expected low/high "
<< static_cast<unsigned>(expected_low) << "/"
<< static_cast<unsigned>(expected_high) << ", got "
<< static_cast<unsigned>(actual.low_frequency_magnitude) << "/"
<< static_cast<unsigned>(actual.high_frequency_magnitude) << '\n';
++failures;
}
uint32_t type_2(uint8_t high_frequency, uint8_t high_amplitude,
uint8_t low_frequency, uint8_t low_amplitude) {
return (1u << 30u) |
((static_cast<uint32_t>(low_amplitude) & 0x7fu) << 23u) |
((static_cast<uint32_t>(low_frequency) & 0x7fu) << 16u) |
((static_cast<uint32_t>(high_amplitude) & 0x7fu) << 9u) |
((static_cast<uint32_t>(high_frequency) & 0x7fu) << 2u);
}
uint32_t type_1_one_sample(uint8_t high_command, uint8_t low_command) {
return (1u << 30u) |
((static_cast<uint32_t>(low_command) & 0x1fu) << 25u) |
((static_cast<uint32_t>(high_command) & 0x1fu) << 20u);
}
uint32_t type_1_three_samples(uint8_t high_0, uint8_t low_0,
uint8_t high_1, uint8_t low_1,
uint8_t high_2, uint8_t low_2) {
return (3u << 30u) |
((static_cast<uint32_t>(low_0) & 0x1fu) << 25u) |
((static_cast<uint32_t>(high_0) & 0x1fu) << 20u) |
((static_cast<uint32_t>(low_1) & 0x1fu) << 15u) |
((static_cast<uint32_t>(high_1) & 0x1fu) << 10u) |
((static_cast<uint32_t>(low_2) & 0x1fu) << 5u) |
(static_cast<uint32_t>(high_2) & 0x1fu);
}
std::array<uint8_t, 8> payload(uint32_t left, uint32_t right) {
std::array<uint8_t, 8> bytes{};
const uint32_t words[2] = {left, right};
for (unsigned actuator = 0; actuator < 2; ++actuator) {
const unsigned offset = actuator * 4u;
bytes[offset] = static_cast<uint8_t>(words[actuator]);
bytes[offset + 1u] = static_cast<uint8_t>(words[actuator] >> 8u);
bytes[offset + 2u] = static_cast<uint8_t>(words[actuator] >> 16u);
bytes[offset + 3u] = static_cast<uint8_t>(words[actuator] >> 24u);
}
return bytes;
}
void test_neutral_and_per_actuator_reset() {
constexpr uint32_t neutral = 0x40400100u;
SwitchHapticsDecoder decoder;
auto frame = payload(neutral, neutral);
expect_output("explicit neutral", decoder.decode(frame.data()), 0, 0);
frame = payload(type_2(90, 16, 50, 127), type_2(100, 32, 40, 16));
expect_output("active actuators", decoder.decode(frame.data()), 250, 32);
decoder.reset();
frame = payload(1u << 5u, 1u << 5u);
expect_output("explicit decoder reset", decoder.decode(frame.data()), 0, 0);
frame = payload(type_2(90, 16, 50, 127), type_2(100, 32, 40, 16));
decoder.decode(frame.data());
frame = payload(0, type_2(100, 32, 40, 16));
expect_output("zero resets only left actuator", decoder.decode(frame.data()), 16, 32);
frame = payload(0, neutral);
expect_output("neutral resets right actuator", decoder.decode(frame.data()), 0, 0);
}
void test_type_2_full_state_and_band_mapping() {
constexpr uint32_t neutral = 0x40400100u;
SwitchHapticsDecoder decoder;
const auto frame = payload(type_2(100, 32, 20, 16), neutral);
expect_output("type-2 low/high mapping", decoder.decode(frame.data()), 16, 32);
}
void test_type_1_relative_update_and_idempotence() {
constexpr uint32_t neutral = 0x40400100u;
SwitchHapticsDecoder decoder;
auto frame = payload(type_2(64, 16, 64, 16), neutral);
expect_output("relative update initial state", decoder.decode(frame.data()), 16, 16);
frame = payload(type_1_one_sample(17, 20), neutral);
expect_output("type-1 relative update", decoder.decode(frame.data()), 17, 18);
expect_output("identical delta is idempotent", decoder.decode(frame.data()), 17, 18);
}
void test_subsample_peak_and_repeated_current_state() {
constexpr uint32_t neutral = 0x40400100u;
SwitchHapticsDecoder decoder;
auto frame = payload(type_2(64, 16, 64, 16), neutral);
decoder.decode(frame.data());
frame = payload(type_1_three_samples(17, 17, 29, 29, 24, 24), neutral);
expect_output("peak across three subsamples", decoder.decode(frame.data()), 18, 18);
expect_output("repeat returns final cumulative state", decoder.decode(frame.data()), 16, 16);
}
void test_left_right_peak_combination() {
SwitchHapticsDecoder decoder;
const auto frame = payload(type_2(90, 1, 50, 127), type_2(100, 32, 40, 1));
expect_output("independent actuator band peaks", decoder.decode(frame.data()), 250, 32);
}
void test_type_3_and_type_4_frames() {
constexpr uint32_t neutral = 0x40400100u;
SwitchHapticsDecoder decoder;
auto frame = payload(type_2(64, 16, 64, 16), neutral);
decoder.decode(frame.data());
const uint32_t type3 = (2u << 30u) | 1u | (70u << 1u) |
(24u << 8u) | (17u << 13u) |
(20u << 18u) | (32u << 23u);
frame = payload(type3, neutral);
expect_output("type-3 full plus relative samples", decoder.decode(frame.data()), 18, 32);
const uint32_t type4_low_amplitude = (1u << 30u) | 2u | (32u << 23u);
frame = payload(type4_low_amplitude, neutral);
expect_output("type-4 low amplitude selection", decoder.decode(frame.data()), 32, 32);
const uint32_t type4_high_amplitude = (1u << 30u) | 3u | (127u << 23u);
frame = payload(type4_high_amplitude, neutral);
expect_output("type-4 high amplitude selection", decoder.decode(frame.data()), 32, 250);
}
void test_malformed_and_reserved_words_preserve_state() {
constexpr uint32_t neutral = 0x40400100u;
SwitchHapticsDecoder decoder;
auto frame = payload(type_2(100, 32, 20, 16), neutral);
decoder.decode(frame.data());
frame = payload((1u << 30u) | 1u, neutral);
expect_output("reserved type discriminator", decoder.decode(frame.data()), 16, 32);
frame = payload(1u << 5u, neutral);
expect_output("zero-frame word clears high band", decoder.decode(frame.data()), 16, 0);
}
void test_output_report_normalization() {
const uint8_t stripped[] = {
0x0a,
0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40,
};
uint8_t output[64]{};
size_t size = normalize_switch_output_report(0x01, stripped, sizeof(stripped), output);
if (size != sizeof(stripped) + 1 || output[0] != 0x01 ||
output[1] != 0x0a || output[2] != 0x00 || output[9] != 0x40) {
std::cerr << "stripped 0x01 report normalization failed\n";
++failures;
}
size = normalize_switch_output_report(0x10, stripped, sizeof(stripped), output);
if (size != sizeof(stripped) + 1 || output[0] != 0x10 ||
output[1] != 0x0a || output[2] != 0x00 || output[9] != 0x40) {
std::cerr << "stripped 0x10 report normalization failed\n";
++failures;
}
const uint8_t complete[] = {
0x10, 0x0a,
0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40,
};
size = normalize_switch_output_report(0, complete, sizeof(complete), output);
if (size != sizeof(complete) || output[0] != 0x10 ||
output[1] != 0x0a || output[9] != 0x40) {
std::cerr << "complete interrupt report normalization failed\n";
++failures;
}
std::array<uint8_t, 64> oversized{};
if (normalize_switch_output_report(0x01, oversized.data(), oversized.size(), output) != 0) {
std::cerr << "oversized stripped report was accepted\n";
++failures;
}
}
} // namespace
int main() {
test_neutral_and_per_actuator_reset();
test_type_2_full_state_and_band_mapping();
test_type_1_relative_update_and_idempotence();
test_subsample_peak_and_repeated_current_state();
test_left_right_peak_combination();
test_type_3_and_type_4_frames();
test_malformed_and_reserved_words_preserve_state();
test_output_report_normalization();
if (failures != 0) {
std::cerr << failures << " haptics test(s) failed\n";
return 1;
}
return 0;
}

View file

@ -0,0 +1,184 @@
#include "switch_pro_descriptors.h"
#include "tusb_config.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <iostream>
#ifndef EXPECTED_HID_INSTANCE_COUNT
#error "EXPECTED_HID_INSTANCE_COUNT must be defined by the test build"
#endif
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == EXPECTED_HID_INSTANCE_COUNT,
"the requested HID instance count did not reach the descriptors");
static_assert(CFG_TUD_HID == EXPECTED_HID_INSTANCE_COUNT,
"TinyUSB HID count differs from the descriptor count");
static_assert(sizeof(switch_pro_configuration_descriptor) ==
9u + 32u * EXPECTED_HID_INSTANCE_COUNT,
"configuration descriptor has the wrong total size");
namespace {
constexpr uint8_t kConfigurationDescriptor = 0x02;
constexpr uint8_t kInterfaceDescriptor = 0x04;
constexpr uint8_t kEndpointDescriptor = 0x05;
constexpr uint8_t kHidDescriptor = 0x21;
#if EXPECTED_HID_INSTANCE_COUNT == 1
constexpr std::array<uint8_t, 41> kUartConfigurationDescriptor = {
0x09, 0x02, 0x29, 0x00, 0x01, 0x01, 0x00, 0xA0, 0xFA,
0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00,
0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0xCB, 0x00,
0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x08,
0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x08,
};
#endif
int failures = 0;
void expect(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
++failures;
}
}
uint16_t read_u16(const uint8_t* bytes) {
return static_cast<uint16_t>(bytes[0]) |
(static_cast<uint16_t>(bytes[1]) << 8u);
}
struct InterfaceContract {
bool present = false;
bool in_endpoint = false;
bool out_endpoint = false;
uint8_t endpoint_count = 0;
uint8_t hid_count = 0;
};
void inspect_configuration_descriptor() {
const auto* descriptor = switch_pro_configuration_descriptor;
constexpr size_t descriptor_size =
sizeof(switch_pro_configuration_descriptor);
#if EXPECTED_HID_INSTANCE_COUNT == 1
expect(std::memcmp(descriptor, kUartConfigurationDescriptor.data(),
descriptor_size) == 0,
"UART configuration descriptor bytes changed");
#endif
expect(descriptor[0] == 9 && descriptor[1] == kConfigurationDescriptor,
"configuration header is malformed");
expect(read_u16(descriptor + 2) == descriptor_size,
"wTotalLength does not match the emitted descriptor");
expect(descriptor[4] == EXPECTED_HID_INSTANCE_COUNT,
"bNumInterfaces does not match the HID instance count");
std::array<InterfaceContract, EXPECTED_HID_INSTANCE_COUNT> interfaces{};
std::array<bool, 256> endpoint_addresses{};
int current_interface = -1;
size_t offset = descriptor[0];
while (offset < descriptor_size) {
const uint8_t length = descriptor[offset];
expect(length >= 2, "descriptor block has an invalid length");
if (length < 2) {
break;
}
expect(offset + length <= descriptor_size,
"descriptor block extends beyond wTotalLength");
if (offset + length > descriptor_size) {
break;
}
const uint8_t type = descriptor[offset + 1];
if (type == kInterfaceDescriptor) {
expect(length == 9, "interface descriptor has the wrong length");
const uint8_t number = descriptor[offset + 2];
expect(number < interfaces.size(),
"interface number is outside the configured range");
if (number < interfaces.size()) {
expect(!interfaces[number].present,
"interface number is duplicated");
interfaces[number].present = true;
current_interface = number;
} else {
current_interface = -1;
}
expect(descriptor[offset + 3] == 0,
"interface uses an unexpected alternate setting");
expect(descriptor[offset + 4] == 2,
"interface does not declare two endpoints");
expect(descriptor[offset + 5] == 0x03,
"interface is not HID class");
} else if (type == kHidDescriptor) {
expect(current_interface >= 0,
"HID descriptor appears before an interface");
expect(length == sizeof(switch_pro_hid_descriptor),
"HID descriptor has the wrong length");
expect(std::memcmp(descriptor + offset, switch_pro_hid_descriptor,
sizeof(switch_pro_hid_descriptor)) == 0,
"interfaces do not reuse the shared HID/report contract");
expect(read_u16(descriptor + offset + 7) ==
sizeof(switch_pro_report_descriptor),
"HID descriptor advertises the wrong report descriptor size");
if (current_interface >= 0) {
++interfaces[static_cast<size_t>(current_interface)].hid_count;
}
} else if (type == kEndpointDescriptor) {
expect(current_interface >= 0,
"endpoint descriptor appears before an interface");
expect(length == 7, "endpoint descriptor has the wrong length");
const uint8_t address = descriptor[offset + 2];
expect(!endpoint_addresses[address],
"endpoint address is duplicated across interfaces");
endpoint_addresses[address] = true;
expect(descriptor[offset + 3] == 0x03,
"endpoint is not interrupt type");
expect(read_u16(descriptor + offset + 4) ==
SWITCH_PRO_ENDPOINT_SIZE,
"endpoint has the wrong maximum packet size");
expect(descriptor[offset + 6] == 8,
"endpoint has the wrong polling interval");
if (current_interface >= 0) {
auto& interface =
interfaces[static_cast<size_t>(current_interface)];
++interface.endpoint_count;
const uint8_t endpoint_number =
static_cast<uint8_t>(current_interface + 1);
if ((address & 0x80u) != 0) {
expect(address == static_cast<uint8_t>(0x80u | endpoint_number),
"IN endpoint does not belong to its interface");
interface.in_endpoint = true;
} else {
expect(address == endpoint_number,
"OUT endpoint does not belong to its interface");
interface.out_endpoint = true;
}
}
}
offset += length;
}
expect(offset == descriptor_size,
"descriptor parser did not finish at wTotalLength");
for (const auto& interface : interfaces) {
expect(interface.present, "configured HID interface is missing");
expect(interface.hid_count == 1,
"interface does not contain exactly one HID descriptor");
expect(interface.endpoint_count == 2,
"interface does not contain exactly two endpoints");
expect(interface.in_endpoint && interface.out_endpoint,
"interface is missing an IN or OUT endpoint");
}
}
} // namespace
int main() {
inspect_configuration_descriptor();
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,696 @@
#include "switch_pro_driver.h"
#include "controller_color_config.h"
#include "tusb.h"
#include "pico/time.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <iostream>
namespace {
constexpr uint8_t kInstanceCount = SWITCH_PICO_HID_INSTANCE_COUNT;
constexpr uint8_t kInvalidInstance = kInstanceCount;
static_assert(kInstanceCount == 4,
"the native driver harness must exercise four HID instances");
struct SentReport {
uint8_t instance = 0;
uint8_t report_id = 0;
uint16_t length = 0;
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> data{};
};
struct RumbleEvent {
unsigned count = 0;
uint8_t instance = 0xff;
SwitchRumbleOutput output{};
};
uint64_t now_ms = 0;
uint32_t random_value = 1;
std::array<bool, kInstanceCount> hid_ready{};
std::array<bool, kInstanceCount> hid_report_succeeds{};
std::array<unsigned, kInstanceCount> hid_report_attempts{};
std::array<SentReport, 32> sent_reports{};
unsigned sent_report_count = 0;
std::array<RumbleEvent, kInstanceCount> rumble_events{};
int failures = 0;
void expect(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
++failures;
}
}
void clear_sent_reports() {
sent_reports = {};
sent_report_count = 0;
}
void initialize_contexts() {
now_ms = 0;
hid_report_attempts = {};
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
hid_ready[instance] = true;
hid_report_succeeds[instance] = true;
switch_pro_init(instance);
}
clear_sent_reports();
}
const SentReport* latest_regular_report(uint8_t instance) {
for (unsigned i = sent_report_count; i > 0; --i) {
const SentReport& report = sent_reports[i - 1];
if (report.instance == instance &&
report.length == sizeof(SwitchProReport) &&
report.data[0] == 0x30) {
return &report;
}
}
return nullptr;
}
SwitchProReport copy_switch_report(const SentReport* sent) {
SwitchProReport report{};
if (sent != nullptr) {
std::memcpy(&report, sent->data.data(), sizeof(report));
}
return report;
}
SwitchProReport get_current_report(uint8_t instance,
const char* length_failure) {
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> data{};
expect(tud_hid_get_report_cb(instance, 0, HID_REPORT_TYPE_INPUT,
data.data(), data.size()) ==
sizeof(SwitchProReport),
length_failure);
SwitchProReport report{};
std::memcpy(&report, data.data(), sizeof(report));
return report;
}
void expect_neutral_sticks(SwitchProReport& report,
const char* state_failure) {
constexpr uint16_t packed_mid = SWITCH_PRO_JOYSTICK_MID >> 4u;
constexpr uint16_t packed_inverted_mid =
static_cast<uint16_t>(-static_cast<int32_t>(packed_mid)) & 0x0fffu;
expect(report.inputs.leftStick.getX() == packed_mid &&
report.inputs.leftStick.getY() == packed_inverted_mid &&
report.inputs.rightStick.getX() == packed_mid &&
report.inputs.rightStick.getY() == packed_inverted_mid,
state_failure);
}
unsigned reports_for_instance(uint8_t instance) {
unsigned count = 0;
for (unsigned i = 0; i < sent_report_count; ++i) {
if (sent_reports[i].instance == instance) {
++count;
}
}
return count;
}
uint32_t read_bits_le(const uint8_t* bytes, uint16_t bit_offset,
uint8_t width) {
uint32_t value = 0;
for (uint8_t bit = 0; bit < width; ++bit) {
uint16_t source_bit = static_cast<uint16_t>(bit_offset + bit);
if ((bytes[source_bit >> 3] & (1u << (source_bit & 7u))) != 0) {
value |= 1u << bit;
}
}
return value;
}
int16_t read_int16_le(const uint8_t* bytes) {
return static_cast<int16_t>(
static_cast<uint16_t>(bytes[0]) |
(static_cast<uint16_t>(bytes[1]) << 8u));
}
void send_feature(uint8_t instance, uint8_t command, uint8_t value) {
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> report{};
report[0] = REPORT_FEATURE;
report[10] = command;
report[11] = value;
tud_hid_report_received_cb(instance, 0, report.data(), report.size());
}
void send_spi_read(uint8_t instance, uint32_t address, uint8_t size) {
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> report{};
report[0] = REPORT_FEATURE;
report[10] = SPI_READ;
report[11] = static_cast<uint8_t>(address);
report[12] = static_cast<uint8_t>(address >> 8u);
report[13] = static_cast<uint8_t>(address >> 16u);
report[14] = static_cast<uint8_t>(address >> 24u);
report[15] = size;
tud_hid_report_received_cb(instance, 0, report.data(), report.size());
}
void send_config(uint8_t instance, uint8_t subtype) {
const uint8_t report[] = {REPORT_CONFIGURATION, subtype};
tud_hid_report_received_cb(instance, 0, report, sizeof(report));
}
uint32_t type_2(uint8_t high_frequency, uint8_t high_amplitude,
uint8_t low_frequency, uint8_t low_amplitude) {
return (1u << 30u) |
((static_cast<uint32_t>(low_amplitude) & 0x7fu) << 23u) |
((static_cast<uint32_t>(low_frequency) & 0x7fu) << 16u) |
((static_cast<uint32_t>(high_amplitude) & 0x7fu) << 9u) |
((static_cast<uint32_t>(high_frequency) & 0x7fu) << 2u);
}
uint32_t type_1_one_sample(uint8_t high_command, uint8_t low_command) {
return (1u << 30u) |
((static_cast<uint32_t>(low_command) & 0x1fu) << 25u) |
((static_cast<uint32_t>(high_command) & 0x1fu) << 20u);
}
std::array<uint8_t, 8> rumble_payload(uint32_t left, uint32_t right) {
std::array<uint8_t, 8> payload{};
const uint32_t words[] = {left, right};
for (unsigned actuator = 0; actuator < 2; ++actuator) {
unsigned offset = actuator * 4u;
payload[offset] = static_cast<uint8_t>(words[actuator]);
payload[offset + 1] = static_cast<uint8_t>(words[actuator] >> 8u);
payload[offset + 2] = static_cast<uint8_t>(words[actuator] >> 16u);
payload[offset + 3] = static_cast<uint8_t>(words[actuator] >> 24u);
}
return payload;
}
std::array<uint8_t, 10> complete_rumble_report(
const std::array<uint8_t, 8>& payload) {
std::array<uint8_t, 10> report{};
report[0] = REPORT_OUTPUT_10;
std::memcpy(report.data() + 2, payload.data(), payload.size());
return report;
}
void rumble_callback(uint8_t instance, const SwitchRumbleOutput& output) {
expect(instance < rumble_events.size(),
"rumble callback received an invalid instance");
if (instance >= rumble_events.size()) {
return;
}
RumbleEvent& event = rumble_events[instance];
++event.count;
event.instance = instance;
event.output = output;
}
void test_reset_materializes_neutral_sticks() {
initialize_contexts();
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
SwitchProReport initialized = get_current_report(
instance, "GET_REPORT failed immediately after init");
expect_neutral_sticks(
initialized, "instance sticks were not neutral after init");
}
tud_mount_cb();
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
SwitchProReport mounted = get_current_report(
instance, "GET_REPORT failed immediately after mount");
expect_neutral_sticks(
mounted, "instance sticks were not neutral after mount");
}
}
void test_startup_identify_preserves_first_reply_counter() {
initialize_contexts();
tud_mount_cb();
expect(!switch_pro_task(0), "startup identify counted as regular input");
expect(sent_report_count == 1 && sent_reports[0].instance == 0 &&
sent_reports[0].data[0] == REPORT_USB_INPUT_81 &&
sent_reports[0].data[1] == IDENTIFY,
"startup identify did not use the addressed raw HID route");
send_feature(0, GET_CONTROLLER_STATE, 0);
now_ms = 6;
expect(!switch_pro_task(0), "first subcommand reply counted as regular input");
expect(sent_report_count == 2 &&
sent_reports[1].data[0] == REPORT_OUTPUT_21 &&
sent_reports[1].data[1] == 0,
"startup identify consumed the first subcommand reply counter");
}
void test_failed_startup_identify_retries_preserve_counter() {
initialize_contexts();
tud_mount_cb();
hid_report_succeeds[0] = false;
switch_pro_task(0);
switch_pro_task(0);
expect(hid_report_attempts[0] == 2 && reports_for_instance(0) == 0,
"failed startup identify was not retried");
hid_report_succeeds[0] = true;
expect(!switch_pro_task(0), "retried startup identify counted as regular input");
expect(hid_report_attempts[0] == 3 && reports_for_instance(0) == 1,
"startup identify did not recover after failed sends");
send_feature(0, GET_CONTROLLER_STATE, 0);
now_ms = 6;
switch_pro_task(0);
expect(sent_report_count == 2 &&
sent_reports[1].data[0] == REPORT_OUTPUT_21 &&
sent_reports[1].data[1] == 0,
"failed startup identify retries consumed the reply counter");
}
void test_input_reports_and_timers_are_isolated() {
initialize_contexts();
std::array<SwitchInputState, kInstanceCount> states{};
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
SwitchInputState& state = states[instance];
state.lx = static_cast<uint16_t>(0x1111u * (instance + 1u));
state.ly = static_cast<uint16_t>(0x2222u + 0x1111u * instance);
state.rx = static_cast<uint16_t>(0x5555u + 0x1111u * instance);
state.ry = static_cast<uint16_t>(0x8888u + 0x1111u * instance);
}
states[0].button_a = true;
states[1].button_b = true;
states[2].button_x = true;
states[3].button_y = true;
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
switch_pro_set_input(instance, states[instance]);
}
now_ms = 15;
std::array<SwitchProReport, kInstanceCount> sent{};
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(switch_pro_task(instance),
"configured instance did not send its timed report");
const SentReport* routed = latest_regular_report(instance);
expect(routed != nullptr, "input report used the wrong HID route");
sent[instance] = copy_switch_report(routed);
expect(sent[instance].inputs.buttonA == (instance == 0) &&
sent[instance].inputs.buttonB == (instance == 1) &&
sent[instance].inputs.buttonX == (instance == 2) &&
sent[instance].inputs.buttonY == (instance == 3),
"button state crossed HID instances");
}
std::array<std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE>, kInstanceCount>
current{};
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(tud_hid_get_report_cb(instance, 0, HID_REPORT_TYPE_INPUT,
current[instance].data(),
current[instance].size()) ==
sizeof(SwitchProReport),
"GET_REPORT rejected a configured instance");
}
for (uint8_t left = 0; left < kInstanceCount; ++left) {
for (uint8_t right = static_cast<uint8_t>(left + 1u);
right < kInstanceCount; ++right) {
expect(std::memcmp(current[left].data(), current[right].data(),
current[left].size()) != 0,
"GET_REPORT returned shared state across HID instances");
}
}
SwitchInputState changed_zero = states[0];
changed_zero.button_a = false;
changed_zero.button_home = true;
switch_pro_set_input(0, changed_zero);
now_ms = 30;
expect(switch_pro_task(0),
"instance 0 did not apply its changed input state");
SwitchProReport unchanged_three = get_current_report(
3, "GET_REPORT failed for instance 3 after instance 0 changed");
expect(unchanged_three.inputs.buttonY &&
!unchanged_three.inputs.buttonHome,
"instance 0 input change leaked into instance 3");
SwitchInputState changed_three = states[3];
changed_three.button_y = false;
changed_three.button_capture = true;
switch_pro_set_input(3, changed_three);
now_ms = 45;
expect(switch_pro_task(3),
"instance 3 did not apply its changed input state");
SwitchProReport unchanged_zero = get_current_report(
0, "GET_REPORT failed for instance 0 after instance 3 changed");
expect(unchanged_zero.inputs.buttonHome &&
!unchanged_zero.inputs.buttonCapture,
"instance 3 input change leaked into instance 0");
}
void test_callback_send_and_imu_modes_are_isolated() {
initialize_contexts();
send_feature(0, TOGGLE_IMU, 1);
now_ms = 6;
expect(!switch_pro_task(0), "feature reply was reported as regular input");
expect(reports_for_instance(0) == 1,
"feature callback reply did not use instance 0");
expect(reports_for_instance(1) == 0,
"feature callback queued a reply on instance 1");
SwitchInputState zero{};
zero.lx = zero.ly = zero.rx = zero.ry = SWITCH_PRO_JOYSTICK_MID;
zero.imu_sample_count = 1;
zero.imu_samples[0] = {101, 202, 303, 404, 505, 606};
SwitchInputState one = zero;
one.button_x = true;
one.imu_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006};
switch_pro_set_input(0, zero);
switch_pro_set_input(1, one);
now_ms = 21;
expect(switch_pro_task(0), "raw-IMU instance did not send input");
expect(switch_pro_task(1), "off-IMU instance timer did not send input");
SwitchProReport raw = copy_switch_report(latest_regular_report(0));
SwitchProReport off = copy_switch_report(latest_regular_report(1));
expect(read_int16_le(raw.imuData) == 101 &&
read_int16_le(raw.imuData + 6) == 404,
"instance 0 raw IMU sample was not preserved");
std::array<uint8_t, 36> zero_imu{};
expect(std::memcmp(off.imuData, zero_imu.data(), zero_imu.size()) == 0,
"instance 0 IMU mode leaked into instance 1");
initialize_contexts();
send_feature(0, TOGGLE_IMU, 2);
send_feature(1, TOGGLE_IMU, 2);
now_ms = 6;
switch_pro_task(0);
switch_pro_task(1);
SwitchInputState moving{};
moving.lx = moving.ly = moving.rx = moving.ry = SWITCH_PRO_JOYSTICK_MID;
moving.imu_sample_count = 1;
moving.imu_samples[0] = {100, 200, 300, 20000, 0, 0};
SwitchInputState stationary{};
stationary.lx = stationary.ly = stationary.rx = stationary.ry =
SWITCH_PRO_JOYSTICK_MID;
stationary.imu_sample_count = 1;
stationary.imu_samples[0] = {1000, 2000, 3000, 0, 0, 0};
switch_pro_set_input(0, moving);
switch_pro_set_input(1, stationary);
now_ms = 21;
expect(switch_pro_task(0), "moving quaternion instance did not report");
expect(switch_pro_task(1), "stationary quaternion timer crossed instances");
SwitchProReport moving_report =
copy_switch_report(latest_regular_report(0));
SwitchProReport stationary_report =
copy_switch_report(latest_regular_report(1));
bool moving_component =
read_bits_le(moving_report.imuData, 52, 21) != 0 ||
read_bits_le(moving_report.imuData, 73, 21) != 0 ||
read_bits_le(moving_report.imuData, 94, 2) != 0 ||
read_bits_le(moving_report.imuData, 144, 19) != 0;
bool stationary_component =
read_bits_le(stationary_report.imuData, 52, 21) != 0 ||
read_bits_le(stationary_report.imuData, 73, 21) != 0 ||
read_bits_le(stationary_report.imuData, 94, 2) != 0 ||
read_bits_le(stationary_report.imuData, 144, 19) != 0;
expect(moving_component, "moving quaternion did not integrate");
expect(!stationary_component,
"instance 0 quaternion state leaked into instance 1");
expect(read_int16_le(stationary_report.imuData) == 2000 &&
read_int16_le(stationary_report.imuData + 2) == 1000,
"instance 1 quaternion accelerometer state was overwritten");
}
void test_grip_colors_are_isolated() {
initialize_contexts();
constexpr uint32_t grip_address =
0x6000u + offsetof(SwitchFactoryConfig, leftGripColor);
constexpr uint8_t grip_bytes =
sizeof(SwitchColorDefinition) * 2u;
constexpr SwitchRgbColor calibrated_blue =
switch_pro_calibrate_light_color({0x00, 0x89, 0xEB});
constexpr SwitchRgbColor calibrated_gray =
switch_pro_calibrate_light_color({0x96, 0x96, 0x96});
static_assert(calibrated_blue.red == 0x00 &&
calibrated_blue.green == 0x35 &&
calibrated_blue.blue == 0x9D);
static_assert(calibrated_gray.red == 0x64 &&
calibrated_gray.green == 0x64 &&
calibrated_gray.blue == 0x64);
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
send_spi_read(instance, grip_address, grip_bytes);
now_ms += 6;
expect(!switch_pro_task(instance),
"grip color SPI reply counted as regular input");
expect(sent_report_count == static_cast<unsigned>(instance + 1u),
"grip color SPI reply was not sent");
const SentReport& response = sent_reports[sent_report_count - 1u];
const SwitchRgbColor expected =
switch_pro_get_slot_color(instance);
const uint8_t expected_bytes[] = {
expected.red, expected.green, expected.blue,
expected.red, expected.green, expected.blue,
};
expect(response.instance == instance &&
response.data[13] == 0x90 &&
response.data[14] == SPI_READ &&
std::memcmp(response.data.data() + 20, expected_bytes,
sizeof(expected_bytes)) == 0,
"Switch grip color did not match its HID slot");
const SwitchRgbColor light =
switch_pro_get_slot_light_color(instance);
const SwitchRgbColor calibrated =
switch_pro_calibrate_light_color(expected);
expect(light.red == calibrated.red &&
light.green == calibrated.green &&
light.blue == calibrated.blue,
"physical controller light was not derived from its grip");
}
const SwitchRgbColor invalid_grip =
switch_pro_get_slot_color(kInvalidInstance);
const SwitchRgbColor invalid_light =
switch_pro_get_slot_light_color(kInvalidInstance);
expect(invalid_grip.red == 0 && invalid_grip.green == 0 &&
invalid_grip.blue == 0 && invalid_light.red == 0 &&
invalid_light.green == 0 && invalid_light.blue == 0,
"invalid HID slot returned a configured color");
}
void test_rumble_callbacks_and_decoders_are_isolated() {
initialize_contexts();
rumble_events = {};
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
switch_pro_set_rumble_callback(instance, rumble_callback);
}
constexpr uint32_t neutral = 0x40400100u;
auto full_payload = rumble_payload(type_2(64, 16, 64, 16), neutral);
auto full_report = complete_rumble_report(full_payload);
tud_hid_report_received_cb(kInvalidInstance, 0, full_report.data(),
full_report.size());
for (const auto& event : rumble_events) {
expect(event.count == 0,
"invalid output instance reached a rumble callback");
}
std::array<uint8_t, 9> stripped{};
std::memcpy(stripped.data() + 1, full_payload.data(), full_payload.size());
tud_hid_set_report_cb(0, REPORT_OUTPUT_10, HID_REPORT_TYPE_OUTPUT,
stripped.data(), stripped.size());
expect(rumble_events[0].count == 1 && rumble_events[0].instance == 0,
"control output did not route to instance 0 callback");
expect(rumble_events[0].output.low_frequency_magnitude == 16 &&
rumble_events[0].output.high_frequency_magnitude == 16,
"instance 0 full rumble state decoded incorrectly");
for (uint8_t instance = 1; instance < kInstanceCount; ++instance) {
expect(rumble_events[instance].count == 0,
"instance 0 rumble invoked another instance callback");
}
auto delta_payload = rumble_payload(type_1_one_sample(17, 20), neutral);
auto delta_report = complete_rumble_report(delta_payload);
tud_hid_report_received_cb(1, 0, delta_report.data(), delta_report.size());
expect(rumble_events[1].count == 1 && rumble_events[1].instance == 1,
"interrupt output did not route to instance 1 callback");
expect(rumble_events[1].output.low_frequency_magnitude == 0 &&
rumble_events[1].output.high_frequency_magnitude == 1,
"instance 1 decoder inherited instance 0 rumble state");
tud_hid_report_received_cb(0, 0, delta_report.data(), delta_report.size());
expect(rumble_events[0].count == 2 &&
rumble_events[0].output.low_frequency_magnitude == 17 &&
rumble_events[0].output.high_frequency_magnitude == 18,
"instance 0 decoder lost its own prior rumble state");
for (uint8_t instance = 2; instance < kInstanceCount; ++instance) {
const uint8_t magnitude = instance == 2 ? 16 : 32;
auto payload =
rumble_payload(type_2(64, magnitude, 64, magnitude), neutral);
auto report = complete_rumble_report(payload);
tud_hid_report_received_cb(instance, 0, report.data(), report.size());
expect(rumble_events[instance].count == 1 &&
rumble_events[instance].instance == instance,
"rumble output did not route to its configured instance");
expect(rumble_events[instance].output.low_frequency_magnitude ==
magnitude &&
rumble_events[instance].output.high_frequency_magnitude ==
magnitude,
"configured instance decoded another rumble context");
}
expect(rumble_events[1].count == 1,
"another instance's rumble reached instance 1 callback");
}
void test_lifecycle_and_invalid_instances() {
initialize_contexts();
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(switch_pro_is_ready(instance),
"initialized context was not ready");
}
tud_mount_cb();
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(!switch_pro_is_ready(instance),
"mount did not reset every configured context");
}
for (uint8_t addressed = 0; addressed < kInstanceCount; ++addressed) {
send_config(addressed, DISABLE_USB_TIMEOUT);
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(switch_pro_is_ready(instance) == (instance <= addressed),
"handshake readiness crossed configured contexts");
}
}
tud_umount_cb();
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(!switch_pro_is_ready(instance),
"unmount did not reset every configured context");
}
SwitchInputState ignored{};
ignored.button_home = true;
switch_pro_init(kInvalidInstance);
switch_pro_set_input(kInvalidInstance, ignored);
switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback);
expect(!switch_pro_task(kInvalidInstance),
"invalid instance ran a driver task");
expect(!switch_pro_is_ready(kInvalidInstance),
"invalid instance reported ready");
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> buffer{};
expect(tud_hid_get_report_cb(kInvalidInstance, 0, HID_REPORT_TYPE_INPUT,
buffer.data(), buffer.size()) == 0,
"invalid instance served GET_REPORT data");
expect(tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr,
"invalid instance served a report descriptor");
}
void test_uart_parser_is_pure() {
initialize_contexts();
SwitchInputState driver_state{};
driver_state.lx = driver_state.ly = driver_state.rx = driver_state.ry =
SWITCH_PRO_JOYSTICK_MID;
driver_state.button_x = true;
switch_pro_set_input(0, driver_state);
now_ms = 15;
switch_pro_task(0);
std::array<uint8_t, 12> packet{};
packet[0] = 0xaa;
packet[1] = 0x02;
packet[2] = 8;
uint16_t buttons = SWITCH_PRO_MASK_A | SWITCH_PRO_MASK_L;
packet[3] = static_cast<uint8_t>(buttons);
packet[4] = static_cast<uint8_t>(buttons >> 8u);
packet[5] = SWITCH_PRO_HAT_DOWNLEFT;
packet[6] = 0x12;
packet[7] = 0x34;
packet[8] = 0x56;
packet[9] = 0x78;
for (unsigned i = 0; i < packet.size() - 1; ++i) {
packet.back() = static_cast<uint8_t>(packet.back() + packet[i]);
}
SwitchInputState parsed{};
expect(switch_pro_apply_uart_packet(packet.data(), packet.size(), parsed),
"valid UART packet was rejected");
expect(parsed.button_a && parsed.button_l && parsed.dpad_down &&
parsed.dpad_left,
"UART buttons or hat were parsed incorrectly");
expect(parsed.lx == 0x1212 && parsed.ly == 0x3434 &&
parsed.rx == 0x5656 && parsed.ry == 0x7878,
"UART axes were parsed incorrectly");
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> current{};
tud_hid_get_report_cb(0, 0, HID_REPORT_TYPE_INPUT, current.data(),
current.size());
SwitchProReport current_report{};
std::memcpy(&current_report, current.data(), sizeof(current_report));
expect(current_report.inputs.buttonX && !current_report.inputs.buttonA,
"UART parsing mutated driver context state");
SwitchInputState unchanged{};
unchanged.button_home = true;
unchanged.lx = 123;
packet.back() ^= 0xffu;
expect(!switch_pro_apply_uart_packet(packet.data(), packet.size(),
unchanged),
"invalid UART checksum was accepted");
expect(unchanged.button_home && unchanged.lx == 123,
"failed UART parse modified its output reference");
}
} // namespace
extern "C" absolute_time_t get_absolute_time(void) {
return {now_ms};
}
extern "C" uint32_t to_ms_since_boot(absolute_time_t time) {
return static_cast<uint32_t>(time.milliseconds);
}
extern "C" uint32_t get_rand_32(void) {
return random_value++;
}
extern "C" bool tud_hid_n_ready(uint8_t instance) {
return instance < SWITCH_PICO_HID_INSTANCE_COUNT && hid_ready[instance];
}
extern "C" bool tud_hid_n_report(uint8_t instance, uint8_t report_id,
const void* report, uint16_t length) {
if (instance >= SWITCH_PICO_HID_INSTANCE_COUNT || report == nullptr ||
length > SWITCH_PRO_ENDPOINT_SIZE) {
return false;
}
++hid_report_attempts[instance];
if (!hid_report_succeeds[instance] ||
sent_report_count >= sent_reports.size()) {
return false;
}
SentReport& sent = sent_reports[sent_report_count++];
sent.instance = instance;
sent.report_id = report_id;
sent.length = length;
std::memcpy(sent.data.data(), report, length);
return true;
}
extern "C" bool tud_suspended(void) {
return false;
}
extern "C" bool tud_remote_wakeup(void) {
return true;
}
int main() {
test_reset_materializes_neutral_sticks();
test_startup_identify_preserves_first_reply_counter();
test_failed_startup_identify_retries_preserve_counter();
test_input_reports_and_timers_are_isolated();
test_callback_send_and_imu_modes_are_isolated();
test_rumble_callbacks_and_decoders_are_isolated();
test_grip_colors_are_isolated();
test_lifecycle_and_invalid_instances();
test_uart_parser_is_pure();
if (failures != 0) {
std::cerr << failures << " driver context test(s) failed\n";
return 1;
}
return 0;
}

View file

@ -0,0 +1,43 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "bluepad32_backend_lifecycle_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-DSWITCH_PICO_HID_INSTANCE_COUNT=4",
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{root}",
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
for scenario in (
"ready-forward",
"ready-reverse",
"rejections",
"lifecycle",
"pairing-policy",
"slot-lighting",
"flash-core-start",
"flash-core-failure",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -0,0 +1,30 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_bluepad32_imu_normalization_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "bluepad32_imu_normalization_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'external' / 'bluepad32' / 'src' / 'components' / 'bluepad32' / 'include'}",
str(root / "tests" / "bluepad32_imu_normalization_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_bootsel_pairing_button_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
for platform, rp2350 in (("rp2350", 1), ("rp2040", 0)):
executable = tmp_path / f"bootsel_pairing_button_test_{platform}"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-DPICO_RP2350={rp2350}",
f"-I{root / 'tests' / 'bootsel_native_stubs'}",
f"-I{root}",
str(root / "bootsel_pairing_button.cpp"),
str(root / "tests" / "bootsel_pairing_button_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -31,7 +31,7 @@ class RecordingUART:
def send_report(self, report: SwitchReport) -> None:
self.sent_imu.append(tuple(report.imu_samples))
def read_rumble_payload(self) -> bytes | None:
def read_rumble(self) -> tuple[float, float] | None:
return None

View file

@ -0,0 +1,339 @@
"""
Tests for prepare_bluepad32.py patch preparation tool.
Tests cover:
- Fresh patch application
- Idempotence (second invocation succeeds without changing content)
- Missing paths validation
- Diverged/ambiguous repository states
"""
import subprocess
import tempfile
import pytest
from pathlib import Path
from unittest.mock import patch as mock_patch
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "tools"))
from prepare_bluepad32 import (
PatchError,
resolve_paths,
check_paths,
is_patch_applied,
apply_patch,
prepare_bluepad32,
)
@pytest.fixture
def temp_repo_structure():
"""Create a temporary directory structure with git repositories."""
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Create bluepad32 repo
bp_dir = root / "external" / "bluepad32"
bp_dir.mkdir(parents=True)
subprocess.run(["git", "init"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=bp_dir, check=True, capture_output=True)
# Create a file to patch
test_file = bp_dir / "test.txt"
test_file.write_text("line 1\n")
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "initial"], cwd=bp_dir, check=True, capture_output=True)
# Create patches dir
patches_dir = root / "patches"
patches_dir.mkdir()
yield root, bp_dir, patches_dir
def create_simple_patch(repo_path: Path, patch_path: Path, file_to_patch: str = "test.txt") -> str:
"""
Create a simple patch file that modifies a file in the repository.
Returns the patch content as a string.
"""
# Create the modification
test_file = repo_path / file_to_patch
original_content = test_file.read_text()
modified_content = original_content + "line 2\n"
# Generate patch using git diff
test_file.write_text(modified_content)
result = subprocess.run(
["git", "diff", file_to_patch],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
patch_content = result.stdout
# Reset the file to original state
test_file.write_text(original_content)
# Write patch to file
patch_path.write_text(patch_content)
return patch_content
def test_resolve_paths_with_defaults():
"""Test that resolve_paths returns expected default paths."""
with tempfile.TemporaryDirectory() as tmpdir:
bp_path, patch_path = resolve_paths(Path(tmpdir))
assert bp_path == Path(tmpdir) / "external" / "bluepad32"
assert patch_path == Path(tmpdir) / "patches" / "bluepad32-sdl3-imu.patch"
def test_resolve_paths_no_root():
"""Test resolve_paths with no root uses current directory."""
bp_path, patch_path = resolve_paths()
assert bp_path.is_absolute()
assert patch_path.is_absolute()
def test_check_paths_missing_bluepad32(temp_repo_structure):
"""Test that check_paths fails if bluepad32 dir is missing."""
root, bp_dir, patches_dir = temp_repo_structure
# Remove bluepad32
import shutil
shutil.rmtree(bp_dir)
patch_file = patches_dir / "test.patch"
patch_file.write_text("dummy")
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
check_paths(bp_dir, patch_file)
def test_check_paths_missing_patch(temp_repo_structure):
"""Test that check_paths fails if patch file is missing."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "nonexistent.patch"
with pytest.raises(PatchError, match="patch file does not exist"):
check_paths(bp_dir, patch_file)
def test_check_paths_bluepad32_not_git_repo(temp_repo_structure):
"""Test that check_paths fails if bluepad32 is not a git repo."""
root, bp_dir, patches_dir = temp_repo_structure
# Remove .git to make it not a git repo
import shutil
shutil.rmtree(bp_dir / ".git")
patch_file = patches_dir / "test.patch"
patch_file.write_text("dummy")
with pytest.raises(PatchError, match="not a git repository"):
check_paths(bp_dir, patch_file)
def test_fresh_patch_application(temp_repo_structure):
"""Test applying a fresh patch to a clean repository."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Verify test.txt before patch
test_file = bp_dir / "test.txt"
original = test_file.read_text()
assert "line 2" not in original
# Apply patch
apply_patch(bp_dir, patch_file)
# Verify test.txt after patch
patched = test_file.read_text()
assert "line 2" in patched
def test_idempotent_patch_application(temp_repo_structure):
"""Test that applying the same patch twice succeeds (idempotence)."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# First application
apply_patch(bp_dir, patch_file)
test_file = bp_dir / "test.txt"
after_first = test_file.read_text()
# Second application should succeed without changing content
apply_patch(bp_dir, patch_file)
after_second = test_file.read_text()
assert after_first == after_second
def test_is_patch_applied_not_applied(temp_repo_structure):
"""Test is_patch_applied returns False for unapplied patch."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Patch not applied yet
assert is_patch_applied(bp_dir, patch_file) is False
def test_is_patch_applied_already_applied(temp_repo_structure):
"""Test is_patch_applied returns True for already applied patch."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Apply patch first
apply_patch(bp_dir, patch_file)
# Now check should detect it's applied
assert is_patch_applied(bp_dir, patch_file) is True
def test_diverged_repository_state(temp_repo_structure):
"""Test that diverged repository (patch doesn't apply cleanly) is rejected."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Diverge the repository by modifying the file such that the patch conflicts
test_file = bp_dir / "test.txt"
test_file.write_text("completely different line 1\n")
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "divergence"], cwd=bp_dir, check=True, capture_output=True)
# Try to apply patch - should fail because file content doesn't match
with pytest.raises(PatchError, match="Patch validation failed"):
apply_patch(bp_dir, patch_file)
def test_missing_bluepad32_path(temp_repo_structure):
"""Test prepare_bluepad32 fails gracefully with missing bluepad32."""
root, bp_dir, patches_dir = temp_repo_structure
import shutil
shutil.rmtree(bp_dir)
patch_file = patches_dir / "test.patch"
patch_file.write_text("dummy")
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
prepare_bluepad32(bp_dir, patch_file)
def test_missing_patch_file(temp_repo_structure):
"""Test prepare_bluepad32 fails gracefully with missing patch file."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "nonexistent.patch"
with pytest.raises(PatchError, match="patch file does not exist"):
prepare_bluepad32(bp_dir, patch_file)
def test_prepare_bluepad32_full_workflow(temp_repo_structure):
"""Test complete prepare_bluepad32 workflow: apply then idempotent re-apply."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
test_file = bp_dir / "test.txt"
original = test_file.read_text()
# First prepare (should apply patch)
prepare_bluepad32(bp_dir, patch_file)
after_first = test_file.read_text()
assert after_first != original
assert "line 2" in after_first
# Second prepare (should be idempotent)
prepare_bluepad32(bp_dir, patch_file)
after_second = test_file.read_text()
assert after_first == after_second
def test_prepare_bluepad32_with_defaults(temp_repo_structure):
"""Test prepare_bluepad32 uses correct defaults when paths not provided."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "bluepad32-sdl3-imu.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Change to root directory and call with defaults
import os
original_cwd = os.getcwd()
try:
os.chdir(root)
prepare_bluepad32() # Use defaults
finally:
os.chdir(original_cwd)
# Verify patch was applied
test_file = bp_dir / "test.txt"
assert "line 2" in test_file.read_text()
def test_patch_application_with_conflicting_content(temp_repo_structure):
"""Test that patch with conflicting content is rejected."""
root, bp_dir, patches_dir = temp_repo_structure
# Create a patch that adds a specific change
patch_content = """--- a/test.txt
+++ b/test.txt
@@ -1 +1,3 @@
line 1
+line 2
+line 3
"""
patch_file = patches_dir / "conflict.patch"
patch_file.write_text(patch_content)
# Modify the file to have different content that won't match the patch context
test_file = bp_dir / "test.txt"
test_file.write_text("modified line 1\n")
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "modify"], cwd=bp_dir, check=True, capture_output=True)
# Try to apply patch - should fail due to context mismatch
with pytest.raises(PatchError):
apply_patch(bp_dir, patch_file)
def test_cli_with_explicit_paths(temp_repo_structure):
"""Test CLI argument parsing with explicit paths."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Simulate CLI call
sys.argv = [
"prepare_bluepad32.py",
"--bluepad32", str(bp_dir),
"--patch", str(patch_file),
]
from prepare_bluepad32 import main
# Should not raise
try:
main()
except SystemExit as e:
# main() calls sys.exit on success, which we need to catch
if e.code != 0:
raise

View file

@ -0,0 +1,31 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_switch_haptics_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "switch_haptics_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root}",
str(root / "switch_haptics.cpp"),
str(root / "tests" / "switch_haptics_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,89 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def compile_descriptor_test(
root: Path,
compiler: str,
output: Path,
expected_count: int,
configured_count: int | None,
) -> subprocess.CompletedProcess[str]:
command = [
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-DEXPECTED_HID_INSTANCE_COUNT={expected_count}",
]
if configured_count is not None:
command.append(f"-DSWITCH_PICO_HID_INSTANCE_COUNT={configured_count}")
command.extend(
[
f"-I{root}",
str(root / "tests" / "switch_pro_descriptors_test.cpp"),
"-o",
str(output),
]
)
return subprocess.run(
command,
check=False,
cwd=root,
text=True,
capture_output=True,
)
def host_compiler() -> str:
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
return compiler
def test_default_descriptor_contract_is_single_hid(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
executable = tmp_path / "switch_pro_descriptors_default_test"
result = compile_descriptor_test(root, host_compiler(), executable, 1, None)
assert result.returncode == 0, result.stderr
subprocess.run([str(executable)], check=True, cwd=root)
def test_supported_descriptor_contracts(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = host_compiler()
for instance_count in range(1, 5):
executable = (
tmp_path / f"switch_pro_descriptors_{instance_count}_test"
)
result = compile_descriptor_test(
root,
compiler,
executable,
instance_count,
instance_count,
)
assert result.returncode == 0, result.stderr
subprocess.run([str(executable)], check=True, cwd=root)
def test_unsupported_hid_instance_counts_fail_to_compile(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = host_compiler()
for unsupported_count in (0, 5):
executable = tmp_path / f"switch_pro_descriptors_invalid_{unsupported_count}"
result = compile_descriptor_test(
root,
compiler,
executable,
unsupported_count,
unsupported_count,
)
assert result.returncode != 0, (
f"unsupported HID instance count {unsupported_count} compiled successfully"
)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_switch_pro_driver_four_contexts_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "switch_pro_driver_context_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-DSWITCH_PICO_HID_INSTANCE_COUNT=4",
f"-I{root / 'tests' / 'native_stubs'}",
f"-I{root}",
str(root / "switch_pro_driver.cpp"),
str(root / "switch_haptics.cpp"),
str(root / "tests" / "switch_pro_driver_context_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

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

101
tests/test_uart_rumble.py Normal file
View file

@ -0,0 +1,101 @@
"""Focused tests for decoded UART rumble delivery to SDL3."""
from argparse import Namespace
from io import StringIO
from typing import cast
import pytest
import sdl3
from rich.console import Console
import switch_pico_bridge.controller_uart_bridge as bridge
from switch_pico_bridge.switch_pico_uart import PicoUART, SwitchReport, UART_BAUD
class RecordingUART:
def __init__(self) -> None:
self.rumble: list[tuple[float, float]] = []
def send_report(self, _report: SwitchReport) -> None:
pass
def read_rumble(self) -> tuple[float, float] | None:
if not self.rumble:
return None
return self.rumble.pop(0)
def make_config() -> bridge.BridgeConfig:
return bridge.BridgeConfig(
interval=10.0,
deadzone_raw=0,
trigger_threshold=0,
zero_sticks=False,
zero_hotkey="",
swap_hotkey="",
button_map_default={},
button_map_swapped={},
swap_abxy_indices=set(),
swap_abxy_ids=set(),
swap_abxy_global=False,
no_imu=True,
)
def test_apply_rumble_maps_low_and_high_with_50ms_duration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[int, int, int]] = []
monkeypatch.setattr(
bridge.sdl3,
"SDL_RumbleGamepad",
lambda _controller, low, high, duration: calls.append((low, high, duration)),
)
controller = cast(sdl3.SDL_Gamepad, object())
assert bridge.apply_rumble(controller, 1.0, 0.5)
assert calls[-1] == (0xFFFF, 0x7FFF, 50)
assert not bridge.apply_rumble(controller, 0.0, 0.0)
assert calls[-1] == (0, 0, 50)
def test_repeated_constant_rumble_stays_active_until_idle_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[int, int, int]] = []
monkeypatch.setattr(
bridge.sdl3,
"SDL_RumbleGamepad",
lambda _controller, low, high, duration: calls.append((low, high, duration)),
)
monkeypatch.setattr(bridge, "poll_controller_buttons", lambda _ctx, _map: None)
uart = RecordingUART()
controller = cast(sdl3.SDL_Gamepad, object())
ctx = bridge.ControllerContext(
controller,
7,
0,
"controller",
"/dev/null",
cast(PicoUART, cast(object, uart)),
)
contexts = {ctx.instance_id: ctx}
args = Namespace(baud=UART_BAUD)
console = Console(file=StringIO())
magnitude = (64 / 255.0, 192 / 255.0)
uart.rumble.append(magnitude)
bridge.service_contexts(1.0, args, make_config(), contexts, [], console)
uart.rumble.append(magnitude)
bridge.service_contexts(1.7, args, make_config(), contexts, [], console)
bridge.service_contexts(1.71, args, make_config(), contexts, [], console)
assert calls == [(16448, 49344, 50), (16448, 49344, 50)]
assert ctx.rumble_active
bridge.service_contexts(1.96, args, make_config(), contexts, [], console)
assert calls[-1] == (0, 0, 0)
assert not ctx.rumble_active

85
tools/prepare_bluepad32.py Executable file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Apply the project Bluepad32 patch exactly once."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
class PatchError(RuntimeError):
pass
def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path]:
root = Path.cwd() if repo_root is None else Path(repo_root)
return (
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
)
def check_paths(bluepad32_path: Path, patch_path: Path) -> None:
if not bluepad32_path.is_dir():
raise PatchError(f"bluepad32 directory does not exist: {bluepad32_path}")
if not (bluepad32_path / ".git").exists():
raise PatchError(f"Bluepad32 is not a git repository: {bluepad32_path}")
if not patch_path.is_file():
raise PatchError(f"patch file does not exist: {patch_path}")
def git_apply(bluepad32_path: Path, patch_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(bluepad32_path), "apply", *args, str(patch_path)],
capture_output=True,
text=True,
check=False,
)
def is_patch_applied(bluepad32_path: Path, patch_path: Path) -> bool:
return git_apply(bluepad32_path, patch_path, "--reverse", "--check").returncode == 0
def apply_patch(bluepad32_path: Path, patch_path: Path) -> None:
if is_patch_applied(bluepad32_path, patch_path):
return
check = git_apply(bluepad32_path, patch_path, "--check")
if check.returncode != 0:
detail = check.stderr.strip() or "patch does not apply"
raise PatchError(f"Patch validation failed (repository may be diverged):\n{detail}")
result = git_apply(bluepad32_path, patch_path)
if result.returncode != 0:
detail = result.stderr.strip() or "git apply failed"
raise PatchError(f"Could not patch Bluepad32: {detail}")
def prepare_bluepad32(
bluepad32_path: Path | None = None,
patch_path: Path | None = None,
) -> None:
default_bluepad32, default_patch = resolve_paths()
dependency = Path(bluepad32_path or default_bluepad32)
patch = Path(patch_path or default_patch)
check_paths(dependency, patch)
apply_patch(dependency, patch)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bluepad32", type=Path)
parser.add_argument("--patch", type=Path)
args = parser.parse_args()
try:
prepare_bluepad32(args.bluepad32, args.patch)
except PatchError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,11 +1,19 @@
// TinyUSB configuration tailored for a single Switch Pro style HID interface.
// Data is derived from TinyUSB examples and tuned for a 64-byte HID endpoint.
// TinyUSB configuration for one to four Switch Pro style HID interfaces.
// Each interface uses independent 64-byte interrupt IN and OUT endpoints.
#ifndef _TUSB_CONFIG_H_
#define _TUSB_CONFIG_H_
#ifdef __cplusplus
extern "C" {
#endif
#ifndef SWITCH_PICO_HID_INSTANCE_COUNT
#define SWITCH_PICO_HID_INSTANCE_COUNT 1
#endif
#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4
#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4"
#endif
#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED)
#ifndef CFG_TUSB_OS
@ -23,7 +31,7 @@ extern "C" {
#define CFG_TUD_ENDPOINT0_SIZE 64
// Device class configuration
#define CFG_TUD_HID 1
#define CFG_TUD_HID SWITCH_PICO_HID_INSTANCE_COUNT
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
#define CFG_TUD_MIDI 0