No description
Find a file
2026-09-07 18:39:30 -06:00
.vscode Initial commit 2025-11-20 11:20:19 -07:00
bluepad32_config fix: reconnect Joy-Con mates and balance mixed Bluetooth links 2026-09-07 12:04:41 -06:00
examples Rearrange python code 2025-12-08 19:36:01 -07:00
external Add Pico 2 W Bluepad32 AIO backend 2026-08-29 17:37:48 -06:00
firmware fix: reserve Classic reconnect airtime with two Switch 2 links 2026-09-07 18:39:30 -06:00
patches fix: reconnect Joy-Con mates and balance mixed Bluetooth links 2026-09-07 12:04:41 -06:00
src fix: reserve Classic reconnect airtime with two Switch 2 links 2026-09-07 18:39:30 -06:00
tests fix: reserve Classic reconnect airtime with two Switch 2 links 2026-09-07 18:39:30 -06:00
tools Add Switch 2 wake capture and replay 2026-09-04 23:27:02 -06:00
udev Correct macOS HID axis mapping 2026-09-03 11:11:35 -06:00
.gitattributes Normalize Wii and PS Move motion 2026-08-31 12:12:44 -06:00
.gitignore Add Switch 2 wake capture and replay 2026-09-04 23:27:02 -06:00
.gitmodules Add Pico 2 W Bluepad32 AIO backend 2026-08-29 17:37:48 -06:00
ADAPTER_PARITY_PLAN.md fix: reserve Classic reconnect airtime with two Switch 2 links 2026-09-07 18:39:30 -06:00
build.py fix: use sustainable native haptics cadence for mixed controllers 2026-09-05 22:34:20 -06:00
CMakeLists.txt fix: reconnect Joy-Con mates and balance mixed Bluetooth links 2026-09-07 12:04:41 -06:00
HAPTICS_EXPERIMENT.md feat: add opt-in native Nintendo rumble backend 2026-09-05 23:58:50 -06:00
LICENSE Add license 2025-12-14 14:47:49 -07:00
pico_sdk_import.cmake Initial commit 2025-11-20 11:20:19 -07:00
pyproject.toml Add graphical controller profile editor 2026-09-03 21:55:02 -06:00
README.md fix: reserve Classic reconnect airtime with two Switch 2 links 2026-09-07 18:39:30 -06:00
requirements.txt add requirements.txt 2025-11-25 11:37:09 -07:00
SWITCH_FAMILY_HD_RUMBLE_PLAN.md feat: coordinate native rumble with deadline-aware scheduling 2026-09-06 15:12:06 -06:00
uv.lock Add physical and USB pairing management 2026-09-01 08:19:11 -06:00

Switch Pico Controller Bridge

Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from Bluetooth controllers through Bluepad32.

What you get

  • Firmware (src/firmware/): acts as a Switch Pro controller (one on standard Pico, four on Pico 2 W AIO), accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
  • 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. Hotplug friendly and crossplatform (macOS/Windows/Linux).
  • Color configuration (src/firmware/platform/pico/controller_color_config.h): compile-time RGB colors for emulated controller grips and supported Bluetooth controller LEDs.
  • Pico 2 W AIO firmware (firmware/switch-pico-aio.uf2): hosts four concurrent Bluetooth controllers and sends their controls, calibrated motion, rumble, and slot identity through four separate Switch Pro USB interfaces without a computer.

Source layout

Firmware code has one include root, src/firmware, with responsibility-based modules:

Path Responsibility
src/firmware/main.cpp Firmware entry point and backend orchestration
src/firmware/adapter/ USB mode selection, host probing, and managed reboot
src/firmware/configuration/ Persistent adapter configuration and transactions
src/firmware/core/ Shared controller identity, color, and input-state types
src/firmware/input/ Bluepad32 controller input backend and hotkeys
src/firmware/platform/pico/ Pico flash/BOOTSEL integrations and compile-time board configuration
src/firmware/profile/ Controller profiles, transforms, storage, and runtime
src/firmware/usb/ USB output boundary, management protocol, and per-protocol drivers

Internal includes are rooted at src/firmware, for example #include "profile/controller_profile.h". Host-side Python remains in src/switch_pico_bridge/; native firmware tests remain in tests/.

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).
  2. Wire Pico UART1 to a USB↔UART adapter (GPIO4 TX, GPIO5 RX, GND) and plug that adapter into your host PC.
  3. Enable System Settings → Controllers and Sensors → Pro Controller Wired Communication on the Switch.
  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:

git submodule update --init external/bluepad32

Build and flash a Pico 2 W in BOOTSEL mode:

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 copy the pinned Bluepad32 source into the active build directory and apply patches/bluepad32-sdl3-imu.patch there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The external/bluepad32 submodule remains pristine; patch or source-revision drift fails configuration.

Switch 2 wake from L + R + Home, PS, or Xbox

The AIO firmware can wake a sleeping Switch 2 when a connected controller's physical L + R + System chord becomes held: L + R + Home on Nintendo-style controllers, L1 + R1 + PS on PlayStation controllers, or LB + RB + Xbox on Xbox controllers. Plain Home, PS, or Xbox remains a normal button and does not start wake advertising. Setup needs one wake advertisement captured from a Joy-Con 2 already paired with that Switch 2. The generated configuration is console-specific and is intentionally ignored by Git.

The implementation replays the captured, unencrypted Switch 2 BLE wake advertisement for two seconds at a 20 ms base interval. The configured AIO firmware adopts the captured Joy-Con's public Bluetooth address once during startup, before Bluepad32 admits controller connections. Wake bursts then require no identity change and do not disconnect the input controller. This follows the packet format documented by ndeadly/switch2_controller_research and the capture/replay approach demonstrated by alexvnesta/switch2controller and the MIT-licensed Switch2-Wake-Beacon-ESPHome.

Back up the complete Pico flash before replacing the AIO firmware with the temporary capture image:

picotool save -a -v switch-pico-before-wake-capture.uf2

Then:

  1. Connect the Pico 2 W to the computer and build/flash the one-shot capture firmware:

    python3 build.py --wake-capture
    

    This also publishes firmware/switch-pico-wake-capture.elf and firmware/switch-pico-wake-capture.uf2.

  2. Start the configuration tool. It auto-detects a single Pico USB serial port; use --port /dev/ttyACM0 when more than one Pico is attached:

    python3 tools/configure_switch2_wake.py
    
  3. Detach a Joy-Con 2 that is already paired with the target Switch 2, put the console to sleep, and press that Joy-Con's Home button. Do not press its sync button. The capture firmware accepts the first public ADV_IND packet with Nintendo's Switch 2 wake flag and nonzero console address, stops scanning automatically, lights the onboard LED solid, and repeats the captured record until the tool receives it.

  4. The tool validates the packet and atomically writes src/firmware/platform/pico/switch2_wake_config.h.

  5. Restore the AIO firmware with the generated wake configuration:

    python3 build.py --aio
    
  6. The Pico now has a new stable Bluetooth host address. Clear its old Bluepad32 bonds by holding BOOTSEL for ten seconds, then open a pairing window and pair each input controller again. This is a one-time re-pair.

The capture tool also accepts a saved serial log:

python3 tools/configure_switch2_wake.py --input switch2-joycon-capture.log

With the configured AIO firmware powered while the console sleeps, first turn on the paired input controller with Home, PS, or Xbox and let it reconnect to the Pico. Then hold L + R and press its system button to send one wake burst. Holding the chord does not retrigger it; release at least one chord button before another attempt. Plain Home, PS, or Xbox is forwarded normally and does not disturb the radio. Because the wake identity is stable from startup, the wake code itself does not disconnect the input controller.

Hardware testing found that the Switch 2 can briefly remove USB power while entering or leaving sleep. A Pico powered only by that port necessarily reboots, resetting the CYW43439 and dropping every controller link regardless of the wake implementation. Continuous controller connectivity therefore requires a properly isolated powered USB hub or another power arrangement that keeps the Pico powered without backfeeding the console. Do not use an unisolated USB Y-cable.

