Compare commits

..

10 commits

Author SHA1 Message Date
f722a9dcbb Merge branch 'feat/imu-gyro-support' of github.com:jyapayne/switch-pico into feat/imu-gyro-support 2026-03-16 20:37:12 -06:00
4f7e4a8de6 chore(bridge): disable IMU support by default
IMU sensor pipeline is not yet stable; default no_imu to True so it must be explicitly opted into with --no-no-imu or a future --imu flag.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 20:34:05 -06:00
731e1d8d15
fix(firmware): correct SPI 0x6080 horizontal offsets to match bridge output
The SPI horizontal offsets at 0x6080 tell the Switch what accelerometer
values to expect when the controller is held in normal gaming position.
The Switch uses this as a gravity reference for its sensor fusion.

Old values (-688, 0, 4038) were from a real Pro Controller's physical
IMU chip. Our bridge sends ~(0, 0, 4096) through the axis reversal
pipeline. The 388-count mismatch on X (0.095G = 5.4° tilt error) caused
the Switch's sensor fusion to continuously fight the gyro data, trying
to correct toward the wrong reference orientation → camera swinging.

New values (0, 0, 4096) match the bridge's output for a still controller
after SDL axis reversal, matching the zeroed calibration origins at 0x6020.
2026-03-16 19:18:37 -06:00
6884f25121
fix: eliminate IMU jumping from stale FIFO samples and zero-accel startup
Two root causes of camera 'wild jumping':

1. FIFO latency (bridge): Popped from the FRONT of the 32-sample FIFO,
   sending 145ms-stale data while fresh samples sat at the back. Movement
   played back on delay, making the camera feel disconnected from input.
   Fix: pop from the END (newest samples) and clear the entire FIFO.

2. Zero-accel startup (firmware): At boot, imuData was all zeros until the
   first UART frame with IMU arrived (~3-4 seconds later). The Switch
   interpreted zero accel as free-fall, corrupting its sensor fusion state.
   Fix: default imuData to a 'rest' sample (1G on accel_z, zero gyro)
   so the Switch always sees a valid gravity reference.
2026-03-16 18:57:26 -06:00
0fbb187068
fix(firmware): revert broken 0x10 subcommand routing, add diagnostic logging
Reverts the 0x10/0x21 routing to handle_feature_report() — those reports
during handshake are rumble-only keep-alives where buffer[10] is coincidental
data (always 0x01), not a real subcommand. Routing them caused every report
to trigger BLUETOOTH_PAIR_REQUEST.

The real subcommands (TOGGLE_IMU, SPI_READ, SET_MODE, etc.) are sent via
0x80 config reports and 0x01 feature reports, which were already routed
correctly. UART0 debug log now confirms is_imu_enabled=1 after handshake.

Added LOG_PRINTF to handle_feature_report() showing the report ID,
command ID, and is_imu_enabled state for each processed subcommand.
2026-03-16 18:40:58 -06:00
e7c01d1116
fix(firmware): route 0x10/0x21 output reports to subcommand handler
The Switch sends subcommands (IMU enable, SPI reads, vibration enable,
player lights, etc.) inside 0x10 and 0x21 output reports at byte 10.
The firmware extracted rumble data from these reports but never routed
the subcommand to handle_feature_report() — it fell through the
if-else chain silently.

This caused the handshake to stall: the Switch kept retrying early
subcommands (0x00-0x0f cycling) because it never received ACK replies.
It never progressed to sending 0x40 (Toggle IMU), 0x10 (SPI Read),
0x48 (Enable Vibration), or 0x30 (Set Player Lights).

The IMU was technically sending data, but the Switch never enabled it
via subcommand 0x40, so the Switch's IMU processing was undefined.

Fix: after extracting rumble from 0x10/0x21 reports, also pass them
to handle_feature_report() so the subcommand at buffer[10] gets
processed and ACK'd. Same fix applied to both tud_hid_set_report_cb
and tud_hid_report_received_cb.
2026-03-16 18:32:45 -06:00
3c60841d23
fix(bridge): replace flawed motion-threshold bias with warmup delay + timeout
The motion threshold (0.5 rad/s) caused two bugs:
1. Normal hand tremor could exceed the threshold, so bias_samples never
   reached 200, bias never locked, and IMU never activated.
2. Violent shaking produced occasional near-zero samples at direction
   reversals that contaminated the accumulator with wrong values, locking
   a completely wrong bias and causing immediate fast spinning.

New approach:
- 1.5s warmup phase: discard all samples while the user is still interacting
  with the keyboard/terminal after launch. Print a message so the user knows
  to hold still.
- Unconditional collection of 100 samples (~0.5s at 200Hz) after warmup.
- 10s force-lock timeout: if 100 still samples haven't accumulated after
  10s total, lock anyway with whatever we have (> 10 samples required).
- Print bias quality report: magnitude > 0.05 rad/s warns the user that
  the controller was moving during calibration and they should restart.
2026-03-16 17:42:01 -06:00
30e22c210c
fix(firmware): zero SPI IMU calibration origins to prevent phantom rotation
The Switch applies its stored SPI calibration when interpreting IMU data:
  gyro_dps = (raw - spi_origin) * 936 / coeff

The firmware had real hardware offsets as calibration origins:
  gyro_origin = (9, -22, -95)  accel_origin = (-29, -199, 493)

A real Pro Controller sensor reads those values at rest, so the Switch
subtracts them to get zero. But our bridge already removes hardware bias
via gyro bias calibration and sends near-zero counts when still.

The Switch was then applying a second origin correction:
  gyro_z=2 → (2 - (-95)) * 0.070 = 6.79 dps constant yaw rotation

This caused the character to spin horizontally even when holding the
controller perfectly still.

Fix: zero all calibration origins. The bridge handles bias correction;
the Switch must not apply a second offset on top.
2026-03-16 17:22:52 -06:00
9d33bc4be7
fix(bridge): fix gyro bias calibration corruption and zero-accel startup
Two bugs causing constant camera drift and jarring first-frame behaviour:

1. Bias calibration was immediately collecting samples at launch, while the
   user is still typing / setting down the controller. This polluted the bias
   estimate (observed: by=-0.073 rad/s vs true ~-0.009 rad/s), causing a
   permanent ~4 deg/s camera drift even when the controller is held still.
   Fix: reject samples with gyro magnitude >= 0.5 rad/s (motion threshold)
   during the calibration window so only truly still samples count. Also add
   a return-early so no IMU is sent to the Pico until bias is locked.

2. last_accel initialised to (0,0,0), but the first gyro event fires before
   the first accel event. The Pico received accel=(0,0,0) on the first sample
   instead of the expected ~4096 counts on the gravity axis.
   Fix: default last_accel to (0.0, 9.80665, 0.0) — gravity on SDL Y axis,
   which is correct for a Pro Controller held in normal gaming position.
2026-03-16 17:16:49 -06:00
12dc3d58ee
chore: add raw SDL3 IMU diagnostic tool 2026-03-16 17:05:56 -06:00
32 changed files with 660 additions and 2275 deletions

View file

@ -24,29 +24,8 @@ if (EXISTS ${picoVscode})
endif()
# ====================================================================================
option(SWITCH_PICO_LOG "Enable UART debug logging" OFF)
set(SWITCH_PICO_PROTOCOL "legacy" CACHE STRING "USB protocol: legacy or switch2")
set_property(CACHE SWITCH_PICO_PROTOCOL PROPERTY STRINGS legacy switch2)
set(PICO_BOARD pico CACHE STRING "Board type")
if (SWITCH_PICO_PROTOCOL STREQUAL "legacy")
set(SWITCH_PICO_PROTOCOL_SOURCES
switch_pro_driver.cpp
switch_legacy_protocol.cpp
)
set(SWITCH_PICO_PROTOCOL_DEFINITION SWITCH_PICO_PROTOCOL_LEGACY=1)
elseif (SWITCH_PICO_PROTOCOL STREQUAL "switch2")
set(SWITCH_PICO_PROTOCOL_SOURCES
switch2_driver.cpp
switch2_descriptors.cpp
switch2_reports.cpp
switch2_commands.cpp
)
set(SWITCH_PICO_PROTOCOL_DEFINITION SWITCH_PICO_PROTOCOL_SWITCH2=1)
else()
message(FATAL_ERROR
"Invalid SWITCH_PICO_PROTOCOL='${SWITCH_PICO_PROTOCOL}'. Expected legacy or switch2.")
endif()
# Pull in Raspberry Pi Pico SDK (must be before project)
include(pico_sdk_import.cmake)
@ -59,12 +38,9 @@ pico_sdk_init()
add_executable(switch-pico
switch-pico.cpp
switch_uart_protocol.cpp
${SWITCH_PICO_PROTOCOL_SOURCES}
switch_pro_driver.cpp
)
target_compile_definitions(switch-pico PRIVATE ${SWITCH_PICO_PROTOCOL_DEFINITION})
pico_set_program_name(switch-pico "switch-pico")
pico_set_program_version(switch-pico "0.1")

View file