The configured Pico continuously owns the captured Joy-Con's public Bluetooth address, so keep that Joy-Con inactive while the AIO firmware is running to avoid two radios using one address.

To target another Switch 2, repeat the capture and configuration steps. To disable wake, delete the generated switch2_wake_config.h, rebuild the AIO firmware, clear the Pico's bonds, and pair the input controllers to its restored factory address. Restore the full-flash backup only if you need to recover the exact pre-capture firmware and persistent state.

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 BOOTSEL for about two seconds until the onboard LED starts double-blinking. This enables new Bluetooth authentication for 60 seconds.
  4. Put a controller into Bluetooth pairing mode:
    • DualSense: hold Create + PS.
    • DualShock 4: hold Share + PS.
    • Switch Pro: press its sync button.
    • Switch 2 Pro / Joy-Con 2: hold SYNC while the Pico pairing window is open; do not pair through the PC's Bluetooth settings.
    • 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 deadline by 60 seconds from that point.

Pairing order determines the initial USB slot assignment. Up to four physical Bluetooth controllers are supported. Ordinary controllers each occupy one emulated Switch Pro interface; a merged Joy-Con 2 pair consumes two Bluetooth connections but occupies one USB player slot.

With no active controller, the Pico runs Bluepad32 discovery and autoconnect. After any controller becomes active, active discovery pauses to protect input, motion, and rumble latency; bonded controllers may still initiate incoming reconnects. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button. Hold BOOTSEL for the bounded pairing window before pairing a new controller or a controller that requires host-side discovery. Outside that window, BTstack remains non-bondable and rejects new Classic and BLE authentication.

Exception: a ready solo Joy-Con 2 keeps a low-duty passive BLE scan running for a remembered opposite half while physical capacity remains and no controller setup is pending. This reconnect does not require BOOTSEL, start Classic inquiry, or enable fresh pairing. Scanning stops when the pair completes; an explicit pairing window restores normal discovery.

To clear every stored Classic, BLE and proprietary Switch 2 pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears remembered controllers, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. A persistent-storage failure is reported rather than acknowledging a successful clear.

LED meanings and device state

The Pico 2 W onboard LED reports the overall Bluetooth state:

  • Double blink: new controller authentication is enabled for the bounded pairing window.
  • Rapid blink for two seconds: all stored pairings were cleared.
  • Fast blink: a controller connection is still completing its handshake.
  • Solid: at least one controller is active.
  • Slow blink: no controller is active; Bluetooth discovery and autoconnect are running.
  • 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: power it on normally with its Home, PS, or Xbox button.
  • 8BitDo Ultimate Bluetooth reconnect: leave its selector in Bluetooth mode, press Home once, then shake it. After an abrupt controller power-off, the Pico can remain solid for up to four seconds while Bluetooth link supervision confirms the disconnect; scanning restarts immediately afterward.
  • Pair a new controller: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
  • Pairing window expires: new authentication and full discovery stop while a controller is active; remembered controllers may still initiate reconnects, and a solo Joy-Con 2 resumes low-duty discovery of its remembered opposite half.
  • Clear all pairings: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again.

Joy-Con 2 player arrangement

In Controller Studio, use Joy-Con 2 default mode and Apply default mode. This is a real adapter-wide setting, separate from the artwork preview:

  • Paired: ready opposite halves form one player and use their L+R bank.
  • Individual: both halves can remain connected as separate sideways players, each using its own solo bank. Select the L or R owner to edit its settings.

Changes apply live without Bluetooth disconnection or fresh pairing. Affected input, macro/capture and rumble epochs are cleared; unrelated players and all saved banks remain intact. Unmatched halves stay solo. The default persists across reboot:

uv run switch-pico-config joycon-mode
uv run switch-pico-config joycon-mode individual
uv run switch-pico-config joycon-mode paired --json

Join or split a specific pair from the controllers

  • On the left Joy-Con 2, hold ZL + Minus.
  • On the right Joy-Con 2, hold ZR + Plus.
  • Hold both chords together for 2 seconds, until the short confirmation pulse, then release all four buttons.

Two solo halves join; an existing pair splits. Other controllers are untouched. An explicit join keeps the lower of the two participating player slots, not whichever slot belongs to the left half. Both LEDs follow that retained slot; the other slot is neutralized. It never takes a slot from an unrelated player. The shortcut does not change the saved default: its connection-only override ends when either participating half disconnects, or when the adapter default changes. Existing solo/pair banks and active selections are reused.

Each half's complete two-button chord is reserved while held and until both buttons on that half release, even if the other half has not joined the attempt. Those inputs do not reach profile macros or the console. Other inputs remain available. Fresh held reports from both halves are required; stale, failed or ambiguous attempts require release before retry. When several solo halves are armed ambiguously, no arbitrary pair is chosen. A chord cannot steal a member from a different already-joined pair.

For a non-destructive disconnect/reset, Nintendo documents pressing SYNC once, then a normal button to wake the Joy-Con 2.

Configuration schema 4 keeps the 232-byte object and stores the default mode in byte 4 (0 Paired, 1 Individual); bytes 57 remain reserved. Schemas 13 migrate to Paired, preserving USB mode, pairing-window duration and existing native approvals. Older firmware cannot save this setting.

Managing configuration, profiles, and pairings from a PC

Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. switch-pico-config uses versioned private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux hidraw nodes.

uv run switch-pico-config status
uv run switch-pico-config diagnostics
uv run switch-pico-config reboot bootsel
uv run switch-pico-config config show
uv run switch-pico-config config set --pairing-window-seconds 90
uv run switch-pico-config config reset --yes
uv run switch-pico-config mode auto
uv run switch-pico-config mode switch
uv run switch-pico-config mode xinput
uv run switch-pico-config mode dinput
uv run switch-pico-config mode mac
uv run switch-pico-config profiles list
uv run switch-pico-config profiles edit
uv run switch-pico-config profiles export 1 profile.json --identity 0
uv run switch-pico-config profiles import 2 profile.json --identity 0
uv run switch-pico-config profiles activate 2 --identity 0
uv run switch-pico-config profiles reset all --identity 0 --yes
uv run switch-pico-config pairings list
uv run switch-pico-config pairings clear --yes

Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use separate append-only arenas before the adapter and Bluepad32 bond regions. Profile writes are individually checksummed, recover the previous record after interruption or corruption, compact atomically between arenas, skip unchanged data, and are rate-limited.

Output mode is selected before TinyUSB starts and never changes while mounted. A mode command atomically stores the selection, resets synthetic input, reboots, follows the same physical USB port through re-enumeration, and verifies requested versus active mode. auto uses the verified Switch probe → Windows XInput transition; manual modes bypass probing. The controller chord L + R + Select + Start + System held for three seconds cycles auto → switch → xinput → dinput → mac → auto. The destructive ten-second BOOTSEL pairing reset also restores auto before reboot, providing physical recovery.

Development USB identities are CAFE:4010 (XInput), CAFE:4020 (DInput), and CAFE:4021 (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble.

profiles edit starts a local-only browser editor at http://127.0.0.1:8765/. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use Start from defaults for a new draft, then Save to Pico. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use profiles edit --no-browser for a printed URL or profiles edit --port PORT to choose another local port.

Switch 2's C, GL, GR, Left SL/SR and Right SL/SR are additional source-only controls. Map each to a normal button or trigger, assign a button-only alternate Shift mapping, or use it in action/macro chords, cancellation and modifiers. Extra mappings default to disabled. The live playtest shows raw extras separately; they are not fictitious output channels on the emulated Switch Pro/XInput controller.

Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2 left/right solo and paired layouts, original Switch Pro, DualSense, Xbox, and Wii Remote/Nunchuk views. Hotspots follow the artwork's actual coordinates; solo Joy-Con views rotate with their firmware input mappings. Rear buttons and rails are labeled below the front view rather than drawn in fictitious positions. On narrow screens, pan the diagram or use the Source control menu.

Auto uses matching-owner live metadata to distinguish a Joy-Con pair from a solo half. Preview changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Wii orientation is not reported, so horizontal/vertical views require an explicit preview.

Joy-Con 2 pair profiles: the first successful join of an L + R combination creates a separate Nintendo Joy-Con 2 (L + R) owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Splitting or losing a half restores solo banks; joining the same members again restores their existing pair bank without copying. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings.

Pair keys contain both complete Bluetooth addresses and address types, in canonical L/R order. They retain the 14-byte identity size: transport byte 1 is 3; byte 0 contains the stable bit plus the left/right static-random flags in bits 1/2; bytes 27 and 813 contain the left and right addresses. Pair keys are profile identities, not Bluetooth peers or native-output approvals. Update host tools with firmware when using this identity kind.

The read-only playtest endpoint (0x39) uses schema 4, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), Wii Remote (4), or Wii Remote + Nunchuk (5). Host tools still read schema 2/54-byte and schema 3/55-byte payloads; older firmware cannot confirm Joy-Con topology. This metadata does not change profile records, input mapping, or rumble.

Profile names and controller aliases are stored as independently checksummed catalog metadata. Runtime profiles use schema 7 and unchanged 384-byte records; schemas 16 migrate with extra inputs unmapped and existing settings preserved. Names remain separate. The editor can rename and copy profiles across controllers and slots, import or export JSON backups, and reset one section without discarding the rest of the draft. Its response-curve cards provide named presets, exact Q8.8 fine adjustment, live curve markers, and one-click application to the opposite stick or trigger. Connected-controller details include transport, battery, and supported feedback/motion capabilities; Identify sends one bounded rumble/light pulse only to the selected live controller.

profiles list prints identity index 0 for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are 1 through 8; --identity uses the zero-based index from profiles list.

pairings list refreshes and prints stored Bluetooth Classic and BLE addresses, including Switch 2's application-level authorizations. pairings clear --yes forgets them all, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require --yes. If multiple compatible Picos are attached, select one with --bus N --address N; the error lists their locations. USB access errors require permission to the matching /dev/bus/usb device.

diagnostics reports Bluetooth initialization stage, real BTstack timer callbacks, controller report traffic, host/local rumble requests and dispatches, active/rumble-capable slot counts, and pending feedback. The AIO build services one CYW43 packet per poll and explicitly reschedules remaining input. Packet-level ring reads and bounded incoming-credit batching reduce bus work without disabling flow control. haptics-experiment profile --json adds transport timings, clock/voltage settings and packet-size diagnostics. Switch 2 native output adds separate ingress and output-stage drop counters. The management response extends from 32 to 40 bytes; the updated host tool still reads older 32-byte responses and treats their missing counters as unreported, not zero. These count firmware queue discards/rejections, not physical actuator-delivery receipts.

Per-controller profiles

The profile editor lists Cycle active profile, Toggle motion, and Run custom macro as separate editable actions. Every action chord can contain any combination of the 16 buttons, L2/R2 analog triggers and seven Switch 2 extra inputs. The default profile-switching chord is L + R + Select + Start; on DualSense, use L1 + R1 + Create + Options. A stored empty chord selects that default.

  • The chord cycles persistent profiles 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 1.
  • Chord buttons are consumed locally and are not forwarded to the host.
  • The new profile applies only after its atomic flash commit completes.
  • Confirmation uses one to eight 75 ms pulses matching the active profile number.
  • The profile policy independently enables rumble and LED feedback.
  • On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number.
  • Each controller identity and each of the four active USB slots remain isolated.

Profile hotkeys use physical controls, with trigger thresholds from the base profile. Reserved output-mode handling runs first, followed by direct profile shortcuts, profile cycling, and motion toggle. Shift consumes its modifier, macro bindings use unshifted controls, Turbo gates physical button sources, the selected button map and base analog transforms produce output, and explicit macro overrides apply last.

  • Direct shortcuts: assign one modifier and unique face/D-pad selectors to profiles 18. Chord controls are consumed; holding or changing a profile cannot retrigger the same press. Activation and feedback wait for the atomic commit. The existing one-second minimum commit interval still applies to rapid successive changes.
  • Shift: one alternate button-only map, enabled while held or toggled on a fresh modifier press. It does not layer analog tuning, Turbo settings, or macro definitions. Toggle state resets with connection, profile, mode, and configuration changes.
  • Turbo/Burst: shared settings with optional per-button overrides; 130 Hz, 199% duty, and 1255 finite Burst pulses. Hold Turbo follows the button, Auto Burst toggles continuous repetition, and Burst runs its configured count after a press. Defaults remain 15 Hz/50%. Counts describe scheduled ON windows; narrow phases can be missed by host report sampling, so the editor warns instead of silently changing settings.
  • Macros: four independently triggered sequences, up to eight steps each, sharing sixteen decoded steps and a 136-byte sparse stream. Wait/button/all-field steps cost 3/5/17 bytes. Playback supports Once, While held, Toggle, and bounded Repeat (1255 cycles). Repeating zero-duration sequences are rejected; clock gaps skip elapsed cycles rather than replay a backlog.
  • Authoring: insert, duplicate, remove, drag-reorder, or move steps with keyboard controls. Duration and byte budgets update on every edit. The visual preview shows overridden versus passthrough fields and never injects controller output.
  • Recording: Record input captures timestamped firmware-side changes before profile mapping, not the editor's 75 ms snapshots. Choose channels and explicit analog quantization; the initial state consumes one entry and long holds split at ten seconds. The UI limits capture to the remaining 8/16/136 budget, visibly reports capacity/time/disconnect endings, and retains data for review. Use recorded steps changes only the unsaved macro; Save to Pico is still separate. Input-report and millisecond playback precision remain real limits.

Per-controller motion toggle

The default motion action is D-pad Up + R + Start; on DualSense, use D-pad Up + R1 + Options. Each profile can replace it with any button/trigger chord from the graphical editor; a stored empty chord selects the default.

  • A longer rumble confirms motion disabled.
  • A shorter rumble confirms motion enabled.
  • The chord is consumed locally and is not forwarded to the Switch.
  • Other controller slots are unaffected.
  • Motion returns to enabled after disconnect or reboot.

Edit src/firmware/input/controller_hotkey_config.h only to change the default motion-enabled state or its feedback patterns.

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 src/firmware/platform/pico/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
Switch 2 Pro Yes, including remappable C/GL/GR Yes, amplitude translation Yes
Joy-Con 2 solo / merged pair Implemented; hardware qualification pending Implemented Implemented
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 Grip + impulse-trigger motors (Microsoft Xbox parser) 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.

Switch 2 controller input

The AIO firmware implements the proprietary BLE protocol for Nintendo 057E:2069 (Pro), 057E:2067 (left Joy-Con 2), and 057E:2066 (right Joy-Con 2). This is controller input support, distinct from the existing Switch 2 console-wake feature and from emulating a native Switch 2 USB controller.

  • Pairing: fresh SYNC pairing requires the existing bounded pairing window. A directed reconnect must target this adapter's Bluetooth address and match its persistent application-level authorization. These links are unencrypted and are not authenticated SMP bonds. No global Bluetooth security downgrade is made; automatic SMP requests for these devices fail closed while other controllers retain their existing policy. Public/static addresses can own profiles; transient private addresses are not promoted to persistent identities.
  • Joy-Con ownership: the saved player-mode default and connection-only shortcut determine whether ready opposite halves join. Enrollment must finish before a pair is published; a failed join leaves the solos intact. Pair identity includes both typed member addresses, and the right half supplies paired motion. Splitting clears stale input/effects and restores sideways solo banks. Changing the default does not disconnect Bluetooth; a participant disconnect clears its temporary grouping override. Two pairs exhaust the four physical Bluetooth connections. New authentication remains restricted to the normal pairing window.
  • Protocol: service, characteristic and CCCD UUIDs are discovered rather than trusting fixed ATT handles. Setup requires matching acknowledgements, reads user/factory stick calibration and gyro bias, and rejects malformed/failed transactions. Motion is normalized to the existing SDL-oriented units; sensor clock/range classification and physical axis accuracy still need wider model qualification.
  • Rumble: Switch HD commands now retain independent left/right frequency/amplitude fields and up to three ordered subframes through the native Switch 2 encoder and bounded queues described below. XInput and local feedback retain their conventional fixed-carrier behavior. This is separate from the original Switch-native opt-in backend.
  • Not implemented: Joy-Con mouse output, native GameChat signaling, NFC/IR and Switch 2 NSO GameCube support. C and back/rail inputs can instead be remapped to controls the selected USB mode supports.