@ -103,62 +103,19 @@ Filters you can use:
## Building and flashing firmware
Prereqs: Pico SDK + CMake toolchain set up.
### Protocol selection
The default `legacy` protocol is the existing wired Switch Pro implementation. Omitting `--protocol` from `build.py` or `SWITCH_PICO_PROTOCOL` from CMake preserves that default.
Build and flash the default legacy firmware with picotool:
### One-shot build + flash (picotool)
```sh
python3 build.py
```
Build without flashing, either implicitly or explicitly selecting legacy:
```sh
python3 build.py --build-only
python3 build.py --protocol legacy --build-only
```
Manual legacy build:
```sh
cmake -S . -B build -DSWITCH_PICO_PROTOCOL=legacy -DSWITCH_PICO_LOG=OFF
cmake --build build -j
```
Build the experimental Switch 2 protocol without flashing:
```sh
python3 build.py --protocol switch2 --build-only
```
Manual experimental Switch 2 build:
```sh
cmake -S . -B build -DSWITCH_PICO_PROTOCOL=switch2 -DSWITCH_PICO_LOG=OFF
cmake --build build -j
```
To build and flash that experimental image, omit `--build-only`:
```sh
python3 build.py --protocol switch2
```
`--build-only` confirms and prints `build/switch-pico.elf` and `build/switch-pico.uf2`, then exits without invoking `picotool load` or flashing hardware. Pico SDK may still use picotool internally while generating build outputs. Without that flag, existing build-and-flash behavior is unchanged.
- Requires `picotool` on your `PATH` (or set `PICOTOOL_PATH=/path/to/picotool`) and a connected Pico in BOOTSEL mode to automatically flash.
- Set `ELF_PATH` to override the default `build/switch-pico.elf`.
### Experimental Switch 2 scope
- Uses VID:PID `057E:2069` and a reduced two-interface USB configuration: interface 0 is HID with 64-byte interrupt IN/OUT endpoints, and interface 1 is vendor-specific with 64-byte bulk IN/OUT endpoints.
- Omits the captured audio interfaces, so it is not the full five-interface controller topology.
- Supports only USB initialization and selection of input reports `0x05` and `0x09`. Other vendor command families are unsupported.
- Packs buttons and sticks only. Switch 2 IMU data is not packed, and native HD-rumble output is not mapped to the legacy UART rumble path.
- Has been validated only for compilation and descriptor consistency. PC enumeration and Nintendo Switch 2 console compatibility remain unverified.
- Changes only the USB protocol facade. Existing UART1 wiring (GPIO4 TX, GPIO5 RX), 921600 baud rate, and UART report framing are unchanged.
### Manual build
```sh
cmake -S . -B build -DSWITCH_PICO_LOG=OFF
cmake --build build -j
```
This produces a `.uf2` you can flash (typically `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:
@ -178,7 +135,6 @@ Flags:
`build.py` can optionally update the **grip** colours in `controller_color_config.h` before building/flashing (default leaves the file unchanged):
- Random grip colours: `python3 build.py --random-grip-color`
- Set grip colours: `python3 build.py --grip-color FF00AA`
- Grip colour overrides apply only to `legacy`; `build.py` rejects them when `--protocol switch2` is selected.
## Python bridge (recommended)
Works on macOS, Windows, Linux. Uses SDL2 + pyserial.

121
build.py Executable file → Normal file
View file

@ -1,6 +1,5 @@
#!/usr/bin/env python3
"""Build and flash the project with optional grip color overrides."""
import argparse
import os
import random
@ -9,15 +8,12 @@ import shutil
import subprocess
import sys
from pathlib import Path
from typing import Final, Literal, TypeAlias, final
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "controller_color_config.h"
BUILD_DIR = SCRIPT_DIR / "build"
BUILD_ELF_PATH = BUILD_DIR / "switch-pico.elf"
BUILD_UF2_PATH = BUILD_DIR / "switch-pico.uf2"
ELF_PATH = Path(os.environ.get("ELF_PATH", str(BUILD_ELF_PATH))).expanduser()
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
MACROS = (
"SWITCH_COLOR_LEFT_GRIP_R",
@ -28,73 +24,36 @@ MACROS = (
"SWITCH_COLOR_RIGHT_GRIP_B",
)
BuildProtocol: TypeAlias = Literal["legacy", "switch2"]
PROTOCOL_CHOICES: Final[tuple[BuildProtocol, BuildProtocol]] = ("legacy", "switch2")
@final
class BuildArguments(argparse.Namespace):
def __init__(self) -> None:
super().__init__()
self.protocol: BuildProtocol = "legacy"
self.build_only: bool = False
self.random_grip_color: bool = False
self.grip_color: str = ""
def parse_args() -> BuildArguments:
def parse_args():
parser = argparse.ArgumentParser(
description="Build and flash the project, optionally setting grip colors.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Default behavior leaves controller_color_config.h unchanged.",
)
_ = parser.add_argument(
"--protocol",
choices=PROTOCOL_CHOICES,
default="legacy",
help="USB protocol to build (default: legacy).",
)
_ = parser.add_argument(
"--build-only",
action="store_true",
help="Build and print ELF/UF2 paths without flashing.",
)
group = parser.add_mutually_exclusive_group()
_ = group.add_argument(
group.add_argument(
"--random-grip-color",
action="store_true",
help="Randomize both grip colors before building.",
)
_ = group.add_argument(
group.add_argument(
"--grip-color",
metavar="RRGGBB",
help="Set both grip colors to the provided hex value.",
)
args = BuildArguments()
_ = parser.parse_args(namespace=args)
if args.protocol == "switch2" and (args.random_grip_color or bool(args.grip_color)):
parser.error(
"Switch 2 builds do not consume legacy grip colors; omit --random-grip-color "
+ "and --grip-color."
)
return args
return parser.parse_args()
def random_hex_color() -> str:
def random_hex_color():
return "".join(f"{random.randrange(256):02X}" for _ in range(3))
def validate_custom_color(value: str) -> str:
def validate_custom_color(value):
if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
raise argparse.ArgumentTypeError(
"Color must be a 6-digit hex value like FF8800."
)
raise ValueError("Color must be a 6-digit hex value like FF8800.")
return value
def update_grip_colors(rgb_hex: str) -> None:
def update_grip_colors(rgb_hex):
if not CONFIG_FILE.exists():
_ = sys.stderr.write(f"Error: Cannot find {CONFIG_FILE}\n")
sys.stderr.write(f"Error: Cannot find {CONFIG_FILE}\n")
sys.exit(1)
r, g, b = rgb_hex[:2], rgb_hex[2:4], rgb_hex[4:6]
@ -102,14 +61,14 @@ def update_grip_colors(rgb_hex: str) -> None:
try:
text = CONFIG_FILE.read_text(encoding="utf-8")
except OSError as exc:
_ = sys.stderr.write(f"Error reading {CONFIG_FILE}: {exc}\n")
sys.stderr.write(f"Error reading {CONFIG_FILE}: {exc}\n")
sys.exit(1)
def replace(name: str, val: str, data: str) -> str:
def replace(name, val, data):
pattern = rf"(?m)^(#define\s+{name}\s+)0x[0-9A-Fa-f]{{2}}"
updated, count = re.subn(pattern, rf"\g<1>0x{val.upper()}", data)
if count == 0:
_ = sys.stderr.write(f"Error: Could not find {name} in {CONFIG_FILE}\n")
sys.stderr.write(f"Error: Could not find {name} in {CONFIG_FILE}\n")
sys.exit(1)
return updated
@ -118,30 +77,26 @@ def update_grip_colors(rgb_hex: str) -> None:
text = replace(macro, val, text)
try:
_ = CONFIG_FILE.write_text(text, encoding="utf-8")
CONFIG_FILE.write_text(text, encoding="utf-8")
except OSError as exc:
_ = sys.stderr.write(f"Error writing {CONFIG_FILE}: {exc}\n")
sys.stderr.write(f"Error writing {CONFIG_FILE}: {exc}\n")
sys.exit(1)
def run_cmd(command: list[str]) -> None:
def run_cmd(command):
try:
_ = subprocess.run(command, cwd=SCRIPT_DIR, check=True)
subprocess.run(command, cwd=SCRIPT_DIR, check=True)
except FileNotFoundError as exc:
_ = sys.stderr.write(f"Error running {command[0]}: {exc}\n")
sys.stderr.write(f"Error running {command[0]}: {exc}\n")
sys.exit(1)
except subprocess.CalledProcessError as exc:
sys.exit(exc.returncode)
def resolve_picotool() -> Path:
def resolve_picotool():
env_val = os.environ.get("PICOTOOL_PATH")
if env_val:
env_path = Path(env_val).expanduser()
if not env_path.exists():
_ = sys.stderr.write(
f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n"
)
sys.stderr.write(f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n")
sys.exit(1)
return env_path
@ -149,13 +104,10 @@ def resolve_picotool() -> Path:
if found:
return Path(found)
_ = sys.stderr.write(
"Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n"
)
sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n")
sys.exit(1)
def build(protocol: BuildProtocol) -> None:
def build():
run_cmd(
[
"cmake",
@ -164,23 +116,20 @@ def build(protocol: BuildProtocol) -> None:
"-B",
str(BUILD_DIR),
"-DSWITCH_PICO_LOG=OFF",
f"-DSWITCH_PICO_PROTOCOL={protocol}",
]
)
run_cmd(["cmake", "--build", str(BUILD_DIR)])
def flash() -> None:
def flash():
picotool = resolve_picotool()
if not ELF_PATH.exists():
_ = sys.stderr.write(
sys.stderr.write(
f"Error: Cannot find ELF at {ELF_PATH}. Set ELF_PATH to override.\n"
)
sys.exit(1)
run_cmd([str(picotool), "load", str(ELF_PATH), "-fx"])
def main() -> None:
def main():
args = parse_args()
color = None
@ -189,30 +138,16 @@ def main() -> None:
elif args.grip_color:
try:
color = validate_custom_color(args.grip_color)
except argparse.ArgumentTypeError as exc:
_ = sys.stderr.write(f"Error: {exc}\n")
except ValueError as exc:
sys.stderr.write(f"Error: {exc}\n")
sys.exit(1)
if color:
update_grip_colors(color)
print(f"Grip color set to #{color} in {CONFIG_FILE.name}")
build(args.protocol)
if args.build_only:
outputs = (BUILD_ELF_PATH, BUILD_UF2_PATH)
for output in outputs:
if not output.is_file():
_ = sys.stderr.write(
f"Error: Expected build output not found: {output}\n"
)
sys.exit(1)
print("Build outputs:")
for output in outputs:
print(f" {output}")
return
build()
flash()
if __name__ == "__main__":
main()

View file

@ -59,8 +59,12 @@ RUMBLE_MIN_ACTIVE = 0.40 # below this, rumble is treated as off/noise
RUMBLE_SCALE = 1.0
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)
GYRO_BIAS_SAMPLES = 200
SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(
sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658
)
GYRO_BIAS_SAMPLES = 100 # samples to collect for bias (~0.5 s at 200 Hz)
GYRO_BIAS_WARMUP_S = 1.5 # seconds to wait before starting calibration
GYRO_BIAS_TIMEOUT_S = 10.0 # force-lock after this many seconds even if still moving
IMU_BUFFER_SIZE = 32
@ -249,12 +253,16 @@ class ControllerContext:
sensors_supported: bool = False
sensors_enabled: bool = False
imu_samples: List[IMUSample] = field(default_factory=list)
last_accel: Tuple[float, float, float] = (0.0, 0.0, 0.0)
# Default: gravity on SDL Y axis (~+9.8 m/s²) — controller held horizontally.
# This prevents the first IMU sample from having zero accel before the first
# accel event arrives.
last_accel: Tuple[float, float, float] = (0.0, 9.80665, 0.0)
gyro_bias_x: float = 0.0
gyro_bias_y: float = 0.0
gyro_bias_z: float = 0.0
gyro_bias_samples: int = 0
gyro_bias_locked: bool = False
gyro_bias_start_time: float = 0.0 # monotonic time when calibration began
last_debug_imu_print: float = 0.0
@ -322,7 +330,9 @@ def initialize_controller_sensors(ctx: ControllerContext, console: Console) -> N
accel_enabled = sdl3.SDL_SetGamepadSensorEnabled(
ctx.controller, SENSOR_ACCEL, SDL_TRUE
)
gyro_enabled = sdl3.SDL_SetGamepadSensorEnabled(ctx.controller, SENSOR_GYRO, SDL_TRUE)
gyro_enabled = sdl3.SDL_SetGamepadSensorEnabled(
ctx.controller, SENSOR_GYRO, SDL_TRUE
)
ctx.sensors_enabled = accel_enabled and gyro_enabled
if not ctx.sensors_enabled:
console.print(
@ -819,7 +829,7 @@ class BridgeConfig:
swap_abxy_ids: set[str]
swap_abxy_global: bool
debug_imu: bool = False
no_imu: bool = False
no_imu: bool = True
gyro_scale: float = 1.0
@ -853,7 +863,9 @@ class PairingState:
ignore_port_desc: List[str] = field(default_factory=list)
include_port_desc: List[str] = field(default_factory=list)
include_port_mfr: List[str] = field(default_factory=list)
display_index_alloc: DisplayIndexAllocator = field(default_factory=DisplayIndexAllocator)
display_index_alloc: DisplayIndexAllocator = field(
default_factory=DisplayIndexAllocator
)
def load_button_maps(
@ -944,7 +956,11 @@ def detect_controllers(
if sdl3.SDL_IsGamepad(instance_id):
name = sdl3.SDL_GetGamepadNameForID(instance_id)
name_str = (
name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
name.decode()
if isinstance(name, bytes)
else str(name)
if name
else "Unknown"
)
if include_controller_name and all(
substr not in name_str.lower() for substr in include_controller_name
@ -953,14 +969,20 @@ def detect_controllers(
f"[yellow]Skipping controller ({name_str}) due to name filter[/yellow]"
)
continue
console.print(f"[cyan]Detected controller {display_counter}: ({name_str})[/cyan]")
console.print(
f"[cyan]Detected controller {display_counter}: ({name_str})[/cyan]"
)
display_counter += 1
controller_ids.append(instance_id)
controller_names[instance_id] = name_str
else:
name = sdl3.SDL_GetJoystickNameForID(instance_id)
name_str = (
name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
name.decode()
if isinstance(name, bytes)
else str(name)
if name
else "Unknown"
)
if include_controller_name and all(
substr not in name_str.lower() for substr in include_controller_name
@ -1003,10 +1025,19 @@ def list_controllers_with_guids(
if is_gc
else sdl3.SDL_GetJoystickNameForID(instance_id)
)
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
name_str = (
name.decode()
if isinstance(name, bytes)
else str(name)
if name
else "Unknown"
)
guid_str = guid_string_for_instance_id(instance_id)
table.add_row(
str(instance_id), "GameController" if is_gc else "Joystick", name_str, guid_str
str(instance_id),
"GameController" if is_gc else "Joystick",
name_str,
guid_str,
)
sdl3.SDL_free(joystick_ids)
console.print(table)
@ -1098,7 +1129,9 @@ def assign_port_for_index(
return port_choice
def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set[str]:
def ports_in_use(
pairing: PairingState, contexts: Dict[int, ControllerContext]
) -> set[str]:
"""Return a set of UART paths currently reserved or mapped."""
used = set(pairing.mapping_by_index.values())
used.update(ctx.port for ctx in contexts.values() if ctx.port)
@ -1217,7 +1250,13 @@ def open_initial_contexts(
for instance_id in controller_indices:
if not sdl3.SDL_IsGamepad(instance_id):
name = sdl3.SDL_GetJoystickNameForID(instance_id)
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
name_str = (
name.decode()
if isinstance(name, bytes)
else str(name)
if name
else "Unknown"
)
console.print(
f"[yellow]ID {instance_id} is not a GameController ({name_str}). Trying raw open failed.[/yellow]"
)
@ -1326,22 +1365,55 @@ def handle_sensor_update(
gx, gy, gz = float(data[0]), float(data[1]), float(data[2])
if not ctx.gyro_bias_locked:
now = time.monotonic()
# Track when the first gyro event arrived so we can enforce the warmup.
if ctx.gyro_bias_start_time == 0.0:
ctx.gyro_bias_start_time = now
print(
f"[IMU idx={ctx.controller_index}] Gyro bias calibration started — "
f"hold controller still for {GYRO_BIAS_WARMUP_S:.0f}s..."
)
elapsed = now - ctx.gyro_bias_start_time
# Phase 1: warmup — discard all samples, just wait.
if elapsed < GYRO_BIAS_WARMUP_S:
return
# Phase 2: collect samples unconditionally.
# Timeout: after GYRO_BIAS_TIMEOUT_S total, force-lock with whatever we have.
if ctx.gyro_bias_samples < GYRO_BIAS_SAMPLES:
ctx.gyro_bias_x += gx
ctx.gyro_bias_y += gy
ctx.gyro_bias_z += gz
ctx.gyro_bias_samples += 1
if ctx.gyro_bias_samples >= GYRO_BIAS_SAMPLES:
n = ctx.gyro_bias_samples
force_lock = elapsed > GYRO_BIAS_TIMEOUT_S and ctx.gyro_bias_samples > 10
if ctx.gyro_bias_samples >= GYRO_BIAS_SAMPLES or force_lock:
n = max(ctx.gyro_bias_samples, 1)
ctx.gyro_bias_x /= n
ctx.gyro_bias_y /= n
ctx.gyro_bias_z /= n
ctx.gyro_bias_locked = True
import math
if not ctx.gyro_bias_locked:
bx, by, bz = 0.0, 0.0, 0.0
else:
bx, by, bz = ctx.gyro_bias_x, ctx.gyro_bias_y, ctx.gyro_bias_z
mag = math.sqrt(
ctx.gyro_bias_x**2 + ctx.gyro_bias_y**2 + ctx.gyro_bias_z**2
)
quality = (
"OK" if mag < 0.05 else "WARN: controller was moving during calibration"
)
print(
f"[IMU idx={ctx.controller_index}] Bias locked{' (timeout)' if force_lock else ''}: "
f"({ctx.gyro_bias_x:.5f}, {ctx.gyro_bias_y:.5f}, {ctx.gyro_bias_z:.5f}) rad/s "
f"magnitude={mag:.4f} rad/s = {mag * 180 / math.pi:.2f} deg/s [{quality}]"
)
# Don't send IMU until bias is locked — raw unbiased values cause drift.
return
bx, by, bz = ctx.gyro_bias_x, ctx.gyro_bias_y, ctx.gyro_bias_z
ux, uy, uz = gx, gy, gz
ux -= bx
@ -1385,31 +1457,10 @@ def handle_sensor_update(
)
ABXY_SWAP_COMBO = frozenset({
sdl3.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER,
sdl3.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER,
sdl3.SDL_GAMEPAD_BUTTON_BACK,
sdl3.SDL_GAMEPAD_BUTTON_START,
})
def _check_abxy_swap_combo(
ctx: ControllerContext,
config: BridgeConfig,
console: Console,
) -> None:
"""Toggle ABXY layout when LB+RB+SELECT+START are all held."""
if not all(ctx.button_state.get(b) for b in ABXY_SWAP_COMBO):
return
toggle_abxy_for_context(ctx, config, console)
sdl3.SDL_RumbleGamepad(ctx.controller, 0xAAAA, 0xAAAA, 200)
def handle_button_event(
event: sdl3.SDL_Event,
config: BridgeConfig,
contexts: Dict[int, ControllerContext],
console: Console,
) -> None:
"""Process button events into report/dpad state."""
ctx = contexts.get(event.gbutton.which)
@ -1432,8 +1483,6 @@ def handle_button_event(
elif button in DPAD_BUTTONS:
ctx.dpad[DPAD_BUTTONS[button]] = pressed
ctx.report.hat = str_to_dpad(ctx.dpad)
if pressed and button in ABXY_SWAP_COMBO:
_check_abxy_swap_combo(ctx, config, console)
def handle_device_added(
@ -1451,7 +1500,13 @@ def handle_device_added(
return
if not sdl3.SDL_IsGamepad(sdl_id):
name = sdl3.SDL_GetJoystickNameForID(sdl_id)
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
name_str = (
name.decode()
if isinstance(name, bytes)
else str(name)
if name
else "Unknown"
)
console.print(
f"[yellow]Device {sdl_id} is not a GameController ({name_str}).[/yellow]"
)
@ -1464,11 +1519,15 @@ def handle_device_added(
try:
controller, instance_id, guid = open_controller(sdl_id)
except Exception as exc:
console.print(f"[red]Hotplug open failed for controller {display_idx}: {exc}[/red]")
console.print(
f"[red]Hotplug open failed for controller {display_idx}: {exc}[/red]"
)
pairing.display_index_alloc.release(display_idx)
return
stable_id = guid
should_swap = display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids
should_swap = (
display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids
)
uart = open_uart_or_warn(port, args.baud, console) if port else None
if uart:
uarts.append(uart)
@ -1569,12 +1628,23 @@ def service_contexts(
if ctx.sensors_enabled and not config.no_imu:
count = min(len(ctx.imu_samples), IMU_SAMPLES_PER_REPORT)
if count > 0:
ctx.report.imu_samples = ctx.imu_samples[:count]
ctx.imu_samples = ctx.imu_samples[count:]
# Take the NEWEST samples, discard stale ones.
# Previously took from front (oldest) which caused
# 145ms latency when FIFO was full at 29-32 samples.
ctx.report.imu_samples = ctx.imu_samples[-count:]
ctx.imu_samples.clear()
else:
ctx.report.imu_samples = []
else:
ctx.report.imu_samples = []
# Debug: log actual IMU values being sent via UART
if config.debug_imu and ctx.report.imu_samples:
s = ctx.report.imu_samples[0]
if abs(s.gyro_x) > 50 or abs(s.gyro_y) > 50 or abs(s.gyro_z) > 50:
print(
f"[UART_SEND] LARGE GYRO a=({s.accel_x},{s.accel_y},{s.accel_z}) "
f"g=({s.gyro_x},{s.gyro_y},{s.gyro_z}) fifo_remaining={len(ctx.imu_samples)}"
)
ctx.uart.send_report(ctx.report)
ctx.last_send = now
@ -1645,7 +1715,7 @@ def run_bridge_loop(
sdl3.SDL_EVENT_GAMEPAD_BUTTON_DOWN,
sdl3.SDL_EVENT_GAMEPAD_BUTTON_UP,
):
handle_button_event(event, config, contexts, console)
handle_button_event(event, config, contexts)
elif event.type == SDL_EVENT_GAMEPAD_SENSOR_UPDATE:
handle_sensor_update(event, contexts, config)
elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED:

View file

@ -4,9 +4,7 @@
#include "hardware/uart.h"
#include "pico/stdlib.h"
#include "tusb.h"
#include "switch_input.h"
#include "switch_protocol.h"
#include "switch_uart_protocol.h"
#include "switch_pro_driver.h"
#ifdef SWITCH_PICO_LOG
#define LOG_PRINTF(...) printf(__VA_ARGS__)
@ -62,7 +60,7 @@ static void on_rumble_from_switch(const uint8_t rumble[8]) {
send_rumble_uart_frame(rumble);
}
// Consume UART bytes and decode complete input frames.
// Consume UART bytes and forward complete frames to the Switch Pro driver.
static bool poll_uart_frames() {
static uint8_t buffer[64];
static uint8_t index = 0;
@ -105,7 +103,7 @@ static bool poll_uart_frames() {
if (expected_len > 0 && index >= expected_len) {
SwitchInputState parsed{};
if (switch_uart_decode_input_frame(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",
@ -139,7 +137,7 @@ static bool poll_uart_frames() {
static void log_usb_state() {
bool mounted = tud_mounted();
bool ready = switch_protocol_is_ready();
bool ready = switch_pro_is_ready();
if (mounted != g_last_mounted) {
g_last_mounted = mounted;
@ -158,10 +156,10 @@ int main() {
init_uart_input();
tusb_init();
switch_protocol_init();
switch_protocol_set_rumble_callback(on_rumble_from_switch);
switch_pro_init();
switch_pro_set_rumble_callback(on_rumble_from_switch);
g_user_state = neutral_input();
switch_protocol_set_input(g_user_state);
switch_pro_set_input(g_user_state);
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n",
@ -172,8 +170,8 @@ int main() {
bool new_data = poll_uart_frames(); // Pull controller state from UART1
(void)new_data;
SwitchInputState state = g_user_state;
switch_protocol_set_input(state);
switch_protocol_task(); // Push state to the Switch host
switch_pro_set_input(state);
switch_pro_task(); // Push state to the Switch host
log_usb_state();
}
}

View file

@ -1,79 +0,0 @@
#include "switch2_commands.h"
#include <cstring>
// Captured command vectors and acknowledgements:
// https://github.com/ndeadly/switch2_controller_research/blob/d1c5a7f7ba298f83017fae84952a4e6d2ef8fc92/commands.md
namespace {
constexpr uint8_t kSelectReportResponse[] = {
0x03, 0x01, 0x00, 0x0A, 0x00, 0xF8, 0x00, 0x00,
};
constexpr uint8_t kInitializeUsbResponse[] = {
0x03, 0x01, 0x00, 0x0D, 0x00, 0xF8, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00,
};
} // namespace
Switch2VendorCommand switch2_classify_vendor_request(
const uint8_t* data,
std::size_t length) {
if (data == nullptr || length < 8) {
return Switch2VendorCommand::Unsupported;
}
if (data[0] != 0x03 || data[1] != 0x91 || data[2] != 0x00 ||
data[4] != 0x00 || data[6] != 0x00 || data[7] != 0x00) {
return Switch2VendorCommand::Unsupported;
}
if (length != static_cast<std::size_t>(8 + data[5])) {
return Switch2VendorCommand::Unsupported;
}
switch (data[3]) {
case 0x0A:
if (data[5] != 4 || data[9] != 0 || data[10] != 0 || data[11] != 0) {
return Switch2VendorCommand::Unsupported;
}
if (data[8] == 0x05) return Switch2VendorCommand::SelectReport05;
if (data[8] == 0x09) return Switch2VendorCommand::SelectReport09;
return Switch2VendorCommand::Unsupported;
case 0x0D:
if (data[5] == 8 && data[8] == 0x01) {
return Switch2VendorCommand::InitializeUsb;
}
return Switch2VendorCommand::Unsupported;
default:
return Switch2VendorCommand::Unsupported;
}
}
std::size_t switch2_build_vendor_response(
Switch2VendorCommand command,
uint8_t* output,
std::size_t capacity) {
const uint8_t* response = nullptr;
std::size_t response_length = 0;
switch (command) {
case Switch2VendorCommand::Unsupported:
return 0;
case Switch2VendorCommand::SelectReport05:
case Switch2VendorCommand::SelectReport09:
response = kSelectReportResponse;
response_length = sizeof(kSelectReportResponse);
break;
case Switch2VendorCommand::InitializeUsb:
response = kInitializeUsbResponse;
response_length = sizeof(kInitializeUsbResponse);
break;
}
if (output == nullptr || capacity < response_length) {
return 0;
}
std::memcpy(output, response, response_length);
return response_length;
}

View file

@ -1,20 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
enum class Switch2VendorCommand {
Unsupported,
SelectReport05,
SelectReport09,
InitializeUsb,
};
Switch2VendorCommand switch2_classify_vendor_request(
const uint8_t* data,
std::size_t length);
std::size_t switch2_build_vendor_response(
Switch2VendorCommand command,
uint8_t* output,
std::size_t capacity);

View file

@ -1,64 +0,0 @@
#include "switch2_descriptors.h"
// Captured Pro Controller 2 descriptors:
// https://github.com/ndeadly/switch2_controller_research/blob/d1c5a7f7ba298f83017fae84952a4e6d2ef8fc92/descriptors.md
const uint8_t switch2_device_descriptor[] = {
0x12, 0x01, 0x00, 0x02, 0xEF, 0x02, 0x01, 0x40, 0x7E,
0x05, 0x69, 0x20, 0x00, 0x02, 0x01, 0x02, 0x03, 0x01,
};
const size_t switch2_device_descriptor_length = sizeof(switch2_device_descriptor);
// The capture has five interfaces and a 268-byte configuration. This explicit
// experimental subset keeps captured HID/vendor interfaces 0-1 only, changes
// wTotalLength to 80 and bNumInterfaces to 2, and zeros uncaptured string
// indices iConfiguration and iInterface. Audio interfaces 2-4 are omitted.
const uint8_t switch2_configuration_descriptor[] = {
0x09, 0x02, 0x50, 0x00, 0x02, 0x01, 0x00, 0xC0, 0xFA,
0x08, 0x0B, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00,
0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00,
0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x61, 0x00,
0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x04,
0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x04,
0x08, 0x0B, 0x01, 0x01, 0xFF, 0x00, 0x00, 0x00,
0x09, 0x04, 0x01, 0x00, 0x02, 0xFF, 0x00, 0x00, 0x00,
0x07, 0x05, 0x02, 0x02, 0x40, 0x00, 0x00,
0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00,
};
const size_t switch2_configuration_descriptor_length =
sizeof(switch2_configuration_descriptor);
const uint8_t switch2_hid_report_descriptor[] = {
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x05,
0x05, 0xFF, 0x09, 0x01, 0x15, 0x00, 0x26, 0xFF, 0x00,
0x95, 0x3F, 0x75, 0x08, 0x81, 0x02,
0x85, 0x09, 0x09, 0x01, 0x95, 0x02, 0x81, 0x02,
0x05, 0x09, 0x19, 0x01, 0x29, 0x15, 0x25, 0x01,
0x95, 0x15, 0x75, 0x01, 0x81, 0x02,
0x95, 0x01, 0x75, 0x03, 0x81, 0x03,
0x05, 0x01, 0x09, 0x01, 0xA1, 0x00,
0x09, 0x30, 0x09, 0x31, 0x09, 0x33, 0x09, 0x35,
0x26, 0xFF, 0x0F, 0x95, 0x04, 0x75, 0x0C, 0x81, 0x02, 0xC0,
0x05, 0xFF, 0x09, 0x02, 0x26, 0xFF, 0x00,
0x95, 0x34, 0x75, 0x08, 0x81, 0x02,
0x85, 0x02, 0x09, 0x01, 0x95, 0x3F, 0x91, 0x02, 0xC0,
};
const size_t switch2_hid_report_descriptor_length =
sizeof(switch2_hid_report_descriptor);
const uint8_t switch2_string_language[] = {0x09, 0x04};
const size_t switch2_string_language_length = sizeof(switch2_string_language);
const uint8_t switch2_string_manufacturer[] = "Nintendo";
const size_t switch2_string_manufacturer_length =
sizeof(switch2_string_manufacturer) - 1;
const uint8_t switch2_string_product[] = "Switch 2 Pro Controller";
const size_t switch2_string_product_length = sizeof(switch2_string_product) - 1;
const uint8_t switch2_string_serial[] = "00";
const size_t switch2_string_serial_length = sizeof(switch2_string_serial) - 1;
static_assert(sizeof(switch2_device_descriptor) == 18);
static_assert(sizeof(switch2_configuration_descriptor) == 80);
static_assert(sizeof(switch2_hid_report_descriptor) == 97);

View file

@ -1,22 +0,0 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
extern const uint8_t switch2_device_descriptor[];
extern const size_t switch2_device_descriptor_length;
extern const uint8_t switch2_configuration_descriptor[];
extern const size_t switch2_configuration_descriptor_length;
extern const uint8_t switch2_hid_report_descriptor[];
extern const size_t switch2_hid_report_descriptor_length;
extern const uint8_t switch2_string_language[];
extern const size_t switch2_string_language_length;
extern const uint8_t switch2_string_manufacturer[];
extern const size_t switch2_string_manufacturer_length;
extern const uint8_t switch2_string_product[];
extern const size_t switch2_string_product_length;
extern const uint8_t switch2_string_serial[];
extern const size_t switch2_string_serial_length;

View file

@ -1,237 +0,0 @@
#include "switch_protocol.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include "pico/time.h"
#include "switch2_commands.h"
#include "switch2_descriptors.h"
#include "switch2_reports.h"
#include "tusb.h"
namespace {
constexpr uint32_t kReportIntervalMs = 4;
constexpr std::size_t kMaxVendorRequestLength = 64;
constexpr std::size_t kMaxVendorResponseLength = 12;
SwitchInputState input_state{};
Switch2InputReportId selected_report = Switch2InputReportId::Pro;
uint32_t report_counter = 0;
uint32_t last_report_time = 0;
bool mounted = false;
bool initialized = false;
std::array<uint8_t, kMaxVendorResponseLength> pending_response{};
std::size_t pending_response_length = 0;
SwitchRumbleCallback rumble_callback = nullptr;
void reset_connection_state(bool is_mounted) {
input_state = {};
selected_report = Switch2InputReportId::Pro;
report_counter = 0;
last_report_time = 0;
mounted = is_mounted;
initialized = false;
pending_response_length = 0;
}
void flush_vendor_response() {
if (pending_response_length == 0) return;
const uint32_t written = tud_vendor_write(
pending_response.data(), static_cast<uint32_t>(pending_response_length));
if (written == 0) return;
const std::size_t consumed = written;
pending_response_length -= consumed;
if (pending_response_length > 0) {
std::memmove(
pending_response.data(),
pending_response.data() + consumed,
pending_response_length);
}
tud_vendor_write_flush();
}
} // namespace
void switch_protocol_init() {
rumble_callback = nullptr;
reset_connection_state(false);
}
void switch_protocol_set_input(const SwitchInputState& state) {
input_state = state;
}
void switch_protocol_task() {
if (!mounted) return;
const bool had_pending_response = pending_response_length > 0;
flush_vendor_response();
if (had_pending_response || !initialized || pending_response_length > 0) return;
const uint32_t now = static_cast<uint32_t>(
to_ms_since_boot(get_absolute_time()));
if ((now - last_report_time) < kReportIntervalMs || !tud_hid_ready()) return;
const Switch2InputReport report =
switch2_build_input_report(selected_report, input_state, report_counter);
if (tud_hid_report(
static_cast<uint8_t>(report.id),
report.payload.data(),
static_cast<uint16_t>(report.payload.size()))) {
++report_counter;
last_report_time = now;
}
}
bool switch_protocol_is_ready() {
return mounted && initialized;
}
void switch_protocol_set_rumble_callback(SwitchRumbleCallback callback) {
rumble_callback = callback;
}
uint8_t const* tud_descriptor_device_cb() {
return switch2_device_descriptor;
}
uint8_t const* tud_descriptor_configuration_cb(uint8_t index) {
return index == 0 ? switch2_configuration_descriptor : nullptr;
}
uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) {
return instance == 0 ? switch2_hid_report_descriptor : nullptr;
}
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 reqlen) {
if (instance != 0 || report_type != HID_REPORT_TYPE_INPUT || buffer == nullptr ||
!mounted || !initialized) {
return 0;
}
Switch2InputReportId id;
if (report_id == static_cast<uint8_t>(Switch2InputReportId::Common)) {
id = Switch2InputReportId::Common;
} else if (report_id == static_cast<uint8_t>(Switch2InputReportId::Pro)) {
id = Switch2InputReportId::Pro;
} else {
return 0;
}
const Switch2InputReport report =
switch2_build_input_report(id, input_state, report_counter);
const uint16_t length = reqlen < report.payload.size()
? reqlen
: static_cast<uint16_t>(report.payload.size());
std::memcpy(buffer, report.payload.data(), length);
return length;
}
void tud_hid_set_report_cb(
uint8_t instance,
uint8_t report_id,
hid_report_type_t report_type,
uint8_t const* buffer,
uint16_t bufsize) {
if (instance != 0 || report_type != HID_REPORT_TYPE_OUTPUT || buffer == nullptr) return;
uint8_t const* payload = nullptr;
if (report_id == 0 && bufsize == 64 && buffer[0] == 0x02) {
payload = buffer + 1;
} else if (report_id == 0x02 && bufsize == 63) {
payload = buffer;
} else {
return;
}
// Report 0x02 carries two native HD-rumble payloads. No legacy UART mapping exists.
(void)payload;
}
void tud_vendor_rx_cb(uint8_t itf, uint8_t const* buffer, uint16_t bufsize) {
if (itf != 0) return;
std::array<uint8_t, kMaxVendorRequestLength> request{};
const std::size_t request_length = bufsize < request.size() ? bufsize : request.size();
if (buffer != nullptr) {
std::memcpy(request.data(), buffer, request_length);
}
tud_vendor_read_flush();
if (!mounted || buffer == nullptr) return;
const Switch2VendorCommand command =
switch2_classify_vendor_request(request.data(), request_length);
switch (command) {
case Switch2VendorCommand::Unsupported:
return;
case Switch2VendorCommand::SelectReport05:
selected_report = Switch2InputReportId::Common;
break;
case Switch2VendorCommand::SelectReport09:
selected_report = Switch2InputReportId::Pro;
break;
case Switch2VendorCommand::InitializeUsb:
initialized = true;
break;
}
pending_response_length = switch2_build_vendor_response(
command, pending_response.data(), pending_response.size());
}
void tud_mount_cb() {
reset_connection_state(true);
}
void tud_umount_cb() {
reset_connection_state(false);
}
uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
(void)langid;
static uint16_t descriptor[32];
if (index == 0) {
descriptor[1] = static_cast<uint16_t>(switch2_string_language[0]) |
(static_cast<uint16_t>(switch2_string_language[1]) << 8);
descriptor[0] = 0x0304;
return descriptor;
}
const uint8_t* string = nullptr;
std::size_t length = 0;
switch (index) {
case 1:
string = switch2_string_manufacturer;
length = switch2_string_manufacturer_length;
break;
case 2:
string = switch2_string_product;
length = switch2_string_product_length;
break;
case 3:
string = switch2_string_serial;
length = switch2_string_serial_length;
break;
default:
return nullptr;
}
if (length > 31) length = 31;
for (std::size_t position = 0; position < length; ++position) {
descriptor[1 + position] = string[position];
}
descriptor[0] = static_cast<uint16_t>(0x0300 | (2 * length + 2));
return descriptor;
}

View file

@ -1,89 +0,0 @@
#include "switch2_reports.h"
// Report layouts are pinned to:
// https://github.com/ndeadly/switch2_controller_research/blob/d1c5a7f7ba298f83017fae84952a4e6d2ef8fc92/hid_reports.md
namespace {
void pack_stick(uint8_t* destination, uint16_t x, uint16_t y) {
const uint16_t packed_x = x >> 4;
const uint16_t packed_y = y >> 4;
destination[0] = static_cast<uint8_t>(packed_x & 0xFF);
destination[1] = static_cast<uint8_t>(
((packed_x >> 8) & 0x0F) | ((packed_y & 0x0F) << 4));
destination[2] = static_cast<uint8_t>(packed_y >> 4);
}
} // namespace
Switch2InputReport switch2_build_input_report(
Switch2InputReportId id,
const SwitchInputState& state,
uint32_t counter) {
Switch2InputReport report{id, {}};
std::array<uint8_t, 63>& payload = report.payload;
switch (id) {
case Switch2InputReportId::Common:
payload[0] = static_cast<uint8_t>(counter);
payload[1] = static_cast<uint8_t>(counter >> 8);
payload[2] = static_cast<uint8_t>(counter >> 16);
payload[3] = static_cast<uint8_t>(counter >> 24);
payload[4] = static_cast<uint8_t>(
(state.button_zr ? 0x80 : 0) |
(state.button_r ? 0x40 : 0) |
(state.button_a ? 0x08 : 0) |
(state.button_b ? 0x04 : 0) |
(state.button_x ? 0x02 : 0) |
(state.button_y ? 0x01 : 0));
payload[5] = static_cast<uint8_t>(
(state.button_capture ? 0x20 : 0) |
(state.button_home ? 0x10 : 0) |
(state.button_l3 ? 0x08 : 0) |
(state.button_r3 ? 0x04 : 0) |
(state.button_plus ? 0x02 : 0) |
(state.button_minus ? 0x01 : 0));
payload[6] = static_cast<uint8_t>(
(state.button_zl ? 0x80 : 0) |
(state.button_l ? 0x40 : 0) |
(state.dpad_left ? 0x08 : 0) |
(state.dpad_right ? 0x04 : 0) |
(state.dpad_up ? 0x02 : 0) |
(state.dpad_down ? 0x01 : 0));
pack_stick(&payload[10], state.lx, state.ly);
pack_stick(&payload[13], state.rx, state.ry);
payload[0x29] = 0x01;
break;
case Switch2InputReportId::Pro:
payload[0] = static_cast<uint8_t>(counter);
// USB external power is known; charging and battery bits stay zero.
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(
(state.button_r3 ? 0x80 : 0) |
(state.button_plus ? 0x40 : 0) |
(state.button_zr ? 0x20 : 0) |
(state.button_r ? 0x10 : 0) |
(state.button_x ? 0x08 : 0) |
(state.button_y ? 0x04 : 0) |
(state.button_a ? 0x02 : 0) |
(state.button_b ? 0x01 : 0));
payload[3] = static_cast<uint8_t>(
(state.button_l3 ? 0x80 : 0) |
(state.button_minus ? 0x40 : 0) |
(state.button_zl ? 0x20 : 0) |
(state.button_l ? 0x10 : 0) |
(state.dpad_up ? 0x08 : 0) |
(state.dpad_left ? 0x04 : 0) |
(state.dpad_right ? 0x02 : 0) |
(state.dpad_down ? 0x01 : 0));
payload[4] = static_cast<uint8_t>(
(state.button_capture ? 0x02 : 0) |
(state.button_home ? 0x01 : 0));
pack_stick(&payload[5], state.lx, state.ly);
pack_stick(&payload[8], state.rx, state.ry);
payload[11] = 0x30;
break;
}
return report;
}

View file

@ -1,21 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
#include "switch_input.h"
enum class Switch2InputReportId : uint8_t {
Common = 0x05,
Pro = 0x09,
};
struct Switch2InputReport {
Switch2InputReportId id;
std::array<uint8_t, 63> payload;
};
Switch2InputReport switch2_build_input_report(
Switch2InputReportId id,
const SwitchInputState& state,
uint32_t counter);

View file

@ -1,72 +0,0 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#define SWITCH_PRO_HAT_UP 0x00
#define SWITCH_PRO_HAT_UPRIGHT 0x01
#define SWITCH_PRO_HAT_RIGHT 0x02
#define SWITCH_PRO_HAT_DOWNRIGHT 0x03
#define SWITCH_PRO_HAT_DOWN 0x04
#define SWITCH_PRO_HAT_DOWNLEFT 0x05
#define SWITCH_PRO_HAT_LEFT 0x06
#define SWITCH_PRO_HAT_UPLEFT 0x07
#define SWITCH_PRO_HAT_NOTHING 0x08
#define SWITCH_PRO_MASK_Y (1U << 0)
#define SWITCH_PRO_MASK_B (1U << 1)
#define SWITCH_PRO_MASK_A (1U << 2)
#define SWITCH_PRO_MASK_X (1U << 3)
#define SWITCH_PRO_MASK_L (1U << 4)
#define SWITCH_PRO_MASK_R (1U << 5)
#define SWITCH_PRO_MASK_ZL (1U << 6)
#define SWITCH_PRO_MASK_ZR (1U << 7)
#define SWITCH_PRO_MASK_MINUS (1U << 8)
#define SWITCH_PRO_MASK_PLUS (1U << 9)
#define SWITCH_PRO_MASK_L3 (1U << 10)
#define SWITCH_PRO_MASK_R3 (1U << 11)
#define SWITCH_PRO_MASK_HOME (1U << 12)
#define SWITCH_PRO_MASK_CAPTURE (1U << 13)
#define SWITCH_PRO_JOYSTICK_MIN 0x0000
#define SWITCH_PRO_JOYSTICK_MID 0x7FFF
#define SWITCH_PRO_JOYSTICK_MAX 0xFFFF
typedef struct {
int16_t accel_x;
int16_t accel_y;
int16_t accel_z;
int16_t gyro_x;
int16_t gyro_y;
int16_t gyro_z;
} SwitchImuSample;
typedef struct {
bool dpad_up;
bool dpad_down;
bool dpad_left;
bool dpad_right;
bool button_a;
bool button_b;
bool button_x;
bool button_y;
bool button_l;
bool button_r;
bool button_zl;
bool button_zr;
bool button_plus;
bool button_minus;
bool button_home;
bool button_capture;
bool button_l3;
bool button_r3;
uint16_t lx;
uint16_t ly;
uint16_t rx;
uint16_t ry;
uint8_t imu_sample_count;
SwitchImuSample imu_samples[3];
} SwitchInputState;

View file

@ -1,23 +0,0 @@
#include "switch_protocol.h"
#include "switch_pro_driver.h"
void switch_protocol_init() {
switch_pro_init();
}
void switch_protocol_set_input(const SwitchInputState& state) {
switch_pro_set_input(state);
}
void switch_protocol_task() {
switch_pro_task();
}
bool switch_protocol_is_ready() {
return switch_pro_is_ready();
}
void switch_protocol_set_rumble_callback(SwitchRumbleCallback callback) {
switch_pro_set_rumble_callback(callback);
}

View file

@ -1,71 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
enum class SwitchProOutputReportKind : uint8_t {
Ignore,
Noop,
Rumble,
Feature,
Configuration,
};
inline SwitchProOutputReportKind switch_pro_classify_output_report(
const uint8_t* report,
std::size_t length) {
if (report == nullptr || length < 2 || length > 64) {
return SwitchProOutputReportKind::Ignore;
}
switch (report[0]) {
case 0x00:
return SwitchProOutputReportKind::Noop;
case 0x01:
return length >= 16
? SwitchProOutputReportKind::Feature
: SwitchProOutputReportKind::Ignore;
case 0x10:
case 0x21:
return length >= 10
? SwitchProOutputReportKind::Rumble
: SwitchProOutputReportKind::Ignore;
case 0x80:
return SwitchProOutputReportKind::Configuration;
default:
return SwitchProOutputReportKind::Ignore;
}
}
inline bool switch_pro_spi_read_size_fits(std::size_t size) {
return size <= 64 - 20;
}
inline std::size_t switch_pro_fill_flash_read(
uint8_t* destination,
std::size_t destination_capacity,
const uint8_t* source,
std::size_t source_size,
std::size_t source_offset,
std::size_t requested) {
if (destination == nullptr) {
return 0;
}
const std::size_t produced = requested < destination_capacity
? requested
: destination_capacity;
for (std::size_t index = 0; index < produced; ++index) {
destination[index] = 0xFF;
}
if (source != nullptr && source_offset < source_size) {
const std::size_t available = source_size - source_offset;
const std::size_t copied = available < produced ? available : produced;
for (std::size_t index = 0; index < copied; ++index) {
destination[index] = source[source_offset + index];
}
}
return produced;
}

View file

@ -9,10 +9,39 @@
#include <stdint.h>
#include "switch_input.h"
#define SWITCH_PRO_ENDPOINT_SIZE 64
// HAT report (4 bits)
#define SWITCH_PRO_HAT_UP 0x00
#define SWITCH_PRO_HAT_UPRIGHT 0x01
#define SWITCH_PRO_HAT_RIGHT 0x02
#define SWITCH_PRO_HAT_DOWNRIGHT 0x03
#define SWITCH_PRO_HAT_DOWN 0x04
#define SWITCH_PRO_HAT_DOWNLEFT 0x05
#define SWITCH_PRO_HAT_LEFT 0x06
#define SWITCH_PRO_HAT_UPLEFT 0x07
#define SWITCH_PRO_HAT_NOTHING 0x08
#define SWITCH_PRO_MASK_Y (1U << 0)
#define SWITCH_PRO_MASK_B (1U << 1)
#define SWITCH_PRO_MASK_A (1U << 2)
#define SWITCH_PRO_MASK_X (1U << 3)
#define SWITCH_PRO_MASK_L (1U << 4)
#define SWITCH_PRO_MASK_R (1U << 5)
#define SWITCH_PRO_MASK_ZL (1U << 6)
#define SWITCH_PRO_MASK_ZR (1U << 7)
#define SWITCH_PRO_MASK_MINUS (1U << 8)
#define SWITCH_PRO_MASK_PLUS (1U << 9)
#define SWITCH_PRO_MASK_L3 (1U << 10)
#define SWITCH_PRO_MASK_R3 (1U << 11)
#define SWITCH_PRO_MASK_HOME (1U << 12)
#define SWITCH_PRO_MASK_CAPTURE (1U << 13)
#define SWITCH_PRO_JOYSTICK_MIN 0x0000
#define SWITCH_PRO_JOYSTICK_MID 0x7FFF
#define SWITCH_PRO_JOYSTICK_MAX 0xFFFF
typedef enum {
REPORT_OUTPUT_00 = 0x00,
REPORT_FEATURE = 0x01,

View file

@ -1,5 +1,4 @@
#include "switch_pro_driver.h"
#include "switch_pro_bounds.h"
#include <algorithm>
#include <cstring>
@ -7,7 +6,6 @@
#include <stdio.h>
#include "pico/rand.h"
#include "pico/time.h"
#include "switch_pro_descriptors.h"
#include "tusb.h"
#ifdef SWITCH_PICO_LOG
@ -99,10 +97,18 @@ static const uint8_t factory_config_data[0xEFF] = {
0xFF, 0xFF, 0xFF, 0xFF,
// config & calibration 1
0xE3, 0xFF, 0x39, 0xFF, 0xED, 0x01, 0x00, 0x40,
0x00, 0x40, 0x00, 0x40, 0x09, 0x00, 0xEA, 0xFF,
0xA1, 0xFF, 0x3B, 0x34, 0x3B, 0x34, 0x3B, 0x34,
// config & calibration 1 (6-axis IMU, SPI 0x6020-0x6037)
// Accel origin (0,0,0): bridge pre-corrects for bias, so Switch must not
// apply a second origin offset. Real controllers have hardware DC offsets
// here, but our emulated sensor sends bias-corrected values.
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// Accel sensitivity coeff: 0x4000 = 16384 → 4096 LSB/G (matches bridge)
0x00, 0x40, 0x00, 0x40, 0x00, 0x40,
// Gyro origin (0,0,0): bridge removes hardware bias before sending.
// Original values (9, -22, -95) caused phantom 6.7 dps yaw when still.
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// Gyro sensitivity coeff: 0x343B = 13371 → 818.5 LSB/rad_s (matches bridge)
0x3B, 0x34, 0x3B, 0x34, 0x3B, 0x34,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
@ -137,7 +143,12 @@ static const uint8_t factory_config_data[0xEFF] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF,
0x50, 0xFD, 0x00, 0x00, 0xC6, 0x0F,
// Six-Axis horizontal offsets (SPI 0x6080): expected accel when held in
// gaming position. Must match the bridge's actual output for a still
// controller. Old values (-688,0,4038) were for a real Pro Controller's
// physical IMU; our bridge sends (~0,~0,~4096). The 388-count mismatch
// on X caused the Switch's sensor fusion to fight the gyro → camera swing.
0x00, 0x00, 0x00, 0x00, 0x00, 0x10, // (0, 0, 4096) = 1G on Z
0x0F, 0x30, 0x61, 0xAE, 0x90, 0xD9, 0xD4, 0x14,
0x54, 0x41, 0x15, 0x54, 0xC7, 0x79, 0x9C, 0x33,
0x36, 0x63,
@ -179,21 +190,33 @@ static const uint8_t user_calibration_data[0x3F] = {
static const SwitchFactoryConfig* factory_config = reinterpret_cast<const SwitchFactoryConfig*>(factory_config_data);
static const SwitchUserCalibration* user_calibration [[maybe_unused]] = reinterpret_cast<const SwitchUserCalibration*>(user_calibration_data);
struct SpiFlashRegion {
const uint8_t* data;
std::size_t size;
};
static const std::map<uint32_t, SpiFlashRegion> spi_flash_data = {
{0x6000, {factory_config_data, sizeof(factory_config_data)}},
{0x8000, {user_calibration_data, sizeof(user_calibration_data)}}
static std::map<uint32_t, const uint8_t*> spi_flash_data = {
{0x6000, factory_config_data},
{0x8000, user_calibration_data}
};
static inline uint16_t scale16To12(uint16_t pos) { return pos >> 4; }
// Default "at rest" IMU sample: zero gyro, ~1G on accel Z (face-up).
// Written to imuData at boot and whenever no fresh UART data is available,
// so the Switch never sees all-zero IMU (which it interprets as free-fall).
static const uint8_t DEFAULT_IMU_SAMPLE[12] = {
0x00, 0x00, // accel_x = 0
0x00, 0x00, // accel_y = 0
0x00, 0x10, // accel_z = 0x1000 = 4096 = 1G
0x00, 0x00, // gyro_x = 0
0x00, 0x00, // gyro_y = 0
0x00, 0x00, // gyro_z = 0
};
static void fill_imu_report_data(const SwitchInputState& state) {
if (state.imu_sample_count == 0) {
memset(switch_report.imuData, 0x00, sizeof(switch_report.imuData));
// No new IMU data — fill with default "at rest" sample.
// This prevents the Switch from seeing all-zero accel (free-fall)
// during startup or when the bridge hasn't sent IMU yet.
for (int i = 0; i < 3; ++i) {
memcpy(switch_report.imuData + i * 12, DEFAULT_IMU_SAMPLE, 12);
}
return;
}
uint8_t sample_count = state.imu_sample_count > 3 ? 3 : state.imu_sample_count;
@ -217,6 +240,16 @@ static void fill_imu_report_data(const SwitchInputState& state) {
}
}
static SwitchInputState make_neutral_state() {
SwitchInputState s{};
s.lx = SWITCH_PRO_JOYSTICK_MID;
s.ly = SWITCH_PRO_JOYSTICK_MID;
s.rx = SWITCH_PRO_JOYSTICK_MID;
s.ry = SWITCH_PRO_JOYSTICK_MID;
s.imu_sample_count = 0;
return s;
}
static void send_identify() {
memset(report_buffer, 0x00, sizeof(report_buffer));
report_buffer[0] = REPORT_USB_INPUT_81;
@ -241,32 +274,16 @@ static bool send_report(uint8_t reportID, const void* reportData, uint16_t repor
return result;
}
static void read_spi_flash(
uint8_t* destination,
std::size_t destination_capacity,
uint32_t address,
uint8_t size) {
static void read_spi_flash(uint8_t* dest, uint32_t address, uint8_t size) {
uint32_t addressBank = address & 0xFFFFFF00;
uint32_t addressOffset = address & 0x000000FF;
auto it = spi_flash_data.find(addressBank);
if (it != spi_flash_data.end()) {
const SpiFlashRegion& region = it->second;
switch_pro_fill_flash_read(
destination,
destination_capacity,
region.data,
region.size,
addressOffset,
size);
const uint8_t* data = it->second;
memcpy(dest, data + addressOffset, size);
} else {
switch_pro_fill_flash_read(
destination,
destination_capacity,
nullptr,
0,
0,
size);
memset(dest, 0xFF, size);
}
}
@ -336,6 +353,8 @@ static void handle_feature_report(uint8_t switchReportID, uint8_t switchReportSu
uint8_t spiReadSize = 0;
bool canSend = false;
last_host_activity_ms = to_ms_since_boot(get_absolute_time());
LOG_PRINTF("[HID] handle_feature rid=0x%02x cmd=0x%02x imu_enabled=%d\n",
switchReportID, commandID, is_imu_enabled);
report_buffer[0] = REPORT_OUTPUT_21;
report_buffer[1] = last_report_counter;
@ -393,11 +412,7 @@ static void handle_feature_report(uint8_t switchReportID, uint8_t switchReportSu
report_buffer[17] = reportData[13];
report_buffer[18] = reportData[14];
report_buffer[19] = reportData[15];
read_spi_flash(
&report_buffer[20],
sizeof(report_buffer) - 20,
spiReadAddress,
spiReadSize);
read_spi_flash(&report_buffer[20], spiReadAddress, spiReadSize);
canSend = true;
LOG_PRINTF("[HID] FEATURE SPI_READ addr=0x%08lx size=%u\n", (unsigned long)spiReadAddress, spiReadSize);
break;
@ -641,6 +656,19 @@ void switch_pro_task() {
uint16_t report_size = sizeof(switch_report);
if (tud_hid_ready() && send_report(0, inputReport, report_size) == true ) {
memcpy(last_report, inputReport, report_size);
// Log IMU data being sent (throttled to ~4Hz to avoid flooding UART0)
static uint32_t last_imu_log = 0;
if (now - last_imu_log > 250) {
last_imu_log = now;
int16_t ax = (int16_t)(switch_report.imuData[0] | (switch_report.imuData[1] << 8));
int16_t ay = (int16_t)(switch_report.imuData[2] | (switch_report.imuData[3] << 8));
int16_t az = (int16_t)(switch_report.imuData[4] | (switch_report.imuData[5] << 8));
int16_t gx = (int16_t)(switch_report.imuData[6] | (switch_report.imuData[7] << 8));
int16_t gy = (int16_t)(switch_report.imuData[8] | (switch_report.imuData[9] << 8));
int16_t gz = (int16_t)(switch_report.imuData[10] | (switch_report.imuData[11] << 8));
LOG_PRINTF("[IMU_OUT] a=(%d,%d,%d) g=(%d,%d,%d) cnt=%d\n",
ax, ay, az, gx, gy, gz, g_input_state.imu_sample_count);
}
g_input_state.imu_sample_count = 0;
report_sent = true;
}
@ -660,6 +688,112 @@ void switch_pro_task() {
}
}
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, SwitchInputState* out_state) {
// v2 format: 0xAA + 0x02 + payload_len + payload... + checksum
if (length < 12) {
return false;
}
if (packet[0] != 0xAA) {
return false;
}
if (packet[1] != 0x02) {
return false;
}
uint8_t payload_len = packet[2];
if ((uint16_t)payload_len + 4u != length) {
return false;
}
uint16_t sum = 0;
for (uint16_t i = 0; i < (uint16_t)(3u + payload_len); ++i) {
sum += packet[i];
}
if ((sum & 0xFF) != packet[length - 1]) {
return false;
}
// payload: buttons(2 LE), hat, lx, ly, rx, ry, imu_count, [imu_samples...]
if (payload_len < 8) {
return false;
}
SwitchProOutReport out{};
out.buttons = static_cast<uint16_t>(packet[3]) | (static_cast<uint16_t>(packet[4]) << 8);
out.hat = packet[5];
out.lx = packet[6];
out.ly = packet[7];
out.rx = packet[8];
out.ry = packet[9];
uint8_t imu_count = packet[10];
if (imu_count > 3) {
imu_count = 3;
}
uint16_t required_payload_len = static_cast<uint16_t>(8u + static_cast<uint16_t>(imu_count) * 12u);
if (payload_len < required_payload_len) {
return false;
}
auto expand_axis = [](uint8_t v) -> uint16_t {
return static_cast<uint16_t>(v) << 8 | v;
};
SwitchInputState state = make_neutral_state();
state.imu_sample_count = imu_count;
auto read_int16 = [](const uint8_t* src) -> int16_t {
return static_cast<int16_t>(static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8));
};
for (uint8_t i = 0; i < imu_count; ++i) {
const uint8_t* base = &packet[11 + i * 12];
state.imu_samples[i].accel_x = read_int16(base + 0);
state.imu_samples[i].accel_y = read_int16(base + 2);
state.imu_samples[i].accel_z = read_int16(base + 4);
state.imu_samples[i].gyro_x = read_int16(base + 6);
state.imu_samples[i].gyro_y = read_int16(base + 8);
state.imu_samples[i].gyro_z = read_int16(base + 10);
}
switch (out.hat) {
case SWITCH_PRO_HAT_UP: state.dpad_up = true; break;
case SWITCH_PRO_HAT_UPRIGHT: state.dpad_up = true; state.dpad_right = true; break;
case SWITCH_PRO_HAT_RIGHT: state.dpad_right = true; break;
case SWITCH_PRO_HAT_DOWNRIGHT: state.dpad_down = true; state.dpad_right = true; break;
case SWITCH_PRO_HAT_DOWN: state.dpad_down = true; break;
case SWITCH_PRO_HAT_DOWNLEFT: state.dpad_down = true; state.dpad_left = true; break;
case SWITCH_PRO_HAT_LEFT: state.dpad_left = true; break;
case SWITCH_PRO_HAT_UPLEFT: state.dpad_up = true; state.dpad_left = true; break;
default: break;
}
state.button_y = out.buttons & SWITCH_PRO_MASK_Y;
state.button_x = out.buttons & SWITCH_PRO_MASK_X;
state.button_b = out.buttons & SWITCH_PRO_MASK_B;
state.button_a = out.buttons & SWITCH_PRO_MASK_A;
state.button_r = out.buttons & SWITCH_PRO_MASK_R;
state.button_zr = out.buttons & SWITCH_PRO_MASK_ZR;
state.button_plus = out.buttons & SWITCH_PRO_MASK_PLUS;
state.button_minus = out.buttons & SWITCH_PRO_MASK_MINUS;
state.button_r3 = out.buttons & SWITCH_PRO_MASK_R3;
state.button_l3 = out.buttons & SWITCH_PRO_MASK_L3;
state.button_home = out.buttons & SWITCH_PRO_MASK_HOME;
state.button_capture = out.buttons & SWITCH_PRO_MASK_CAPTURE;
state.button_zl = out.buttons & SWITCH_PRO_MASK_ZL;
state.button_l = out.buttons & SWITCH_PRO_MASK_L;
state.lx = expand_axis(out.lx);
state.ly = expand_axis(out.ly);
state.rx = expand_axis(out.rx);
state.ry = expand_axis(out.ry);
if (!out_state) {
return false;
}
*out_state = state;
return true;
}
void switch_pro_set_rumble_callback(SwitchRumbleCallback cb) {
rumble_callback = cb;
}
@ -668,52 +802,6 @@ bool switch_pro_is_ready() {
return is_ready;
}
static void dispatch_output_report(
uint8_t instance,
uint8_t report_id,
const uint8_t* buffer,
uint16_t length) {
if (instance != 0) {
return;
}
const SwitchProOutputReportKind kind =
switch_pro_classify_output_report(buffer, length);
if (kind == SwitchProOutputReportKind::Ignore) {
return;
}
if (kind == SwitchProOutputReportKind::Feature &&
buffer[10] == SPI_READ &&
!switch_pro_spi_read_size_fits(buffer[15])) {
return;
}
memset(report_buffer, 0x00, sizeof(report_buffer));
const uint8_t switchReportID = buffer[0];
const uint8_t switchReportSubID = buffer[1];
LOG_PRINTF("[HID] output_report id=%u switchRID=0x%02x sub=0x%02x len=%u\n",
report_id, switchReportID, switchReportSubID, length);
switch (kind) {
case SwitchProOutputReportKind::Noop:
return;
case SwitchProOutputReportKind::Rumble:
forward_rumble_to_host(buffer, length);
return;
case SwitchProOutputReportKind::Feature:
queued_report_id = report_id;
handle_feature_report(switchReportID, switchReportSubID, buffer, length);
return;
case SwitchProOutputReportKind::Configuration:
queued_report_id = report_id;
handle_config_report(switchReportID, switchReportSubID, buffer, length);
return;
case SwitchProOutputReportKind::Ignore:
return;
}
}
// HID callbacks
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 reqlen) {
(void)instance;
@ -728,12 +816,50 @@ uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_t
}
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t const *buffer, uint16_t bufsize) {
(void)instance;
if (report_type != HID_REPORT_TYPE_OUTPUT) return;
dispatch_output_report(instance, report_id, buffer, bufsize);
memset(report_buffer, 0x00, bufsize);
uint8_t switchReportID = buffer[0];
uint8_t switchReportSubID = buffer[1];
LOG_PRINTF("[HID] set_report type=%d id=%u switchRID=0x%02x sub=0x%02x len=%u\n",
report_type, report_id, switchReportID, switchReportSubID, bufsize);
if (switchReportID == REPORT_OUTPUT_10 || switchReportID == REPORT_OUTPUT_21) {
forward_rumble_to_host(buffer, bufsize);
}
if (switchReportID == REPORT_OUTPUT_00) {
// No-op, just acknowledge to clear any stalls.
return;
} else if (switchReportID == REPORT_FEATURE) {
queued_report_id = report_id;
handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize);
} else if (switchReportID == REPORT_CONFIGURATION) {
queued_report_id = report_id;
handle_config_report(switchReportID, switchReportSubID, buffer, bufsize);
}
}
void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, uint8_t const* buffer, uint16_t bufsize) {
dispatch_output_report(instance, report_id, buffer, bufsize);
(void)instance;
// Host sent data on interrupt OUT; mirror the control path handling.
memset(report_buffer, 0x00, bufsize);
uint8_t switchReportID = buffer[0];
uint8_t switchReportSubID = buffer[1];
LOG_PRINTF("[HID] report_received id=%u switchRID=0x%02x sub=0x%02x len=%u\n",
report_id, switchReportID, switchReportSubID, bufsize);
if (switchReportID == REPORT_OUTPUT_10 || switchReportID == REPORT_OUTPUT_21) {
forward_rumble_to_host(buffer, bufsize);
}
if (switchReportID == REPORT_OUTPUT_00) {
return;
} else if (switchReportID == REPORT_FEATURE) {
queued_report_id = report_id;
handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize);
} else if (switchReportID == REPORT_CONFIGURATION) {
queued_report_id = report_id;
handle_config_report(switchReportID, switchReportSubID, buffer, bufsize);
}
}
uint8_t const * tud_hid_descriptor_report_cb(uint8_t itf) {

View file

@ -8,8 +8,46 @@
#include <stdbool.h>
#include <stdint.h>
#include "switch_pro_descriptors.h"
#include "switch_protocol.h"
typedef struct {
int16_t accel_x;
int16_t accel_y;
int16_t accel_z;
int16_t gyro_x;
int16_t gyro_y;
int16_t gyro_z;
} SwitchImuSample;
typedef struct {
bool dpad_up;
bool dpad_down;
bool dpad_left;
bool dpad_right;
bool button_a;
bool button_b;
bool button_x;
bool button_y;
bool button_l;
bool button_r;
bool button_zl;
bool button_zr;
bool button_plus;
bool button_minus;
bool button_home;
bool button_capture;
bool button_l3;
bool button_r3;
uint16_t lx; // 0-65535
uint16_t ly;
uint16_t rx;
uint16_t ry;
uint8_t imu_sample_count; // 0-3
SwitchImuSample imu_samples[3];
} SwitchInputState;
// Initialize USB state and calibration before entering the main loop.
void switch_pro_init();
@ -20,8 +58,13 @@ 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();
// 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);
// Driver state helpers
bool switch_pro_is_ready();
// 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);

View file

@ -1,13 +0,0 @@
#pragma once
#include <stdint.h>
#include "switch_input.h"
using SwitchRumbleCallback = void (*)(const uint8_t rumble_data[8]);
void switch_protocol_init();
void switch_protocol_set_input(const SwitchInputState& state);
void switch_protocol_task();
bool switch_protocol_is_ready();
void switch_protocol_set_rumble_callback(SwitchRumbleCallback callback);

View file

@ -1,124 +0,0 @@
#include "switch_uart_protocol.h"
namespace {
SwitchInputState make_neutral_state() {
SwitchInputState state{};
state.lx = SWITCH_PRO_JOYSTICK_MID;
state.ly = SWITCH_PRO_JOYSTICK_MID;
state.rx = SWITCH_PRO_JOYSTICK_MID;
state.ry = SWITCH_PRO_JOYSTICK_MID;
state.imu_sample_count = 0;
return state;
}
} // namespace
bool switch_uart_decode_input_frame(
const uint8_t* packet,
uint8_t length,
SwitchInputState* out_state) {
if (length < 12) {
return false;
}
if (packet[0] != 0xAA) {
return false;
}
if (packet[1] != 0x02) {
return false;
}
const uint8_t payload_len = packet[2];
if (static_cast<uint16_t>(payload_len) + 4u != length) {
return false;
}
uint16_t sum = 0;
for (uint16_t index = 0; index < static_cast<uint16_t>(3u + payload_len); ++index) {
sum += packet[index];
}
if ((sum & 0xFF) != packet[length - 1]) {
return false;
}
if (payload_len < 8) {
return false;
}
const uint16_t buttons = static_cast<uint16_t>(packet[3]) |
(static_cast<uint16_t>(packet[4]) << 8);
const uint8_t hat = packet[5];
const uint8_t lx = packet[6];
const uint8_t ly = packet[7];
const uint8_t rx = packet[8];
const uint8_t ry = packet[9];
uint8_t imu_count = packet[10];
if (imu_count > 3) {
imu_count = 3;
}
const uint16_t required_payload_len =
static_cast<uint16_t>(8u + static_cast<uint16_t>(imu_count) * 12u);
if (payload_len < required_payload_len) {
return false;
}
const auto expand_axis = [](uint8_t value) -> uint16_t {
return static_cast<uint16_t>(value) << 8 | value;
};
const auto read_int16 = [](const uint8_t* source) -> int16_t {
return static_cast<int16_t>(
static_cast<uint16_t>(source[0]) |
(static_cast<uint16_t>(source[1]) << 8));
};
SwitchInputState state = make_neutral_state();
state.imu_sample_count = imu_count;
for (uint8_t index = 0; index < imu_count; ++index) {
const uint8_t* base = &packet[11 + index * 12];
state.imu_samples[index].accel_x = read_int16(base);
state.imu_samples[index].accel_y = read_int16(base + 2);
state.imu_samples[index].accel_z = read_int16(base + 4);
state.imu_samples[index].gyro_x = read_int16(base + 6);
state.imu_samples[index].gyro_y = read_int16(base + 8);
state.imu_samples[index].gyro_z = read_int16(base + 10);
}
switch (hat) {
case SWITCH_PRO_HAT_UP: state.dpad_up = true; break;
case SWITCH_PRO_HAT_UPRIGHT: state.dpad_up = true; state.dpad_right = true; break;
case SWITCH_PRO_HAT_RIGHT: state.dpad_right = true; break;
case SWITCH_PRO_HAT_DOWNRIGHT: state.dpad_down = true; state.dpad_right = true; break;
case SWITCH_PRO_HAT_DOWN: state.dpad_down = true; break;
case SWITCH_PRO_HAT_DOWNLEFT: state.dpad_down = true; state.dpad_left = true; break;
case SWITCH_PRO_HAT_LEFT: state.dpad_left = true; break;
case SWITCH_PRO_HAT_UPLEFT: state.dpad_up = true; state.dpad_left = true; break;
default: break;
}
state.button_y = buttons & SWITCH_PRO_MASK_Y;
state.button_x = buttons & SWITCH_PRO_MASK_X;
state.button_b = buttons & SWITCH_PRO_MASK_B;
state.button_a = buttons & SWITCH_PRO_MASK_A;
state.button_r = buttons & SWITCH_PRO_MASK_R;
state.button_zr = buttons & SWITCH_PRO_MASK_ZR;
state.button_plus = buttons & SWITCH_PRO_MASK_PLUS;
state.button_minus = buttons & SWITCH_PRO_MASK_MINUS;
state.button_r3 = buttons & SWITCH_PRO_MASK_R3;
state.button_l3 = buttons & SWITCH_PRO_MASK_L3;
state.button_home = buttons & SWITCH_PRO_MASK_HOME;
state.button_capture = buttons & SWITCH_PRO_MASK_CAPTURE;
state.button_zl = buttons & SWITCH_PRO_MASK_ZL;
state.button_l = buttons & SWITCH_PRO_MASK_L;
state.lx = expand_axis(lx);
state.ly = expand_axis(ly);
state.rx = expand_axis(rx);
state.ry = expand_axis(ry);
if (!out_state) {
return false;
}
*out_state = state;
return true;
}

View file

@ -1,10 +0,0 @@
#pragma once
#include <stdint.h>
#include "switch_input.h"
bool switch_uart_decode_input_frame(
const uint8_t* packet,
uint8_t length,
SwitchInputState* out_state);

View file

@ -1,24 +0,0 @@
cmake_minimum_required(VERSION 3.13)
project(switch-pico-firmware-tests LANGUAGES CXX)
enable_testing()
add_executable(switch-pico-firmware-tests
../../switch2_commands.cpp
../../switch2_descriptors.cpp
../../switch2_reports.cpp
../../switch_uart_protocol.cpp
test_main.cpp
test_legacy_descriptors.cpp
test_switch2_commands.cpp
test_switch2_descriptors.cpp
test_switch2_reports.cpp
test_switch_input.cpp
test_switch_uart_protocol.cpp
)
target_compile_features(switch-pico-firmware-tests PRIVATE cxx_std_17)
target_include_directories(switch-pico-firmware-tests PRIVATE ../..)
add_test(NAME switch-pico-firmware-tests COMMAND switch-pico-firmware-tests)

View file

@ -1,173 +0,0 @@
#include "test_support.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include "../../switch_pro_descriptors.h"
#include "../../switch_pro_bounds.h"
namespace {
template <std::size_t ActualSize, std::size_t ExpectedSize>
bool bytes_equal(
const uint8_t (&actual)[ActualSize],
const uint8_t (&expected)[ExpectedSize]) {
return ActualSize == ExpectedSize &&
std::memcmp(actual, expected, ExpectedSize) == 0;
}
bool legacy_device_descriptor_matches_exact_bytes() {
// Given: the captured legacy device descriptor bytes.
static constexpr uint8_t expected[] = {
0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, 0x7E,
0x05, 0x09, 0x20, 0x10, 0x02, 0x01, 0x02, 0x03, 0x01,
};
// When: the compiled legacy descriptor is inspected.
// Then: its identity and all 18 bytes remain unchanged.
CHECK(sizeof(switch_pro_device_descriptor) == 18);
CHECK(bytes_equal(switch_pro_device_descriptor, expected));
return true;
}
bool legacy_configuration_descriptor_matches_exact_bytes() {
// Given: the captured single-interface legacy configuration.
static constexpr uint8_t expected[] = {
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,
};
// When: the compiled legacy configuration is inspected.
// Then: the interface and both interrupt endpoints remain byte-identical.
CHECK(sizeof(switch_pro_configuration_descriptor) == 41);
CHECK(bytes_equal(switch_pro_configuration_descriptor, expected));
return true;
}
bool legacy_hid_report_descriptor_matches_exact_bytes() {
// Given: the complete captured 203-byte legacy HID report descriptor.
static constexpr uint8_t expected[] = {
0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01,
0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02,
0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00,
0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01,
0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75,
0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F,
0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04,
0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0,
};
// When: the compiled legacy HID descriptor is inspected.
// Then: every report item remains byte-identical.
CHECK(sizeof(switch_pro_report_descriptor) == 203);
CHECK(bytes_equal(switch_pro_report_descriptor, expected));
return true;
}
bool legacy_string_descriptors_match_exact_bytes() {
// Given: the legacy language, manufacturer, product, and serial strings.
static constexpr uint8_t language[] = {0x09, 0x04};
static constexpr uint8_t manufacturer[] = "Nintendo Co., Ltd.";
static constexpr uint8_t product[] = "Pro Controller";
static constexpr uint8_t version[] = "000000000001";
// When: the compiled string tables are inspected.
// Then: every string byte and terminator remains unchanged.
CHECK(bytes_equal(switch_pro_string_language, language));
CHECK(bytes_equal(switch_pro_string_manufacturer, manufacturer));
CHECK(bytes_equal(switch_pro_string_product, product));
CHECK(bytes_equal(switch_pro_string_version, version));
return true;
}
bool legacy_output_classifier_rejects_invalid_report_framing() {
const uint8_t report[] = {0x01, 0x00};
CHECK(switch_pro_classify_output_report(nullptr, 2) == SwitchProOutputReportKind::Ignore);
CHECK(switch_pro_classify_output_report(report, 0) == SwitchProOutputReportKind::Ignore);
CHECK(switch_pro_classify_output_report(report, 1) == SwitchProOutputReportKind::Ignore);
CHECK(switch_pro_classify_output_report(report, 65) == SwitchProOutputReportKind::Ignore);
return true;
}
bool legacy_feature_reports_reject_short_payloads_and_accept_bounds() {
const uint8_t report[] = {0x01, 0x00};
CHECK(switch_pro_classify_output_report(report, 15) == SwitchProOutputReportKind::Ignore);
CHECK(switch_pro_classify_output_report(report, 16) == SwitchProOutputReportKind::Feature);
CHECK(switch_pro_classify_output_report(report, 64) == SwitchProOutputReportKind::Feature);
return true;
}
bool legacy_configuration_and_rumble_reports_reject_short_payloads() {
const uint8_t configuration[] = {0x80, 0x00};
const uint8_t rumble[] = {0x10, 0x00};
const uint8_t noop[] = {0x00, 0x00};
CHECK(switch_pro_classify_output_report(configuration, 1) == SwitchProOutputReportKind::Ignore);
CHECK(switch_pro_classify_output_report(configuration, 2) == SwitchProOutputReportKind::Configuration);
CHECK(switch_pro_classify_output_report(rumble, 9) == SwitchProOutputReportKind::Ignore);
CHECK(switch_pro_classify_output_report(rumble, 10) == SwitchProOutputReportKind::Rumble);
CHECK(switch_pro_classify_output_report(noop, 2) == SwitchProOutputReportKind::Noop);
return true;
}
bool legacy_spi_read_rejects_payload_overflow_at_forty_five_bytes() {
CHECK(switch_pro_spi_read_size_fits(0));
CHECK(switch_pro_spi_read_size_fits(44));
CHECK(!switch_pro_spi_read_size_fits(45));
CHECK(!switch_pro_spi_read_size_fits(255));
return true;
}
bool legacy_flash_read_copies_in_range_data_through_exact_end() {
const uint8_t source[] = {1, 2, 3, 4};
uint8_t destination[] = {0xAA, 0xAA, 0xAA, 0xAA};
CHECK(switch_pro_fill_flash_read(destination, 4, source, 4, 0, 4) == 4);
CHECK(destination[0] == 1 && destination[3] == 4);
return true;
}
bool legacy_flash_read_prefills_partial_source_end_with_ff() {
const uint8_t source[] = {1, 2};
uint8_t destination[] = {0xAA, 0xAA, 0xAA, 0xAA};
CHECK(switch_pro_fill_flash_read(destination, 4, source, 2, 0, 4) == 4);
CHECK(destination[0] == 1 && destination[1] == 2);
CHECK(destination[2] == 0xFF && destination[3] == 0xFF);
return true;
}
bool legacy_flash_read_leaves_canaries_on_invalid_range_or_null_source() {
const uint8_t source[] = {1, 2};
uint8_t destination[] = {0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA};
CHECK(switch_pro_fill_flash_read(destination + 1, 4, source, 2, 3, 4) == 4);
CHECK(destination[0] == 0xAA && destination[5] == 0xAA);
CHECK(destination[1] == 0xFF && destination[4] == 0xFF);
CHECK(switch_pro_fill_flash_read(destination + 1, 4, nullptr, 2, 0, 4) == 4);
CHECK(destination[0] == 0xAA && destination[5] == 0xAA);
CHECK(destination[1] == 0xFF && destination[4] == 0xFF);
CHECK(switch_pro_fill_flash_read(nullptr, 4, source, 2, 0, 4) == 0);
return true;
}
} // namespace
void run_legacy_descriptor_tests(TestRunner& runner) {
runner.run("legacy device descriptor exact bytes", legacy_device_descriptor_matches_exact_bytes);
runner.run("legacy configuration descriptor exact bytes", legacy_configuration_descriptor_matches_exact_bytes);
runner.run("legacy HID report descriptor exact bytes", legacy_hid_report_descriptor_matches_exact_bytes);
runner.run("legacy string descriptors exact bytes", legacy_string_descriptors_match_exact_bytes);
runner.run("legacy output classifier rejects invalid report framing", legacy_output_classifier_rejects_invalid_report_framing);
runner.run("legacy feature reports reject short payloads and accept bounds", legacy_feature_reports_reject_short_payloads_and_accept_bounds);
runner.run("legacy configuration and rumble reports reject short payloads", legacy_configuration_and_rumble_reports_reject_short_payloads);
runner.run("legacy SPI read rejects payload overflow at 45 bytes", legacy_spi_read_rejects_payload_overflow_at_forty_five_bytes);
runner.run("legacy flash read copies in-range data through exact end", legacy_flash_read_copies_in_range_data_through_exact_end);
runner.run("legacy flash read prefills partial source end with FF", legacy_flash_read_prefills_partial_source_end_with_ff);
runner.run("legacy flash read leaves canaries on invalid range or null source", legacy_flash_read_leaves_canaries_on_invalid_range_or_null_source);
}

View file

@ -1,12 +0,0 @@
#include "test_support.h"
int main() {
TestRunner runner;
run_legacy_descriptor_tests(runner);
run_switch2_command_tests(runner);
run_switch2_descriptor_tests(runner);
run_switch2_report_tests(runner);
run_switch_input_tests(runner);
run_switch_uart_protocol_tests(runner);
return runner.result();
}

View file

@ -1,38 +0,0 @@
#pragma once
#include <cstdio>
class TestRunner {
public:
void run(const char* name, bool (*test)()) {
if (test()) {
std::printf("PASS %s\n", name);
return;
}
++failures_;
std::printf("FAIL %s\n", name);
}
int result() const {
return failures_ == 0 ? 0 : 1;
}
private:
int failures_ = 0;
};
#define CHECK(condition) \
do { \
if (!(condition)) { \
std::fprintf(stderr, " %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (false)
void run_legacy_descriptor_tests(TestRunner& runner);
void run_switch2_command_tests(TestRunner& runner);
void run_switch2_descriptor_tests(TestRunner& runner);
void run_switch2_report_tests(TestRunner& runner);
void run_switch_input_tests(TestRunner& runner);
void run_switch_uart_protocol_tests(TestRunner& runner);

View file

@ -1,185 +0,0 @@
#include "test_support.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include "../../switch2_commands.h"
namespace {
bool switch2_classifies_captured_report_selection_requests() {
// Given: captured common and Pro report-selection request vectors.
static constexpr uint8_t select_05[] = {
0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00,
0x05, 0x00, 0x00, 0x00,
};
static constexpr uint8_t select_09[] = {
0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00,
0x09, 0x00, 0x00, 0x00,
};
// When/Then: each captured vector selects only its represented report.
CHECK(switch2_classify_vendor_request(select_05, sizeof(select_05)) ==
Switch2VendorCommand::SelectReport05);
CHECK(switch2_classify_vendor_request(select_09, sizeof(select_09)) ==
Switch2VendorCommand::SelectReport09);
return true;
}
bool switch2_builds_exact_report_selection_ack() {
// Given: the two supported report-selection classifications.
static constexpr uint8_t expected[] = {
0x03, 0x01, 0x00, 0x0A, 0x00, 0xF8, 0x00, 0x00,
};
std::array<uint8_t, 12> output{};
// When: either response is built. Then: both equal the captured ACK.
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::SelectReport05, output.data(), output.size()) ==
sizeof(expected));
CHECK(std::memcmp(output.data(), expected, sizeof(expected)) == 0);
output.fill(0);
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::SelectReport09, output.data(), output.size()) ==
sizeof(expected));
CHECK(std::memcmp(output.data(), expected, sizeof(expected)) == 0);
return true;
}
bool switch2_classifies_captured_and_opaque_usb_init_requests() {
// Given: the pinned vector and another opaque host-address payload.
static constexpr uint8_t captured[] = {
0x03, 0x91, 0x00, 0x0D, 0x00, 0x08, 0x00, 0x00,
0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static constexpr uint8_t opaque_address[] = {
0x03, 0x91, 0x00, 0x0D, 0x00, 0x08, 0x00, 0x00,
0x01, 0xA5, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60,
};
// When/Then: both structurally valid requests initialize USB.
CHECK(switch2_classify_vendor_request(captured, sizeof(captured)) ==
Switch2VendorCommand::InitializeUsb);
CHECK(switch2_classify_vendor_request(opaque_address, sizeof(opaque_address)) ==
Switch2VendorCommand::InitializeUsb);
return true;
}
bool switch2_builds_exact_usb_init_ack() {
// Given: the captured initialization acknowledgement.
static constexpr uint8_t expected[] = {
0x03, 0x01, 0x00, 0x0D, 0x00, 0xF8, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00,
};
std::array<uint8_t, sizeof(expected)> output{};
// When: the initialization response is built. Then: every byte is exact.
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::InitializeUsb, output.data(), output.size()) ==
sizeof(expected));
CHECK(std::memcmp(output.data(), expected, sizeof(expected)) == 0);
return true;
}
bool switch2_rejects_malformed_header_and_length_fields() {
// Given: a structurally valid selection request as the mutation baseline.
static constexpr uint8_t valid[] = {
0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00,
0x05, 0x00, 0x00, 0x00,
};
// When/Then: truncation, extension, and every fixed header violation reject.
CHECK(switch2_classify_vendor_request(nullptr, 0) == Switch2VendorCommand::Unsupported);
for (std::size_t length = 0; length < sizeof(valid); ++length) {
CHECK(switch2_classify_vendor_request(valid, length) ==
Switch2VendorCommand::Unsupported);
}
std::array<uint8_t, sizeof(valid) + 1> extended{};
std::memcpy(extended.data(), valid, sizeof(valid));
CHECK(switch2_classify_vendor_request(extended.data(), extended.size()) ==
Switch2VendorCommand::Unsupported);
for (uint8_t offset : {0, 1, 2, 4, 6, 7}) {
std::array<uint8_t, sizeof(valid)> malformed{};
std::memcpy(malformed.data(), valid, sizeof(valid));
++malformed[offset];
CHECK(switch2_classify_vendor_request(malformed.data(), malformed.size()) ==
Switch2VendorCommand::Unsupported);
}
for (uint8_t declared_length : {0x03, 0x05}) {
std::array<uint8_t, sizeof(valid)> malformed{};
std::memcpy(malformed.data(), valid, sizeof(valid));
malformed[5] = declared_length;
CHECK(switch2_classify_vendor_request(malformed.data(), malformed.size()) ==
Switch2VendorCommand::Unsupported);
}
return true;
}
bool switch2_rejects_unsupported_commands_and_payloads() {
// Given: valid framing mutated to unsupported subcommands and payloads.
std::array<uint8_t, 12> request = {
0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00,
0x05, 0x00, 0x00, 0x00,
};
// When/Then: no command outside the captured subset is classified.
request[3] = 0x03;
CHECK(switch2_classify_vendor_request(request.data(), request.size()) ==
Switch2VendorCommand::Unsupported);
request[3] = 0x0A;
request[8] = 0x08;
CHECK(switch2_classify_vendor_request(request.data(), request.size()) ==
Switch2VendorCommand::Unsupported);
request[8] = 0x05;
request[9] = 0x01;
CHECK(switch2_classify_vendor_request(request.data(), request.size()) ==
Switch2VendorCommand::Unsupported);
std::array<uint8_t, 16> init = {
0x03, 0x91, 0x00, 0x0D, 0x00, 0x08, 0x00, 0x00,
0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
};
CHECK(switch2_classify_vendor_request(init.data(), init.size()) ==
Switch2VendorCommand::Unsupported);
return true;
}
bool switch2_response_builder_rejects_unsupported_or_small_outputs() {
// Given: a sentinel output buffer and every unsupported capacity.
std::array<uint8_t, 12> output{};
// When/Then: unsupported, null, and short outputs produce no bytes or writes.
output.fill(0xA5);
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::Unsupported, output.data(), output.size()) == 0);
for (uint8_t byte : output) CHECK(byte == 0xA5);
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::InitializeUsb, nullptr, output.size()) == 0);
for (std::size_t capacity = 0; capacity < 8; ++capacity) {
output.fill(0xA5);
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::SelectReport05, output.data(), capacity) == 0);
for (uint8_t byte : output) CHECK(byte == 0xA5);
}
for (std::size_t capacity = 0; capacity < 12; ++capacity) {
output.fill(0xA5);
CHECK(switch2_build_vendor_response(
Switch2VendorCommand::InitializeUsb, output.data(), capacity) == 0);
for (uint8_t byte : output) CHECK(byte == 0xA5);
}
return true;
}
} // namespace
void run_switch2_command_tests(TestRunner& runner) {
runner.run("Switch 2 captured report selections", switch2_classifies_captured_report_selection_requests);
runner.run("Switch 2 report selection ACK", switch2_builds_exact_report_selection_ack);
runner.run("Switch 2 captured USB initialization", switch2_classifies_captured_and_opaque_usb_init_requests);
runner.run("Switch 2 USB initialization ACK", switch2_builds_exact_usb_init_ack);
runner.run("Switch 2 malformed command framing", switch2_rejects_malformed_header_and_length_fields);
runner.run("Switch 2 unsupported commands", switch2_rejects_unsupported_commands_and_payloads);
runner.run("Switch 2 response capacity", switch2_response_builder_rejects_unsupported_or_small_outputs);
}

View file

@ -1,166 +0,0 @@
#include "test_support.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include "../../switch2_descriptors.h"
namespace {
bool bytes_equal(
const uint8_t* actual,
std::size_t actual_length,
const uint8_t* expected,
std::size_t expected_length) {
return actual_length == expected_length &&
std::memcmp(actual, expected, expected_length) == 0;
}
bool switch2_descriptor_bytes_match_pinned_capture_subset() {
// Given: captured Pro Controller 2 bytes with the documented subset edits.
static constexpr uint8_t expected_device[] = {
0x12, 0x01, 0x00, 0x02, 0xEF, 0x02, 0x01, 0x40, 0x7E,
0x05, 0x69, 0x20, 0x00, 0x02, 0x01, 0x02, 0x03, 0x01,
};
static constexpr uint8_t expected_configuration[] = {
0x09, 0x02, 0x50, 0x00, 0x02, 0x01, 0x00, 0xC0, 0xFA,
0x08, 0x0B, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00,
0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00,
0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x61, 0x00,
0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x04,
0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x04,
0x08, 0x0B, 0x01, 0x01, 0xFF, 0x00, 0x00, 0x00,
0x09, 0x04, 0x01, 0x00, 0x02, 0xFF, 0x00, 0x00, 0x00,
0x07, 0x05, 0x02, 0x02, 0x40, 0x00, 0x00,
0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00,
};
static constexpr uint8_t expected_hid[] = {
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x05, 0x05, 0xFF, 0x09, 0x01, 0x15, 0x00, 0x26, 0xFF,
0x00, 0x95, 0x3F, 0x75, 0x08, 0x81, 0x02, 0x85, 0x09, 0x09, 0x01, 0x95, 0x02, 0x81, 0x02, 0x05,
0x09, 0x19, 0x01, 0x29, 0x15, 0x25, 0x01, 0x95, 0x15, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75,
0x03, 0x81, 0x03, 0x05, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x09, 0x33, 0x09,
0x35, 0x26, 0xFF, 0x0F, 0x95, 0x04, 0x75, 0x0C, 0x81, 0x02, 0xC0, 0x05, 0xFF, 0x09, 0x02, 0x26,
0xFF, 0x00, 0x95, 0x34, 0x75, 0x08, 0x81, 0x02, 0x85, 0x02, 0x09, 0x01, 0x95, 0x3F, 0x91, 0x02,
0xC0,
};
// When/Then: all exported descriptor bytes and lengths match exactly.
CHECK(bytes_equal(switch2_device_descriptor, switch2_device_descriptor_length,
expected_device, sizeof(expected_device)));
CHECK(bytes_equal(switch2_configuration_descriptor, switch2_configuration_descriptor_length,
expected_configuration, sizeof(expected_configuration)));
CHECK(bytes_equal(switch2_hid_report_descriptor, switch2_hid_report_descriptor_length,
expected_hid, sizeof(expected_hid)));
return true;
}
bool switch2_configuration_iterates_two_interfaces_and_four_endpoints() {
// Given: the deliberately reduced configuration descriptor.
std::size_t offset = 0;
uint8_t interface_count = 0;
uint8_t endpoint_count = 0;
const uint8_t expected_endpoints[][5] = {
{0x81, 0x03, 0x40, 0x00, 0x04},
{0x01, 0x03, 0x40, 0x00, 0x04},
{0x02, 0x02, 0x40, 0x00, 0x00},
{0x82, 0x02, 0x40, 0x00, 0x00},
};
// When: every USB descriptor is iterated by bLength.
while (offset < switch2_configuration_descriptor_length) {
const uint8_t length = switch2_configuration_descriptor[offset];
CHECK(length >= 2);
CHECK(offset + length <= switch2_configuration_descriptor_length);
const uint8_t type = switch2_configuration_descriptor[offset + 1];
if (type == 0x04) {
CHECK(length == 9);
CHECK(switch2_configuration_descriptor[offset + 5] != 0x01);
CHECK(switch2_configuration_descriptor[offset + 8] == 0x00);
++interface_count;
} else if (type == 0x05) {
CHECK(length == 7);
CHECK(endpoint_count < 4);
CHECK(std::memcmp(&switch2_configuration_descriptor[offset + 2],
expected_endpoints[endpoint_count], 5) == 0);
++endpoint_count;
}
offset += length;
}
// Then: iteration is exact, contains only HID/vendor, and omits audio.
CHECK(offset == switch2_configuration_descriptor_length);
CHECK(switch2_configuration_descriptor[6] == 0x00);
CHECK(interface_count == 2);
CHECK(endpoint_count == 4);
return true;
}
bool switch2_hid_reports_are_exactly_sixty_three_payload_bytes() {
// Given: the captured HID report descriptor.
uint32_t report_size = 0;
uint32_t report_count = 0;
uint32_t report_id = 0;
uint32_t input_05_bits = 0;
uint32_t input_09_bits = 0;
uint32_t output_02_bits = 0;
// When: HID short items are parsed and report fields accumulated.
for (std::size_t offset = 0; offset < switch2_hid_report_descriptor_length;) {
const uint8_t prefix = switch2_hid_report_descriptor[offset++];
CHECK(prefix != 0xFE);
const uint8_t size_code = prefix & 0x03;
const uint8_t data_size = size_code == 3 ? 4 : size_code;
CHECK(offset + data_size <= switch2_hid_report_descriptor_length);
uint32_t value = 0;
for (uint8_t index = 0; index < data_size; ++index) {
value |= static_cast<uint32_t>(switch2_hid_report_descriptor[offset + index]) << (8 * index);
}
offset += data_size;
const uint8_t type = (prefix >> 2) & 0x03;
const uint8_t tag = (prefix >> 4) & 0x0F;
if (type == 1 && tag == 7) report_size = value;
if (type == 1 && tag == 8) report_id = value;
if (type == 1 && tag == 9) report_count = value;
if (type == 0 && tag == 8 && report_id == 0x05) input_05_bits += report_size * report_count;
if (type == 0 && tag == 8 && report_id == 0x09) input_09_bits += report_size * report_count;
if (type == 0 && tag == 9 && report_id == 0x02) output_02_bits += report_size * report_count;
}
// Then: each supported transfer is 63 payload bytes plus its report ID.
CHECK(input_05_bits == 63 * 8);
CHECK(input_09_bits == 63 * 8);
CHECK(output_02_bits == 63 * 8);
return true;
}
bool switch2_string_bytes_match_pinned_capture() {
// Given/When: the known language and ASCII strings are inspected.
static constexpr uint8_t language[] = {0x09, 0x04};
static constexpr uint8_t manufacturer[] = "Nintendo";
static constexpr uint8_t product[] = "Switch 2 Pro Controller";
static constexpr uint8_t serial[] = "00";
// Then: exported lengths exclude the C terminator and bytes remain exact.
CHECK(bytes_equal(switch2_string_language, switch2_string_language_length,
language, sizeof(language)));
CHECK(bytes_equal(switch2_string_manufacturer, switch2_string_manufacturer_length,
manufacturer, sizeof(manufacturer) - 1));
CHECK(bytes_equal(switch2_string_product, switch2_string_product_length,
product, sizeof(product) - 1));
CHECK(bytes_equal(switch2_string_serial, switch2_string_serial_length,
serial, sizeof(serial) - 1));
CHECK(switch2_string_manufacturer[switch2_string_manufacturer_length] == 0);
CHECK(switch2_string_product[switch2_string_product_length] == 0);
CHECK(switch2_string_serial[switch2_string_serial_length] == 0);
return true;
}
} // namespace
void run_switch2_descriptor_tests(TestRunner& runner) {
runner.run("Switch 2 descriptor exact bytes", switch2_descriptor_bytes_match_pinned_capture_subset);
runner.run("Switch 2 configuration iteration", switch2_configuration_iterates_two_interfaces_and_four_endpoints);
runner.run("Switch 2 HID report sizes", switch2_hid_reports_are_exactly_sixty_three_payload_bytes);
runner.run("Switch 2 string bytes", switch2_string_bytes_match_pinned_capture);
}

View file

@ -1,186 +0,0 @@
#include "test_support.h"
#include <cstddef>
#include <cstdint>
#include "../../switch2_reports.h"
namespace {
SwitchInputState neutral_state() {
SwitchInputState state{};
state.lx = SWITCH_PRO_JOYSTICK_MID;
state.ly = SWITCH_PRO_JOYSTICK_MID;
state.rx = SWITCH_PRO_JOYSTICK_MID;
state.ry = SWITCH_PRO_JOYSTICK_MID;
return state;
}
bool bytes_are_zero(
const std::array<uint8_t, 63>& payload,
std::size_t begin,
std::size_t end) {
for (std::size_t index = begin; index < end; ++index) {
if (payload[index] != 0) return false;
}
return true;
}
bool switch2_neutral_reports_pack_documented_constants() {
// Given: neutral normalized input and distinct counters.
const SwitchInputState state = neutral_state();
// When: common and Pro reports are built.
const Switch2InputReport common = switch2_build_input_report(
Switch2InputReportId::Common, state, 0x78563412);
const Switch2InputReport pro = switch2_build_input_report(
Switch2InputReportId::Pro, state, 0x1234);
// Then: IDs, counters, neutral sticks, and documented constants are exact.
CHECK(static_cast<uint8_t>(common.id) == 0x05);
CHECK(static_cast<uint8_t>(pro.id) == 0x09);
CHECK(common.payload.size() + 1 == 64 && pro.payload.size() + 1 == 64);
CHECK(common.payload[0] == 0x12 && common.payload[1] == 0x34);
CHECK(common.payload[2] == 0x56 && common.payload[3] == 0x78);
CHECK(common.payload[10] == 0xFF && common.payload[11] == 0xF7 && common.payload[12] == 0x7F);
CHECK(common.payload[13] == 0xFF && common.payload[14] == 0xF7 && common.payload[15] == 0x7F);
CHECK(common.payload[0x29] == 0x01);
CHECK(pro.payload[0] == 0x34);
CHECK(pro.payload[1] == 0x01);
CHECK(pro.payload[5] == 0xFF && pro.payload[6] == 0xF7 && pro.payload[7] == 0x7F);
CHECK(pro.payload[8] == 0xFF && pro.payload[9] == 0xF7 && pro.payload[10] == 0x7F);
CHECK(pro.payload[11] == 0x30);
return true;
}
bool switch2_report_counters_roll_over_deterministically() {
// Given: counters at and beyond the Pro report's eight-bit boundary.
const SwitchInputState state = neutral_state();
// When: reports are built at 255 and 256.
const Switch2InputReport common = switch2_build_input_report(
Switch2InputReportId::Common, state, 0xFFFFFFFF);
const Switch2InputReport pro_255 = switch2_build_input_report(
Switch2InputReportId::Pro, state, 255);
const Switch2InputReport pro_256 = switch2_build_input_report(
Switch2InputReportId::Pro, state, 256);
// Then: common remains LE32 while Pro uses the low eight bits.
CHECK(common.payload[0] == 0xFF && common.payload[1] == 0xFF);
CHECK(common.payload[2] == 0xFF && common.payload[3] == 0xFF);
CHECK(pro_255.payload[0] == 0xFF);
CHECK(pro_256.payload[0] == 0x00);
return true;
}
bool switch2_reports_map_every_shared_button() {
struct ButtonCase {
bool SwitchInputState::*field;
uint8_t common_offset;
uint8_t common_mask;
uint8_t pro_offset;
uint8_t pro_mask;
};
static constexpr ButtonCase cases[] = {
{&SwitchInputState::button_y, 4, 0x01, 2, 0x04},
{&SwitchInputState::button_b, 4, 0x04, 2, 0x01},
{&SwitchInputState::button_a, 4, 0x08, 2, 0x02},
{&SwitchInputState::button_x, 4, 0x02, 2, 0x08},
{&SwitchInputState::button_r, 4, 0x40, 2, 0x10},
{&SwitchInputState::button_zr, 4, 0x80, 2, 0x20},
{&SwitchInputState::button_minus, 5, 0x01, 3, 0x40},
{&SwitchInputState::button_plus, 5, 0x02, 2, 0x40},
{&SwitchInputState::button_r3, 5, 0x04, 2, 0x80},
{&SwitchInputState::button_l3, 5, 0x08, 3, 0x80},
{&SwitchInputState::button_home, 5, 0x10, 4, 0x01},
{&SwitchInputState::button_capture, 5, 0x20, 4, 0x02},
{&SwitchInputState::dpad_down, 6, 0x01, 3, 0x01},
{&SwitchInputState::dpad_up, 6, 0x02, 3, 0x08},
{&SwitchInputState::dpad_right, 6, 0x04, 3, 0x02},
{&SwitchInputState::dpad_left, 6, 0x08, 3, 0x04},
{&SwitchInputState::button_l, 6, 0x40, 3, 0x10},
{&SwitchInputState::button_zl, 6, 0x80, 3, 0x20},
};
// Given/When: every shared button is built independently in both reports.
for (const ButtonCase& button : cases) {
SwitchInputState state = neutral_state();
state.*(button.field) = true;
const Switch2InputReport common = switch2_build_input_report(
Switch2InputReportId::Common, state, 0);
const Switch2InputReport pro = switch2_build_input_report(
Switch2InputReportId::Pro, state, 0);
// Then: only the captured byte and bit for that button is set.
for (uint8_t offset = 4; offset <= 7; ++offset) {
CHECK(common.payload[offset] ==
(offset == button.common_offset ? button.common_mask : 0));
}
for (uint8_t offset = 2; offset <= 4; ++offset) {
CHECK(pro.payload[offset] ==
(offset == button.pro_offset ? button.pro_mask : 0));
}
}
return true;
}
bool switch2_reports_pack_twelve_bit_stick_extremes() {
// Given: four distinct normalized axis values.
SwitchInputState state{};
state.lx = 0x0000;
state.ly = 0xFFFF;
state.rx = 0x1234;
state.ry = 0xABCD;
// When: both report formats are built.
const Switch2InputReport common = switch2_build_input_report(
Switch2InputReportId::Common, state, 0);
const Switch2InputReport pro = switch2_build_input_report(
Switch2InputReportId::Pro, state, 0);
// Then: axes are reduced and packed in captured 12-bit little-endian form.
const uint8_t expected_left[] = {0x00, 0xF0, 0xFF};
const uint8_t expected_right[] = {0x23, 0xC1, 0xAB};
for (uint8_t index = 0; index < 3; ++index) {
CHECK(common.payload[10 + index] == expected_left[index]);
CHECK(common.payload[13 + index] == expected_right[index]);
CHECK(pro.payload[5 + index] == expected_left[index]);
CHECK(pro.payload[8 + index] == expected_right[index]);
}
return true;
}
bool switch2_reports_zero_unknowns_and_ignore_imu() {
// Given: populated IMU input that has no known Switch 2 packing.
SwitchInputState state = neutral_state();
state.imu_sample_count = 3;
state.imu_samples[0] = {1, 2, 3, 4, 5, 6};
state.imu_samples[1] = {7, 8, 9, 10, 11, 12};
state.imu_samples[2] = {13, 14, 15, 16, 17, 18};
// When: both reports are built twice from identical input.
const Switch2InputReport common = switch2_build_input_report(
Switch2InputReportId::Common, state, 7);
const Switch2InputReport common_again = switch2_build_input_report(
Switch2InputReportId::Common, state, 7);
const Switch2InputReport pro = switch2_build_input_report(
Switch2InputReportId::Pro, state, 7);
// Then: unknown power/sensor/motion fields stay deterministic and zero.
CHECK(common.payload == common_again.payload);
CHECK(bytes_are_zero(common.payload, 8, 10));
CHECK(bytes_are_zero(common.payload, 16, 0x29));
CHECK(bytes_are_zero(common.payload, 0x2A, 63));
CHECK(bytes_are_zero(pro.payload, 12, 63));
return true;
}
} // namespace
void run_switch2_report_tests(TestRunner& runner) {
runner.run("Switch 2 neutral report constants", switch2_neutral_reports_pack_documented_constants);
runner.run("Switch 2 report counter rollover", switch2_report_counters_roll_over_deterministically);
runner.run("Switch 2 report button mapping", switch2_reports_map_every_shared_button);
runner.run("Switch 2 report stick packing", switch2_reports_pack_twelve_bit_stick_extremes);
runner.run("Switch 2 report unknown fields", switch2_reports_zero_unknowns_and_ignore_imu);
}

View file

@ -1,52 +0,0 @@
#include "test_support.h"
#include <cstddef>
#include "../../switch_input.h"
namespace {
bool switch_input_constants_match_legacy_values() {
// Given/When: the shared input constants are compiled after extraction.
// Then: every UART-visible legacy value remains unchanged.
CHECK(SWITCH_PRO_HAT_UP == 0x00 && SWITCH_PRO_HAT_UPRIGHT == 0x01);
CHECK(SWITCH_PRO_HAT_RIGHT == 0x02 && SWITCH_PRO_HAT_DOWNRIGHT == 0x03);
CHECK(SWITCH_PRO_HAT_DOWN == 0x04 && SWITCH_PRO_HAT_DOWNLEFT == 0x05);
CHECK(SWITCH_PRO_HAT_LEFT == 0x06 && SWITCH_PRO_HAT_UPLEFT == 0x07);
CHECK(SWITCH_PRO_HAT_NOTHING == 0x08);
CHECK(SWITCH_PRO_MASK_Y == (1U << 0) && SWITCH_PRO_MASK_B == (1U << 1));
CHECK(SWITCH_PRO_MASK_A == (1U << 2) && SWITCH_PRO_MASK_X == (1U << 3));
CHECK(SWITCH_PRO_MASK_L == (1U << 4) && SWITCH_PRO_MASK_R == (1U << 5));
CHECK(SWITCH_PRO_MASK_ZL == (1U << 6) && SWITCH_PRO_MASK_ZR == (1U << 7));
CHECK(SWITCH_PRO_MASK_MINUS == (1U << 8) && SWITCH_PRO_MASK_PLUS == (1U << 9));
CHECK(SWITCH_PRO_MASK_L3 == (1U << 10) && SWITCH_PRO_MASK_R3 == (1U << 11));
CHECK(SWITCH_PRO_MASK_HOME == (1U << 12) && SWITCH_PRO_MASK_CAPTURE == (1U << 13));
CHECK(SWITCH_PRO_JOYSTICK_MIN == 0x0000);
CHECK(SWITCH_PRO_JOYSTICK_MID == 0x7FFF);
CHECK(SWITCH_PRO_JOYSTICK_MAX == 0xFFFF);
return true;
}
bool switch_input_layout_matches_legacy_layout() {
// Given/When: the shared input types are compiled after extraction.
// Then: field offsets and aggregate sizes match the former driver-owned layout.
CHECK(sizeof(SwitchImuSample) == 12);
CHECK(offsetof(SwitchImuSample, accel_x) == 0);
CHECK(offsetof(SwitchImuSample, gyro_z) == 10);
CHECK(offsetof(SwitchInputState, dpad_up) == 0);
CHECK(offsetof(SwitchInputState, button_a) == 4);
CHECK(offsetof(SwitchInputState, button_r3) == 17);
CHECK(offsetof(SwitchInputState, lx) == 18);
CHECK(offsetof(SwitchInputState, ry) == 24);
CHECK(offsetof(SwitchInputState, imu_sample_count) == 26);
CHECK(offsetof(SwitchInputState, imu_samples) == 28);
CHECK(sizeof(SwitchInputState) == 64);
return true;
}
} // namespace
void run_switch_input_tests(TestRunner& runner) {
runner.run("shared input constants preserve legacy values", switch_input_constants_match_legacy_values);
runner.run("shared input types preserve legacy layout", switch_input_layout_matches_legacy_layout);
}

View file

@ -1,261 +0,0 @@
#include "test_support.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>
#include "../../switch_input.h"
#include "../../switch_uart_protocol.h"
namespace {
using ImuSamples = std::vector<SwitchImuSample>;
void append_int16(std::vector<uint8_t>& bytes, int16_t value) {
const uint16_t encoded = static_cast<uint16_t>(value);
bytes.push_back(static_cast<uint8_t>(encoded & 0xFF));
bytes.push_back(static_cast<uint8_t>(encoded >> 8));
}
std::vector<uint8_t> make_frame(
uint16_t buttons = 0,
uint8_t hat = SWITCH_PRO_HAT_NOTHING,
uint8_t imu_count = 0,
const ImuSamples& samples = {}) {
std::vector<uint8_t> frame = {
0xAA, 0x02, 0x00,
static_cast<uint8_t>(buttons & 0xFF),
static_cast<uint8_t>(buttons >> 8),
hat, 0x80, 0x80, 0x80, 0x80, imu_count,
};
for (const SwitchImuSample& sample : samples) {
append_int16(frame, sample.accel_x);
append_int16(frame, sample.accel_y);
append_int16(frame, sample.accel_z);
append_int16(frame, sample.gyro_x);
append_int16(frame, sample.gyro_y);
append_int16(frame, sample.gyro_z);
}
frame[2] = static_cast<uint8_t>(frame.size() - 3);
uint8_t checksum = 0;
for (uint8_t byte : frame) {
checksum = static_cast<uint8_t>(checksum + byte);
}
frame.push_back(checksum);
return frame;
}
bool decode(const std::vector<uint8_t>& frame, SwitchInputState& state) {
return switch_uart_decode_input_frame(
frame.data(), static_cast<uint8_t>(frame.size()), &state);
}
bool uart_decoder_rejects_short_frame() {
// Given: a frame shorter than the legacy 12-byte minimum.
const std::array<uint8_t, 11> frame{};
SwitchInputState state{};
// When: the frame is decoded. Then: it is rejected.
CHECK(!switch_uart_decode_input_frame(frame.data(), frame.size(), &state));
return true;
}
bool uart_decoder_rejects_wrong_header_and_version() {
// Given: otherwise-valid frames with invalid framing bytes.
std::vector<uint8_t> wrong_header = make_frame();
std::vector<uint8_t> wrong_version = make_frame();
wrong_header[0] = 0xAB;
wrong_version[1] = 0x01;
SwitchInputState state{};
// When: either frame is decoded. Then: both are rejected before payload use.
CHECK(!decode(wrong_header, state));
CHECK(!decode(wrong_version, state));
return true;
}
bool uart_decoder_rejects_declared_length_and_checksum_mismatch() {
// Given: valid frames corrupted independently at length and checksum.
std::vector<uint8_t> wrong_length = make_frame();
std::vector<uint8_t> wrong_checksum = make_frame();
++wrong_length[2];
++wrong_checksum.back();
SwitchInputState state{};
state.lx = 0x1234;
// When: either frame is decoded. Then: both validation failures are rejected.
CHECK(!decode(wrong_length, state));
CHECK(!decode(wrong_checksum, state));
CHECK(state.lx == 0x1234);
return true;
}
bool uart_decoder_decodes_neutral_frame() {
// Given: the canonical 12-byte neutral frame.
const std::vector<uint8_t> frame = make_frame();
SwitchInputState state{};
// When: the frame is decoded. Then: buttons/hat/IMU are clear and sticks expand exactly.
CHECK(decode(frame, state));
CHECK(!state.dpad_up && !state.dpad_down && !state.dpad_left && !state.dpad_right);
CHECK(!state.button_a && !state.button_b && !state.button_x && !state.button_y);
CHECK(!state.button_l && !state.button_r && !state.button_zl && !state.button_zr);
CHECK(!state.button_plus && !state.button_minus && !state.button_home && !state.button_capture);
CHECK(!state.button_l3 && !state.button_r3);
CHECK(state.lx == 0x8080 && state.ly == 0x8080);
CHECK(state.rx == 0x8080 && state.ry == 0x8080);
CHECK(state.imu_sample_count == 0);
return true;
}
bool uart_decoder_maps_every_button_bit() {
struct ButtonCase {
uint16_t mask;
bool SwitchInputState::*field;
};
static constexpr ButtonCase cases[] = {
{SWITCH_PRO_MASK_Y, &SwitchInputState::button_y},
{SWITCH_PRO_MASK_B, &SwitchInputState::button_b},
{SWITCH_PRO_MASK_A, &SwitchInputState::button_a},
{SWITCH_PRO_MASK_X, &SwitchInputState::button_x},
{SWITCH_PRO_MASK_L, &SwitchInputState::button_l},
{SWITCH_PRO_MASK_R, &SwitchInputState::button_r},
{SWITCH_PRO_MASK_ZL, &SwitchInputState::button_zl},
{SWITCH_PRO_MASK_ZR, &SwitchInputState::button_zr},
{SWITCH_PRO_MASK_MINUS, &SwitchInputState::button_minus},
{SWITCH_PRO_MASK_PLUS, &SwitchInputState::button_plus},
{SWITCH_PRO_MASK_L3, &SwitchInputState::button_l3},
{SWITCH_PRO_MASK_R3, &SwitchInputState::button_r3},
{SWITCH_PRO_MASK_HOME, &SwitchInputState::button_home},
{SWITCH_PRO_MASK_CAPTURE, &SwitchInputState::button_capture},
};
// Given/When: each legacy button bit is decoded independently.
for (const ButtonCase& button : cases) {
SwitchInputState state{};
CHECK(decode(make_frame(button.mask), state));
// Then: the corresponding shared input field is set.
CHECK(state.*(button.field));
const int pressed_count =
state.button_y + state.button_b + state.button_a + state.button_x +
state.button_l + state.button_r + state.button_zl + state.button_zr +
state.button_minus + state.button_plus + state.button_l3 + state.button_r3 +
state.button_home + state.button_capture;
CHECK(pressed_count == 1);
}
return true;
}
bool uart_decoder_maps_every_hat_value() {
struct HatCase {
uint8_t hat;
bool up;
bool down;
bool left;
bool right;
};
static constexpr HatCase cases[] = {
{SWITCH_PRO_HAT_UP, true, false, false, false},
{SWITCH_PRO_HAT_UPRIGHT, true, false, false, true},
{SWITCH_PRO_HAT_RIGHT, false, false, false, true},
{SWITCH_PRO_HAT_DOWNRIGHT, false, true, false, true},
{SWITCH_PRO_HAT_DOWN, false, true, false, false},
{SWITCH_PRO_HAT_DOWNLEFT, false, true, true, false},
{SWITCH_PRO_HAT_LEFT, false, false, true, false},
{SWITCH_PRO_HAT_UPLEFT, true, false, true, false},
{SWITCH_PRO_HAT_NOTHING, false, false, false, false},
{0xFF, false, false, false, false},
};
// Given/When: every legacy hat value is decoded.
for (const HatCase& hat : cases) {
SwitchInputState state{};
CHECK(decode(make_frame(0, hat.hat), state));
// Then: its exact cardinal/diagonal field combination is produced.
CHECK(state.dpad_up == hat.up && state.dpad_down == hat.down);
CHECK(state.dpad_left == hat.left && state.dpad_right == hat.right);
}
return true;
}
bool uart_decoder_expands_stick_bytes() {
// Given: a valid frame with distinct byte values on every axis.
std::vector<uint8_t> frame = make_frame();
frame[6] = 0x00;
frame[7] = 0x7F;
frame[8] = 0x80;
frame[9] = 0xFF;
frame.back() = 0;
for (std::size_t index = 0; index + 1 < frame.size(); ++index) {
frame.back() = static_cast<uint8_t>(frame.back() + frame[index]);
}
SwitchInputState state{};
// When: the frame is decoded. Then: each byte is duplicated into 16 bits.
CHECK(decode(frame, state));
CHECK(state.lx == 0x0000 && state.ly == 0x7F7F);
CHECK(state.rx == 0x8080 && state.ry == 0xFFFF);
return true;
}
bool uart_decoder_decodes_one_and_three_imu_samples() {
// Given: one-sample and three-sample frames with signed extrema and distinct values.
const SwitchImuSample first{-32768, -2, -1, 0, 1, 32767};
const SwitchImuSample second{10, 20, 30, 40, 50, 60};
const SwitchImuSample third{-10, -20, -30, -40, -50, -60};
SwitchInputState one{};
SwitchInputState three{};
// When: both frames are decoded.
CHECK(decode(make_frame(0, SWITCH_PRO_HAT_NOTHING, 1, {first}), one));
CHECK(decode(make_frame(0, SWITCH_PRO_HAT_NOTHING, 3, {first, second, third}), three));
// Then: counts and little-endian signed sample fields remain exact.
CHECK(one.imu_sample_count == 1 && one.imu_samples[0].accel_x == -32768);
CHECK(one.imu_samples[0].gyro_z == 32767);
CHECK(three.imu_sample_count == 3);
CHECK(three.imu_samples[1].accel_z == 30 && three.imu_samples[1].gyro_y == 50);
CHECK(three.imu_samples[2].accel_y == -20 && three.imu_samples[2].gyro_z == -60);
return true;
}
bool uart_decoder_caps_imu_count_and_rejects_truncation() {
// Given: a count of four backed by three samples, and a count of one with none.
const SwitchImuSample sample{1, 2, 3, 4, 5, 6};
const std::vector<uint8_t> capped = make_frame(0, SWITCH_PRO_HAT_NOTHING, 4, {sample, sample, sample});
const std::vector<uint8_t> truncated = make_frame(0, SWITCH_PRO_HAT_NOTHING, 1);
SwitchInputState state{};
// When: both frames are decoded. Then: three samples are accepted and truncation is rejected.
CHECK(decode(capped, state));
CHECK(state.imu_sample_count == 3);
CHECK(!decode(truncated, state));
return true;
}
bool uart_decoder_rejects_null_output() {
// Given: an otherwise-valid frame. When: no output state is supplied.
const std::vector<uint8_t> frame = make_frame();
// Then: the legacy parser returns false rather than mutating driver state.
CHECK(!switch_uart_decode_input_frame(frame.data(), frame.size(), nullptr));
return true;
}
} // namespace
void run_switch_uart_protocol_tests(TestRunner& runner) {
runner.run("UART rejects short frame", uart_decoder_rejects_short_frame);
runner.run("UART rejects header and version", uart_decoder_rejects_wrong_header_and_version);
runner.run("UART rejects length and checksum", uart_decoder_rejects_declared_length_and_checksum_mismatch);
runner.run("UART decodes neutral frame", uart_decoder_decodes_neutral_frame);
runner.run("UART maps every button", uart_decoder_maps_every_button_bit);
runner.run("UART maps every hat", uart_decoder_maps_every_hat_value);
runner.run("UART expands stick bytes", uart_decoder_expands_stick_bytes);
runner.run("UART decodes IMU samples", uart_decoder_decodes_one_and_three_imu_samples);
runner.run("UART caps and validates IMU count", uart_decoder_caps_imu_count_and_rejects_truncation);
runner.run("UART rejects null output", uart_decoder_rejects_null_output);
}

205
tools/debug_imu_raw.py Executable file
View file

@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Raw SDL3 IMU diagnostic tool.
Prints every gyro/accel sensor event directly from SDL3, bypassing all
bridge logic. Use this to confirm SDL3 is delivering sensor events before
debugging conversion or axis mapping issues.
Usage:
uv run python tools/debug_imu_raw.py
uv run python tools/debug_imu_raw.py --count 500 # stop after N gyro events
uv run python tools/debug_imu_raw.py --no-bias # skip bias calibration window
"""
import argparse
import ctypes
import math
import sys
import time
import sdl3
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SDL_SENSOR_ACCEL = 1
SDL_SENSOR_GYRO = 2
GRAVITY = 9.80665 # m/s²
LSB_PER_G = 4096.0 # Nintendo accel scale
LSB_PER_RAD_S = 818.5 # Nintendo gyro scale
BIAS_SAMPLES = 200 # ~1 second at 200 Hz
def main():
parser = argparse.ArgumentParser(description="Raw SDL3 IMU diagnostic")
parser.add_argument("--count", type=int, default=0,
help="Stop after this many gyro events (0 = run forever)")
parser.add_argument("--no-bias", action="store_true",
help="Skip bias calibration window, print raw values immediately")
parser.add_argument("--raw", action="store_true",
help="Also print converted Nintendo-native raw counts")
args = parser.parse_args()
# Init SDL3 with gamepad + sensor support
if not sdl3.SDL_Init(sdl3.SDL_INIT_GAMEPAD | sdl3.SDL_INIT_EVENTS):
print(f"SDL_Init failed: {sdl3.SDL_GetError().decode()}", file=sys.stderr)
sys.exit(1)
sdl3.SDL_SetGamepadEventsEnabled(True)
# Find first gamepad
count = ctypes.c_int(0)
ids = sdl3.SDL_GetJoysticks(ctypes.byref(count))
if not ids or count.value == 0:
print("No joysticks/gamepads found.", file=sys.stderr)
sdl3.SDL_Quit()
sys.exit(1)
gamepad = None
instance_id = None
for i in range(count.value):
if sdl3.SDL_IsGamepad(ids[i]):
gamepad = sdl3.SDL_OpenGamepad(ids[i])
instance_id = ids[i]
break
sdl3.SDL_free(ids)
if not gamepad:
print("No gamepad found (only non-gamepad joysticks detected).", file=sys.stderr)
sdl3.SDL_Quit()
sys.exit(1)
name = sdl3.SDL_GetGamepadName(gamepad)
print(f"Gamepad: {name.decode() if name else 'unknown'} (instance_id={instance_id})")
# Check sensor support
has_accel = bool(sdl3.SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL))
has_gyro = bool(sdl3.SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO))
print(f" Accelerometer supported: {has_accel}")
print(f" Gyroscope supported: {has_gyro}")
if not (has_accel and has_gyro):
print("\nThis controller does not expose IMU sensors to SDL3.")
print("Possible reasons:")
print(" - Controller doesn't have IMU (Xbox, generic gamepads)")
print(" - Missing kernel driver (Linux: hid-nintendo not loaded)")
print(" - SDL3 HIDAPI disabled for this controller")
sdl3.SDL_CloseGamepad(gamepad)
sdl3.SDL_Quit()
sys.exit(1)
# Enable sensors
ok_accel = bool(sdl3.SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, True))
ok_gyro = bool(sdl3.SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, True))
print(f" Accelerometer enabled: {ok_accel}")
print(f" Gyroscope enabled: {ok_gyro}")
if not (ok_accel and ok_gyro):
print(f"\nFailed to enable sensors: {sdl3.SDL_GetError().decode()}")
sdl3.SDL_CloseGamepad(gamepad)
sdl3.SDL_Quit()
sys.exit(1)
print()
if args.no_bias:
print("Skipping bias calibration. Showing raw values immediately.")
else:
print(f"Hold controller STILL — collecting {BIAS_SAMPLES} gyro samples for bias calibration...")
print("Press Ctrl+C to stop.\n")
print(f"{'EVENT':<8} {'AX':>8} {'AY':>8} {'AZ':>8} {'GX':>8} {'GY':>8} {'GZ':>8} {'STATUS'}")
print("-" * 80)
# State
last_accel = (0.0, 0.0, 0.0)
bias = [0.0, 0.0, 0.0]
bias_count = 0
bias_locked = args.no_bias
gyro_events = 0
last_print = time.monotonic()
event = sdl3.SDL_Event()
try:
while True:
while sdl3.SDL_PollEvent(ctypes.byref(event)):
t = event.type
if t == sdl3.SDL_EVENT_GAMEPAD_SENSOR_UPDATE:
gs = event.gsensor
# Only handle events from our gamepad
if gs.which != instance_id:
continue
sensor_type = gs.sensor
d = gs.data # c_float_Array_3
if sensor_type == SDL_SENSOR_ACCEL:
last_accel = (float(d[0]), float(d[1]), float(d[2]))
continue
if sensor_type != SDL_SENSOR_GYRO:
continue
gx, gy, gz = float(d[0]), float(d[1]), float(d[2])
# Bias accumulation
if not bias_locked:
if bias_count < BIAS_SAMPLES:
bias[0] += gx
bias[1] += gy
bias[2] += gz
bias_count += 1
if bias_count >= BIAS_SAMPLES:
bias = [b / BIAS_SAMPLES for b in bias]
bias_locked = True
print(f" [BIAS LOCKED] bias_rad_s=({bias[0]:.5f}, {bias[1]:.5f}, {bias[2]:.5f})\n")
continue # Don't print during calibration
gyro_events += 1
ax, ay, az = last_accel
ux, uy, uz = gx - bias[0], gy - bias[1], gz - bias[2]
now = time.monotonic()
if now - last_print >= 0.1: # 10 Hz display update
last_print = now
# In m/s² and rad/s (SDL values)
status = f"events={gyro_events}"
if args.raw:
# Nintendo-native counts (reversed SDL axis mapping)
nx = int(-uz * LSB_PER_RAD_S)
ny = int(-ux * LSB_PER_RAD_S)
nz = int( uy * LSB_PER_RAD_S)
nax = int(-az / GRAVITY * LSB_PER_G)
nay = int(-ax / GRAVITY * LSB_PER_G)
naz = int( ay / GRAVITY * LSB_PER_G)
status += f" raw_g=({nax},{nay},{naz}) raw_gyro=({nx},{ny},{nz})"
print(
f"{'GYRO':<8} "
f"{ax:>8.3f} {ay:>8.3f} {az:>8.3f} "
f"{ux:>8.4f} {uy:>8.4f} {uz:>8.4f} "
f"{status}"
)
elif t == sdl3.SDL_EVENT_GAMEPAD_REMOVED:
print("\nGamepad disconnected.")
break
if args.count and gyro_events >= args.count:
print(f"\nReached {args.count} gyro events. Done.")
break
time.sleep(0.001)
except KeyboardInterrupt:
print("\n\nStopped.")
print(f"\nTotal gyro events received: {gyro_events}")
if bias_locked:
print(f"Final bias (rad/s): ({bias[0]:.5f}, {bias[1]:.5f}, {bias[2]:.5f})")
print(f"Bias magnitude: {math.sqrt(sum(b**2 for b in bias)):.5f} rad/s "
f"= {math.sqrt(sum(b**2 for b in bias)) * 180/math.pi:.3f} deg/s")
sdl3.SDL_CloseGamepad(gamepad)
sdl3.SDL_Quit()
if __name__ == "__main__":
main()

View file

@ -1,11 +1,8 @@
// TinyUSB configuration for the selected 64-byte controller protocol.
// 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.
#ifndef _TUSB_CONFIG_H_
#define _TUSB_CONFIG_H_
#if (defined(SWITCH_PICO_PROTOCOL_LEGACY) + defined(SWITCH_PICO_PROTOCOL_SWITCH2)) != 1
#error "Define exactly one Switch Pico USB protocol"
#endif
#ifdef __cplusplus
extern "C" {
#endif
@ -30,15 +27,7 @@ extern "C" {
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
#define CFG_TUD_MIDI 0
#define CFG_TUD_AUDIO 0
#if defined(SWITCH_PICO_PROTOCOL_SWITCH2)
#define CFG_TUD_VENDOR 1
#define CFG_TUD_VENDOR_EPSIZE 64
#define CFG_TUD_VENDOR_RX_BUFSIZE 64
#define CFG_TUD_VENDOR_TX_BUFSIZE 64
#else
#define CFG_TUD_VENDOR 0
#endif
// Always enable TinyUSB debug at level 2; LOG_PRINTF controls user-facing logs.
#ifdef CFG_TUSB_DEBUG
#undef CFG_TUSB_DEBUG