This is a scoped reimplementation informed by Bluepad32 PR #219, reviewed at 9c95e43a87d3bd8a68565da0836d8a758bd8d8af, not a wholesale fork import. Protocol references: ndeadly's research, Nadeflore, Switch2Connect, and SDL's Switch 2 sensor implementation.

Verification: 349 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use real BTstack types/accessors. Lifecycle/storage tests cover player-mode transitions, both connection orders, two-pair membership, bank isolation and interrupted initialization. Gesture cases cover timing/freshness/wrap, input masking, real macro/capture cancellation, ambiguity, failures, and disconnect/default restoration. Studio controls, draft preservation, CLI refresh and desktop/mobile layouts were exercised in Chromium.

On hardware, the new L + R owner copied all eight left-bank profiles and names, bringing the inventory to eight owners / 64 profiles without changing the original 56. A temporary edit and name on inactive pair profile 8 survived reboot while both solo banks stayed unchanged; pair activation was also independent. Test edits, names and active selections were restored. Studio selected the real composite owner and its Identify action produced two physical rumble dispatches.

Hardware mode changes split the pair into two live USB slots and recombined it without fresh pairing; Individual remained selected after reboot. The user felt join and split confirmation pulses from the physical shortcut. Saved configuration generation stayed unchanged during each gesture. A temporary split under a Paired default ended after resetting/reconnecting the right half, restoring the original pair. The final saved default is Individual; all 64 profiles, names, aliases and active selections were preserved.

At the initial Switch 2 input checkpoint, a real Pro (3C:A9:AB:65:73:12) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored; all 32 then-existing profiles, metadata and active selections were preserved, with adapter configuration generation 13 / CRC 3af5ee18 unchanged. Subsequent native-rumble and pair measurements are documented here and below; wider sensor-axis, perceptual-equivalence and long-duration transport qualification remains open. Use schema-7-capable firmware after saving expanded profiles.

Switch 2 native HD rumble

Switch-mode host commands use decoded HD parameters, not the compatibility strong/weak peak values. Pro output preserves two independent actuators; paired Joy-Con 2 output routes each source side to its physical half. A solo Joy-Con uses the louder source independently for each band, retaining that band's frequency, with left winning ties and shorter sequences holding their final sample. Profiles scale amplitudes before this conversion.

Physical microphone characterization on the Pro Controller established:

  • Each five-byte sample contains two 10-bit frequency + 10-bit amplitude fields. The measured frequency model is Hz ~= 10 * 2^((code - 1) / 96). Original low/high indices map to 193 + 3*index / 289 + 3*index; index 64 therefore produces codes 385/481 (160/320 Hz).
  • Block headers 0x50, 0x60, 0x70, plus the four-bit sequence counter, select one, two or three valid samples. Unused slots are zeroed. Filling three slots under 0x50 does not play the later slots.
  • A randomized 90-packet run using the other actuator as an acoustic timing reference measured 5.27 ms/frame, ±0.16 ms statistical 95% interval. Acoustic/threshold systematic error is not included. The sender uses conservative 6/11/16 ms submission guards, not a claim of exact onset.

Linear Q0.15 amplitudes use SDL's conservative 29000/65535 envelope, producing native codes 0453. This preserves a linear input curve but is not calibrated physical-force equivalence; it can feel different from compatibility rumble. Source frequency indices are bounded to 1127.

Each logical slot has a 16-command cross-core ingress FIFO; each physical Switch 2 controller has a 16-command transport FIFO. Commands keep their original receipt time and connection/output generation. Native commands expire after 50 ms; a batch that cannot fit its complete playback guard before that deadline is discarded rather than started halfway stale. Expiring unplayed history does not interrupt current playback or force a useless HOLD ahead of fresh work. Consecutive identical one-sample holds may refresh a pending command; multi-sample sequences are never coalesced.

Stops flush older host work, including under backpressure. Local feedback owns a separate bounded override while host state advances underneath it; resuming uses the current valid final sample, not a replay of masked history. Keepalives likewise send only the final sample with count 1. Pending ATT write-request buffers remain immutable; late completion cannot resurrect an old epoch after stop, reconnect or Joy-Con topology change. Normal GATT-client busy responses are treated as transient backpressure, not as controller disconnects.

Final single-Pro hardware runs:

Workload Result
Stereo, frequency sweep, and three-subframe patterns (195 USB reports) Zero ingress/output drops; 1,193 input reports continued
512 changing one-subframe commands at 125.14 Hz Zero ingress/output drops; clean stop
128 changing three-subframe commands at 125.11 Hz Zero ingress drops; 69 output-stage commands discarded/superseded; clean stop and empty ingress

Paired Joy-Con 2 hardware runs exercised left-only, right-only, stereo and ordered-subframe effects: 197 host requests produced 394 physical dispatches with zero ingress/output drops. The user confirmed both sides vibrated as intended. Held one-subframe commands at 125 Hz, changing one-subframe commands at 62.5 Hz, and three-subframe commands at 50 Hz completed without drops. Higher changing workloads discarded output-stage commands, but the pair remained connected after the GATT backpressure fix.

Three-subframe commands at 125 Hz exceed the native playback budget. These results do not establish lossless arbitrary workloads or perceptual equivalence. All 40 profiles, names, active selections and adapter configuration were preserved during this upgrade.

Automatic mixed-controller scheduling: whenever at least two physical Switch 2 BLE links are present, the firmware requests a 30 ms connection interval for those links, even before a Classic Bluetooth controller connects. A single Switch 2 link uses 7.5 ms. A merged Joy-Con pair counts as two links; Individual mode uses the same physical-link policy. Unrelated BLE controllers are not retimed; bonds, profiles, HD encoding and the DualSense timeout stay unchanged. The policy reconciles asynchronous negotiation and topology changes. Waiting for a Classic connection before reserving its airtime left a reconnect timing hole: DualSense could fail to connect with both Joy-Cons already active. Keeping 30 ms after Classic disconnect also leaves airtime for its next attempt.

This trades additional Switch 2 input, gyro and rumble delivery latency for Classic radio time. Multiple reports can travel per connection event, so 30 ms does not impose a 33-report/s limit. Two Joy-Con 2 links at 7.5 ms caused native DualSense PCM timeouts in hardware testing. With the automatic policy, both negotiated 30 ms and DualSense started without manual re-arming. A production-firmware 30-second simultaneous rumble run retained all three connections and native streaming: zero ingress drops or send failures, 14 Joy-Con output-stage discards and nine DualSense PCM skips. This is not a lossless or long-duration qualification. Lifecycle regressions cover airtime reservation before Classic arrival in Paired and Individual modes, Classic departure/reconnect, a missing half, handle reuse, late negotiation completion, clock wrap and transient request rejection. The earlier policy passed one physical DualSense power-off/reconnect check, but subsequent use exposed intermittent connection failures. The preconnection airtime regression fails under that policy and passes with the current one. The user confirmed reconnects and correct actuator output/stops with the preconnection 30 ms policy. A 15 ms trial also reconnected, but its native DualSense stream timed out even while sending silence. Under the same 125 Hz-per-slot held-effect/silence workload at 300 MHz, 15 ms failed around four seconds (111 PCM packets sent, 38 skipped, one send failure); 30 ms completed 30 seconds (1,384 sent, 47 skipped, no send failures). All three controllers remained connected. The 30 ms run had zero host-update or Switch 2 ingress drops and seven Joy-Con output-stage drops; its separate DualSense fixture delivered all 288 packets without skips. This supports keeping 30 ms, not a lossless or long-duration claim. The 15 ms trial is not the release default. The preconnection policy passed all 349 tests and four affected firmware builds.

Rumble per controller

Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps.

Microsoft controllers (045E) using Bluepad32's Xbox parser now add impulse-trigger rumble while retaining the existing strong/weak grip output. Switch high-band amplitude drives the corresponding left/right trigger, taking the peak across each command's substeps and capping the added output at half scale. Conventional/XInput high-frequency magnitude drives both triggers at half strength. Profile rumble scaling applies before this mapping; local confirmation/identify pulses remain grip-only. All four motors share the existing duration/stop handling. This is amplitude-only translation, not HD/PCM playback or adaptive-trigger resistance; the compatibility mailbox and transport cadence are unchanged.

The connected Classic Xbox (045E:02E0) was exercised with 307 USB reports: two rounds of left high-band, right high-band, and both, with intervening stops. It remained connected, and configuration generation 13 / CRC 3af5ee18 was preserved. Firmware counters confirmed host rumble dispatch; the user tested the effect and accepted it as good. The 261-test suite passed, including Xbox side isolation, amplified trigger-only output, conventional mapping, stop, and disconnect coverage.

The standard AIO and XInput builds use 300 MHz at 1.3 V, packet-level CYW43 reads, bounded HCI credit returns, and native DualSense haptics by default. The first eligible DualSense/DualSense Edge that becomes ready can occupy the one native PCM stream, in any slot; later controllers do not steal it. Nintendo native output is a separate, explicit per-controller opt-in described below. Unapproved and unsupported controllers retain their existing parser-specific output. To change the selected DualSense manually, stop the current run and use haptics-experiment gameplay --slot N (API slots are zero-based).

In Switch mode, that stream preserves decoded left/right, low/high-band HD commands. In XInput mode, strong/low magnitude drives the left 160 Hz carrier and weak/high drives the right 320 Hz carrier; these commands stay active until changed or stopped. XInput does not supply Nintendo frequency/substep detail. USB reset, unmount, and suspend stop held host rumble. Auto-mode XInput additionally reboots to Switch probe after unmount, by the existing one-attachment policy; manual XInput is exempt.

Standard native gameplay uses 64 stereo frames at 3 kHz per Bluetooth report (46.875 reports/s), with 21.333 ms causal lookback. The 32-frame mode passed single-controller tests but skipped audio slots under mixed Pro/DualSense load, so it is an explicit experiment: SWITCH_PICO_HD_PACKET_FRAMES=32 requires the optimized transport and at least 300 MHz. It uses 93.75 reports/s and 10.667 ms lookback but is not the mixed-controller default. Native streaming continues silence while idle; no physical actuator-onset bound is claimed. See HAPTICS_EXPERIMENT.md.

400 MHz is an explicit experiment: use SWITCH_PICO_SYS_CLOCK_MHZ=400 and SWITCH_PICO_OVERCLOCK_MV=1400. This board did not boot at 400 MHz/1.3 V; 1.4 V booted and passed a short run but did not outperform 300 MHz in the comparison. USB stays at 48 MHz and flash/radio bus dividers remain bounded. UART builds are unchanged; a stock-clock AIO build is an explicit recovery/compatibility option, not the normal default.

Native Nintendo rumble — opt-in, qualification in progress

The AIO backend can send Nintendo report 0x10 directly to an explicitly approved original Pro Controller or standalone Joy-Con. Approval is keyed to the physical Bluetooth identity and applies across all eight profiles; matching a Nintendo name or VID/PID does not enable it automatically.

uv run switch-pico-config profiles list
uv run switch-pico-config config native-rumble approve --identity N --yes
uv run switch-pico-config config native-rumble status --json
uv run switch-pico-config config native-rumble revoke --identity N

Use the physical controller's row from profiles list, not the global fallback. config native-rumble list also supplies approval indices; revoke --approval N can remove an approval after its profile-catalog entry has been forgotten. Approvals persist in adapter configuration schema 4 (232 bytes), alongside the Joy-Con default mode. Schema 1/2 migration starts with no approvals; schema 3 migration preserves its approval list. Profile schema 7/catalog 3 are separate.

The native encoder preserves safe unity bytes when synchronized, otherwise encodes independent actuator/band/substep state with documented quantization. It has no DualSense PCM lookback or response curve. Native output shares a per-device counter and effective rumble state with LED subcommands. Identical held states are coalesced without changing the 50 ms Switch watchdog; active states refresh at 40 ms. XInput uses held low/left-160-Hz and high/right-320-Hz effects until explicitly stopped. Standalone Joy-Cons downmix each band by dominant amplitude, choosing left on ties; logical Joy-Con pairing is not added.

Nintendo and DualSense native sends share a fixed-capacity deadline-aware host scheduler. Real stop transitions take priority, pending packets use earliest-deadline order with rotating ties, and earlier periodic deadlines protect the last available controller ACL credit. Grants and releases are connection-generation-bound; LED handoffs and reconnects do not retain stale permissions. This schedules HCI submission, not the radio's on-air slots. The standard firmware does not disable Bluetooth sniff/power-saving mode.

Qualification is incomplete. The optimized Pro-only build passed 1,025 distinct commands at 125 Hz with no loss or congestion. Separate held-state testing coalesced 505 of 513 commands into eight state changes plus refreshes, also without loss; the user confirmed both actuators, both bands and clean stops. On the final deadline-scheduler build, a 16.6-second mixed held-effect test received 2,049 commands per controller with no Pro command drops and one DualSense audio-slot skip. This result was accepted for the current setup. Continuously changing both streams at 125 Hz still loses Pro commands and DualSense audio slots; lossless high-rate transport is not claimed. Joy-Con hardware, four-controller operation, captured game effects and physical actuator timing are not qualified by the native regression suite. See SWITCH_FAMILY_HD_RUMBLE_PLAN.md for the exact implementation, quantization policy, evidence and remaining checks.

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: Power-cycle the Pico and confirm a paired controller reconnects with its normal Home/PS/Xbox button without BOOTSEL. Put an unpaired controller into explicit pairing mode and confirm it remains blocked until the BOOTSEL window opens.
  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 macOS, inspect the firmware's raw Game Pad values before GameController or browser remapping with:

swift tools/Test-SwitchPicoMac.swift

The diagnostic matches only CAFE:4021, identifies each of the four interfaces by interface and location, and prints changed axes, hats, and buttons with their HID usage and logical range. The four signed stick axes remain X/Y/Z/Rx. Move each analog trigger slowly and confirm output such as LeftBrake page=0x02 usage=0xC5 logical=0...65535 value=32768 and RightAccelerator page=0x02 usage=0xC4 logical=0...65535 value=32768; each trigger should traverse intermediate values across 0...65535, not only the endpoints. The diagnostic continues through hot-plug events until Ctrl-C. If opening a device fails, allow the terminal (or the app launching Swift) under System Settings → Privacy & Security → Input Monitoring, then rerun it.

On the tested Linux host, all four HID interfaces enumerate immediately at the USB layer, but auto initially presents them as a composite Nintendo Pro Controller while probing the host. Linux binds hid-nintendo to each interface and performs synchronous handshake and calibration requests with retries; incomplete composite interoperability causes -110 timeouts and can accumulate into a 1530 second user-visible delay before the transient hidraw nodes are removed. The timeout is not observed on the Switch. For a Linux laptop, persist dinput for immediate generic-HID enumeration (uv run switch-pico-config mode dinput) or xinput when rumble is required, then restore auto or switch before console use. Profile management uses endpoint-zero vendor transfers and does not depend on hid-nintendo.

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 controller-specific: UART uses SDL3 haptics; AIO uses the selected DualSense PCM stream, explicitly approved Nintendo native output, or the controller's existing Bluepad32 implementation. Native Nintendo hardware qualification remains incomplete; do not assume universal Switch-mode compatibility.
  • 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.
  • In XInput output mode, Home/System is carried in the raw XUSB Guide bit 0x0400, and Capture is carried in the de-facto Share/reserved bit 0x0800 used by modern open XUSB stacks. The standard Microsoft XInput headers define neither Guide nor Share for XINPUT_GAMEPAD.wButtons, so XInputGetState does not expose either button portably. Guide may be reserved or intercepted by the OS, while Share/Capture support depends on the installed driver or consumers such as GameInput and Steam; qualify the intended controller, driver, and application on real Windows hardware.

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).
  • Switch automation (Python): write scripts/bots that drive the Pico directly using switch_pico_bridge.switch_pico_uart (see examples/example_switch_macro.py).
  • Twitch chat plays: translate chat messages into controller actions on the host, then forward them over UART to the Pico.

Remote couch co-op setup (example)

  1. Connect the Switch to a low-latency capture device on the host PC; view it in OBS (or your preferred viewer).
  2. Run controller-uart-bridge on the host PC and connect the Pico to the Switch for input.
  3. Have friends connect to the host PC using Parsec; they use their controllers on their end, which Parsec forwards to the host (SDL3 sees them).
  4. Optional audio routing: Voicemeeter Potato + a virtual audio cable can help manage capture/voice/game audio mixing:

End-to-end data flow (input + rumble)

INPUT (buttons/sticks)
[Any controller] -> [Host OS HID] -> [SDL3 Gamepad] -> [controller-uart-bridge]
                 -> [USB↔UART adapter + UART serial] -> [Pico firmware] -> [USB (Switch Pro)]
                 -> [Nintendo Switch]

RUMBLE (force feedback)
[Nintendo Switch] -> [USB rumble output report] -> [Pico firmware]
                 -> [UART serial + USB↔UART adapter] -> [controller-uart-bridge]
                 -> [SDL3 haptics] -> [Any controller motors]

HD rumble translation

Nintendo sends two stateful four-byte HD-rumble actuator words with full/relative low/high-band commands and up to three substeps. SwitchHapticsDecoder retains this timeline as well as conventional strong/weak magnitudes. The selected DualSense's native PCM backend uses the timeline; ordinary controller-parser and UART/SDL paths use the magnitudes. Preserving frequency intent is not a claim of identical force response across actuators. Native forwarding for genuine Switch-family controllers is planned separately, not enabled by the DualSense implementation.

The UART return frame carries the decoded result rather than raw HD-rumble bytes:

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.
    • RX: GPIO5 (Pico pin 7) → TX of your USB-serial adapter.
    • GND: common ground between Pico and adapter.
  • Baud rate: 921600 (default). Some adapters only handle 500,000; both bridges accept a --baud flag.
  • Keep logic at 3.3V; do not feed 5V UART into the Pico.

Full hookup checklist

  1. Gather the hardware

    • Raspberry Pi Pico flashed with the provided firmware.
    • USB-A-to-micro USB cable (or USB-C if you use a Pico W) to connect the Pico to the Switch or a PC for testing.
    • USB-to-UART adapter capable of 3.3 V logic at 921600 baud (FT232, CP2102, CH340, etc.).
    • Three dupont wires (TX, RX, GND). Optionally add heat-shrink or a small proto board if you want something more permanent.
  2. Wire the Pico to the USB-to-UART adapter

    • Pico GPIO4 → adapter RX (sometimes labelled RXD, DI, or R).
    • Pico GPIO5 → adapter TX (TXD, DO, or T).
    • Pico GND → adapter GND. Tie grounds even if the adapter is already USB-powered.
    • Leave VBUS/VCC unconnected unless your adapter explicitly supports 3.3 V power output and you intend to power the Pico from it (the bridge expects the Pico to be powered from USB instead).
  3. Connect everything to the host and Switch

    • Plug the USB-to-UART adapter into the computer that will run the Python bridge. Note the COM port (Device Manager > Ports) on Windows or /dev/cu.*//dev/ttyUSB* path on macOS/Linux; pass it via --map/--ports.
    • Connect the Pico's micro USB port to the Nintendo Switch (via the dock's USB-A port, a USB-C OTG adapter, or a PC if you are only testing). The Pico enumerates as a Switch Pro Controller over USB.
    • On the Switch, enable System Settings → Controllers and Sensors → Pro Controller Wired Communication.
    • Any SDL-compatible gamepads you want to use should also be plugged into (or paired with) the same host computer that runs the Python bridge; the bridge is the one reading them.

Finding your USB↔UART adapter “description” (port filtering)

If you have multiple serial/COM devices, you can filter which ports the bridge will consider using the port description (or vendor/product text) shown by the OS.

  • macOS/Linux (terminal):
    • Quick list with descriptions: python -m serial.tools.list_ports -v
    • Then run the bridge with a filter, for example: controller-uart-bridge --interactive --include-port-desc CP210
  • Windows:
    • Device Manager → Ports (COM & LPT) → open your adapter → copy the device name/vendor text.
    • Then run: controller-uart-bridge --interactive --include-port-desc "USB-SERIAL CH340"

Filters you can use:

  • --include-port-desc SUBSTR (repeatable): only consider ports whose description contains the substring.
  • --ignore-port-desc SUBSTR (repeatable): exclude ports whose description contains the substring.
  • --all-ports: include non-USB serial devices in discovery (useful if your adapter isnt tagged as USB by the OS).
  1. Power-on order and sanity checks

    • Power the Switch/dock so the Pico gets 5 V over USB; its USB stack must stay alive while the bridge streams data.
    • On the host computer, run controller-uart-bridge --list-controllers to make sure SDL sees your pads, then start the bridge with --map/--ports (or --interactive) referencing the adapter path you found earlier.
    • Watch the Rich console output: you should see each controller paired with a UART port and the rumble loop logging reconnects if cables are unplugged.
  2. Common pitfalls

    • A flipped TX/RX pair results in silence (no button presses); swap them if the Pico never shows input.
    • Some adapters default to 5 V logic—move the jumper to 3.3 V before touching the Pico.
    • If you use multiple adapters, label each cable; COM port numbers can change between boots.
  • When testing on a PC before plugging into a Switch, you can verify activity with the lightweight switch_pico_bridge.switch_pico_uart helper or the Windows "Game Controllers" panel.

Building and flashing firmware

Prereqs: Pico SDK, Arm GNU toolchain, CMake, and picotool.

Using build.py

build.py configures CMake, builds the firmware, checks that both output formats were created, copies the release artifacts into firmware/, and flashes the ELF with picotool.

build.py automatically locates the Pico SDK and Arm GNU toolchain from valid existing build/, build-aio/, or build-feasibility/ CMake caches, then from project-local build/_deps/pico_sdk-src and build/toolchain installs, and finally from conventional user and system locations. A compiler already on PATH is used without setting a toolchain override. Explicit PICO_SDK_PATH and PICO_TOOLCHAIN_PATH values always take precedence; an invalid explicit path is reported instead of silently falling back.

Before running it:

  1. Install the Pico SDK, CMake toolchain, and picotool.
  2. Connect the Pico in BOOTSEL mode.
  3. From the repository root, run:
python3 build.py

The generated files are:

  • build/switch-pico.elf, which build.py passes to picotool.
  • build/switch-pico.uf2, which can also be copied to the Pico manually.
  • firmware/switch-pico.elf and firmware/switch-pico.uf2, refreshed from the corresponding build/ artifacts after every successful build.

To assign one color to every emulated controller slot while building, pass one of these mutually exclusive options:

# Use one random color for all slots
python3 build.py --random-grip-color

# Use one specific six-digit RGB color for all slots
python3 build.py --grip-color FF00AA

Both options update all four slot definitions in src/firmware/platform/pico/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:

PICO_SDK_PATH=/path/to/pico-sdk \
PICO_TOOLCHAIN_PATH=/path/to/arm-none-eabi-toolchain \
PICOTOOL_PATH=/path/to/picotool \
ELF_PATH=/path/to/switch-pico.elf \
UF2_PATH=/path/to/switch-pico.uf2 \
python3 build.py

PICO_SDK_PATH and PICO_TOOLCHAIN_PATH explicitly select the SDK and cross-compiler installations. PICOTOOL_PATH selects the flashing tool, ELF_PATH selects the ELF that is checked and flashed, and UF2_PATH selects the UF2 that is checked after the build. Their defaults are picotool from PATH, build/switch-pico.elf, and build/switch-pico.uf2, respectively.

Manual build

cmake -S . -B build -DSWITCH_PICO_LOG=OFF
cmake --build build -j

This produces both build/switch-pico.elf and a flashable build/switch-pico.uf2.

Manual UF2 flashing (BOOTSEL, no tools)

If you already have a built (or use the pre-built one in firmware/) .uf2, you can flash it without rebuilding:

  1. Unplug the Pico.
  2. Hold the BOOTSEL button.
  3. While holding BOOTSEL, plug the Pico into your computer over USB (not the Switch), then release BOOTSEL.
  4. A USB mass-storage drive (usually RPI-RP2) will appear. Copy the .uf2 onto it (drag-and-drop).
  5. The Pico will reboot automatically and the RPI-RP2 drive will disappear when flashing completes.

Tip: if you dont see RPI-RP2, try a different USB cable (some are charge-only) or a different USB port/hub.

When the AIO firmware is already running on a PC, enter ROM BOOTSEL without touching the board:

uv run switch-pico-config reboot bootsel

The firmware acknowledges the endpoint-zero request, waits 50 ms, and then calls the Pico ROM reset_usb_boot() entry point. Physical BOOTSEL remains the fallback if the firmware or USB management path is unavailable.

Flash alternatives: bootsel + drag-drop or picotool load. Flags:

  • SWITCH_PICO_LOG: enable/disable UART logging on the Pico.

Works on macOS, Windows, Linux. Uses SDL3 + pyserial.

Install dependencies (pyproject-enabled)

The repository now includes a pyproject.toml, so you can install the bridge and helper scripts as an editable package:

# from repo root
uv venv .venv
source .venv/bin/activate  # or .venv\Scripts\activate on Windows
uv pip install -e .

Prefer stock pip?

python -m venv .venv
source .venv/bin/activate  # or .venv\Scripts\activate on Windows
pip install -e .
  • SDL3 runtime: install via your OS package manager (macOS: brew install sdl3; Windows: place SDL3.dll on PATH or next to the script; Linux: install libsdl3-0 or your distribution's equivalent).

Run

source .venv/bin/activate  # or .venv\Scripts\activate on Windows
controller-uart-bridge --interactive
# or, equivalently
python -m switch_pico_bridge.controller_uart_bridge --interactive

Options:

  • --map index:PORT (repeatable) to pin controller index to serial (e.g., --map 0:/dev/cu.usbserial-0001 or --map 0:COM5).
  • --ports PORTS... or --interactive for auto/interactive pairing.
  • --all-ports to include non-USB serial devices in discovery.
  • --ignore-port-desc SUBSTR / --include-port-desc SUBSTR to filter serial ports by description (repeatable).
  • --include-controller-name SUBSTR to only open controllers whose name matches (repeatable).
  • --list-controllers to print detected controllers and their GUIDs, then exit (useful for GUID-based options).
  • --baud 921600 (default 921600; use 500000 if your adapter cant do 900K).
  • --frequency 1000 to send at 1 kHz.
  • --deadzone 0.08 to change stick deadzone (0.0-1.0).
  • --zero-sticks to sample the current stick positions on connect and treat them as neutral (cancel drift).
  • --zero-hotkey z to choose the terminal hotkey that re-zeroes all connected controllers on demand (press z by default; pass an empty string to disable).
  • --update-controller-db to download the latest SDL GameController database before launching (defaults to the bundled copy in switch_pico_bridge/controller_db/).
  • --controller-db-url URL to override the source URL when updating the controller database (defaults to the official mdqinc repo).
  • --trigger-threshold 0.35 to change analog trigger press threshold (0.0-1.0).
  • --swap-abxy to flip AB/XY globally.
  • --swap-abxy-index N (repeatable) to flip AB/XY for controllers first seen at index N (auto-converts to a stable GUID).
  • --swap-abxy-guid GUID (repeatable) to flip AB/XY for a specific physical controller (GUID is stable across runs).
  • --swap-hotkey x to pick the runtime hotkey that prompts you to toggle ABXY layout for a specific connected controller (default x; empty string disables).
  • --sdl-mapping path/to/gamecontrollerdb.txt to load extra SDL mappings (defaults to switch_pico_bridge/controller_db/gamecontrollerdb.txt).
  • --debug-imu to print raw gyroscope and accelerometer readings every ~200ms (useful for verifying sensor data and troubleshooting).
  • --no-imu to disable sensor reading entirely (useful for controllers without gyro, or if motion causes issues).
  • --gyro-scale FLOAT to adjust gyroscope sensitivity (default 1.0; reduce below 1.0 if camera rotates too fast; increase above 1.0 for more sensitivity).

Runtime hotkeys

  • By default, pressing z in the terminal re-samples every connected controller's sticks and re-applies neutral offsets. Change/disable with --zero-hotkey.
  • Press x (configurable via --swap-hotkey) to open an in-CLI prompt and toggle the ABXY layout for a specific connected controller. This updates the controller's stable GUID list immediately; press again to revert.
  • Hotkeys work only when the bridge is started from a TTY/console that currently has focus. Pass an empty string to either flag to disable that shortcut (useful when running unattended).
  • If you launch the bridge with --swap-abxy (global swap), the per-controller toggle hotkey will show that the layout is enforced globally and will not override it.

Updating SDL controller mappings

  • The bridge ships with a pinned switch_pico_bridge/controller_db/gamecontrollerdb.txt. Run controller-uart-bridge --update-controller-db ... to download the latest database from the official upstream (mdqinc/SDL_GameControllerDB).
  • The download only touches switch_pico_bridge/controller_db/gamecontrollerdb.txt; add --controller-db-url https://.../custom.txt if you maintain your own fork.
  • If the file is missing, the bridge will automatically attempt a download on startup.

Hot-plugging: controllers and UARTs can be plugged/unplugged while running; the bridge will auto reconnect when possible.

Using the lightweight UART helper (no SDL needed)

For simple scripts or tests you can skip SDL and drive the Pico directly with switch_pico_bridge.switch_pico_uart:

from switch_pico_bridge import SwitchUARTClient, SwitchButton, SwitchDpad

with SwitchUARTClient("/dev/cu.usbserial-0001") as client:
    client.press(SwitchButton.A)
    client.release(SwitchButton.A)
    client.move_left_stick(0.0, -1.0)  # push up
    client.set_hat(SwitchDpad.UP_RIGHT)
    print(client.poll_rumble())  # returns (left, right) amplitudes 0.0-1.0 or None
  • SwitchButton is an IntFlag (bitwise friendly) and SwitchDpad is an IntEnum for the DPAD/hat values (alias SwitchHat remains for older scripts).
  • The helper only depends on pyserial; SDL is not required.

macOS tips

  • Ensure the USBserial adapter shows up (use /dev/cu.usb* for TX).
  • Some controllers Guide/Home buttons are intercepted by macOS; using XInput/DInput mode or disabling Steams controller handling helps.

Windows tips

  • Use COMx for ports (e.g., COM5). Autodetect lists COM ports.
  • Ensure SDL3.dll is on PATH or alongside the script.

Linux tips

  • You may need udev permissions for /dev/ttyUSB*//dev/ttyACM* (add user to dialout/uucp or use udev rules).
  • For the development XInput/DInput/Mac identities, install udev/99-switch-pico.rules into /etc/udev/rules.d/, reload udev, and reconnect the Pico so switch-pico-config can access endpoint zero without root.

IMU / Motion Controls

The bridge supports gyroscope and accelerometer passthrough from controllers that have motion sensors (e.g. the Nintendo Switch Pro Controller and DualSense). Motion data is forwarded to the Pico as a rolling three-sample window; the Pico emits standard 0x30 reports at 15 ms intervals and supports both raw IMU mode 1 and packed quaternion mode 2.

Requirements

  • A controller with gyro/accelerometer support that SDL3 can enable.
  • The Switch will automatically use motion data once the controller is recognised as a Pro Controller.

Gyro bias calibration

On startup, the bridge collects the first 200 gyro readings while the controller is stationary and averages them to compute a per-axis bias (zero-rate offset). The bias is subtracted from subsequent readings. Keep the controller still during startup for best results.

CLI flags

  • --debug-imu: Print raw sensor values (m/s² and rad/s) and converted Switch integer counts every ~200ms. Useful for verifying the sensor is detected and producing sensible data.
  • --no-imu: Disable IMU entirely. The bridge sends zero motion data to the Pico, which sends zero-filled IMU bytes to the Switch. Buttons and sticks are unaffected.
  • --gyro-scale FLOAT (default 1.0): Multiply all gyro values by this factor before sending. Reduce below 1.0 if the camera moves too fast; increase above 1.0 for more sensitivity.

Troubleshooting

  • Gyro not detected: Run with --debug-imu. If no IMU readings appear, SDL3 cannot see sensors on the controller. On Linux, the hid-nintendo kernel driver may expose Nintendo controller motion differently; DualSense motion is supported by SDL3's PlayStation HID driver.
  • Wild camera swinging: Rebuild and flash the current Pico firmware. Older builds acknowledged quaternion IMU mode 2 but emitted raw mode-1 bytes, which Zelda interpreted as random quaternion data. Keep the controller still during startup, then use --gyro-scale only for deliberate sensitivity adjustment.
  • Verifying Pico output: Use uv run python tools/read_pro_imu.py --vid 0x057E --pid 0x2009 to read raw IMU bytes directly from the Pico's USB HID output. A stationary controller should show gyro values near zero and three non-empty, non-duplicated samples per report.

Implementation notes for maintainers

The failure

Nintendo subcommand 0x40 is a mode selector, not a Boolean enable:

Value Meaning Required bytes 13-48 in report 0x30
0 IMU off Zero-filled
1 Raw IMU Three 12-byte accelerometer/gyro samples
2 Quaternion Nintendo's packed 36-byte mode-2 structure

The previous firmware stored the argument in bool is_imu_enabled. A mode-2 request therefore enabled the raw mode-1 packer. Zelda then decoded raw sensor bytes as mode bits, compressed quaternion components, deltas, and timestamps, producing apparently random camera rotation. The fake also advertised firmware 4.91, while the genuine wired Pro Controller used during diagnosis reported 3.48.

Keep SwitchImuMode as a three-state value. Never acknowledge mode 2 and then emit mode-1 bytes.

Mode-1 implementation

  • Emit one 0x30 report every 15 ms.
  • Advance the report timer by 3: one timer tick for each nominal 5 ms IMU sample.
  • Pack three chronological samples as signed little-endian accel X/Y/Z, then gyro X/Y/Z.
  • The host bridge must retain and republish its latest three-sample window. Do not drain it at the faster UART rate; that previously produced empty and duplicated USB reports.
  • With the advertised factory calibration, 1g is approximately 4096 counts and 1 rad/s is approximately 818.5 gyro counts.

Mode-2 implementation

src/firmware/usb/switch/switch_pro_driver.cpp implements this in integrate_motion_sample() and fill_quaternion_imu_report_data():

  1. Reset quaternion state to (0, 0, 0, 1) when transitioning into mode 2.
  2. Integrate each report's three gyro samples at 5 ms per sample. The Nintendo quaternion axes use sensor Y, X, Z, not X, Y, Z.
  3. Build a delta quaternion from the angular rotation vector, multiply it into the current orientation, and normalize after every sample.
  4. Select the largest absolute quaternion component. Its index and sign represent the omitted component; encode the other three signed components at 21-bit precision.
  5. Pack accelerometer data in Y, X, Z order, set the mode field to 2, write the 11-bit millisecond timestamp, and set the timestamp/sample count to 3.
  6. Integrate and repack only when transmitting the next 15 ms USB report. Calling the integrator from the unrestricted main loop over-integrates the same UART samples.

The mode-2 wire format is bit-packed and fields cross byte boundaries. Use write_bits_le() rather than C/C++ bitfields so layout does not depend on compiler bitfield rules.

Regression and hardware verification

After changing any IMU conversion, calibration, timing, or report packing:

  1. Run uv run --with pytest pytest -q.
  2. Build with cmake --build build -j.
  3. Capture at least 200 raw 0x30 reports. Stationary gyro should remain near zero; there should be no empty windows, duplicated three-sample windows, or timer-step errors.
  4. Send subcommand 0x40 with value 2. Every resulting report must have mode bits 2 and timestamp count 3.
  5. Inject a known single-axis gyro rate and decode the packed quaternion. The corresponding component must change smoothly with the expected sign.
  6. Perform the decisive end-to-end check: genuine Pro Controller → SDL3 bridge → UART → emulated Pico → Zelda. This path was confirmed correct after the mode-2 fix.

Firmware resource usage

The Pico 2 W AIO build is measured from build-aio/switch-pico.elf and its linked binary, not from the larger debug-bearing ELF or UF2 transport file:

Resource Used or reserved Device capacity
Executable flash image 833,320 bytes 4 MiB
Indexed profile arenas 256 KiB 4 MiB flash
Adapter configuration 8 KiB 4 MiB flash
BTstack bonds and Switch 2 application authorizations 8 KiB 4 MiB flash
RP2350 terminal sector 4 KiB 4 MiB flash
Allocated/reserved SRAM, including heap and stacks 151,804 bytes 520 KiB

The executable plus persistent reservations consume 1,115,944 bytes of flash, leaving 3,078,360 bytes. Allocated SRAM sections leave 380,676 bytes of link-time headroom; this is not a runtime heap high-water measurement. Core 0 has a 4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested catalog migration/compaction rather than overflowing its 4 KiB scratch bank.

Profiles use two 128 KiB append-only arenas and retain 248 physical record slots. Catalog 3 uses a 128-byte header plus a 384-byte profile in the same 512-byte stride. The second page is programmed before the header-containing first page, and records are read back before publication. Profile names remain 256-byte metadata payloads and aliases remain 32 bytes. The compact index stores locations and generations; active/fallback profiles for observed identities and the selected profile are decoded, not the entire database. Pair initialization commits one seed record that snapshots the left bank's immutable profile/name record references and active selection. Subsequent writes are independent, and compaction materializes pair-owned records. Interrupted initialization exposes either no pair bank or the complete bank, never a partly copied bank. An ambiguous storage write/readback failure freezes catalog access until reboot/replay; existing live profiles retain their last committed cached settings.

The catalog supports eight profiles for the global fallback and each of 16 stable identities, including pair owners. Missing records resolve to defaults, so profiles 58 do not consume flash until changed. When an arena fills, the latest indexed records are compacted into its peer and the new superblock is published last. Interrupted or corrupt appends leave the previous valid record available. Catalogs 1/2 and retired four-profile banks migrate through the alternate arena; the old published data is retained until all copies and the new superblock verify. Schema 16 profiles retain their meaning when decoded as schema 7. Keep a profile export before downgrading: older firmware cannot read the new catalog/profile format.

References

Troubleshooting

  • No input on Switch: verify UART wiring (Pico GPIO4/5), baud matches both sides, Pico flashed with current firmware, and Pro Controller Wired Communication is enabled on the Switch.
  • Constant buzzing rumble: the bridge filters small rumble payloads; ensure baud isnt dropping bytes. Try lowering rumble scale in switch_pico_bridge.controller_uart_bridge if needed.
  • Guide/Home triggers system menu (macOS): try different controller mode (XInput/DInput), disable Steam overlay/controller support, or connect wired.
  • SDL cant see controller: load switch_pico_bridge/controller_db/gamecontrollerdb.txt (default), add your own mapping, or try a different mode on the pad (e.g., XInput).