From 12dc3d58ee172a76046f7592320311a083668bf6 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 17:05:56 -0600 Subject: [PATCH 01/27] chore: add raw SDL3 IMU diagnostic tool --- tools/debug_imu_raw.py | 205 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100755 tools/debug_imu_raw.py diff --git a/tools/debug_imu_raw.py b/tools/debug_imu_raw.py new file mode 100755 index 0000000..ad9b4cd --- /dev/null +++ b/tools/debug_imu_raw.py @@ -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() From 9d33bc4be7b03971251dc10d3f6beba3b2504508 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 17:16:49 -0600 Subject: [PATCH 02/27] fix(bridge): fix gyro bias calibration corruption and zero-accel startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../controller_uart_bridge.py | 102 ++++++++++++++---- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index a5a22af..cbf4104 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -59,9 +59,15 @@ 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) +SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr( + sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658 +) GYRO_BIAS_SAMPLES = 200 IMU_BUFFER_SIZE = 32 +# Gyro samples exceeding this magnitude (rad/s) during calibration are treated as +# motion and discarded. ~0.5 rad/s = ~28 deg/s covers all realistic hand tremor +# while excluding deliberate movement or controller-pickup events. +GYRO_MOTION_THRESHOLD = 0.5 def parse_mapping(value: str) -> Tuple[int, str]: @@ -249,7 +255,10 @@ 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 @@ -322,7 +331,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( @@ -853,7 +864,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 +957,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 +970,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 +1026,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 +1130,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 +1251,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,7 +1366,11 @@ def handle_sensor_update( gx, gy, gz = float(data[0]), float(data[1]), float(data[2]) if not ctx.gyro_bias_locked: - if ctx.gyro_bias_samples < GYRO_BIAS_SAMPLES: + # Reject motion samples — only accumulate when controller is still. + # This prevents startup movement (typing, setting down controller) from + # corrupting the bias estimate, which would cause constant camera drift. + magnitude = (gx * gx + gy * gy + gz * gz) ** 0.5 + if magnitude < GYRO_MOTION_THRESHOLD: ctx.gyro_bias_x += gx ctx.gyro_bias_y += gy ctx.gyro_bias_z += gz @@ -1337,11 +1381,21 @@ def handle_sensor_update( ctx.gyro_bias_y /= n ctx.gyro_bias_z /= n ctx.gyro_bias_locked = True + if config.debug_imu: + 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 + ) + print( + f"[IMU idx={ctx.controller_index}] bias locked: " + f"({ctx.gyro_bias_x:.5f}, {ctx.gyro_bias_y:.5f}, {ctx.gyro_bias_z:.5f}) rad/s " + f"magnitude={mag:.5f} rad/s = {mag * 180 / math.pi:.2f} deg/s" + ) + # 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 @@ -1428,7 +1482,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]" ) @@ -1441,11 +1501,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) From 30e22c210c678ff2157cb2a43ca2e920a487e976 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 17:22:52 -0600 Subject: [PATCH 03/27] fix(firmware): zero SPI IMU calibration origins to prevent phantom rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- switch_pro_driver.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index ee68f68..b84af59 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -97,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, From 3c60841d237b5c37a9e5701a9ac609add6505766 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 17:42:01 -0600 Subject: [PATCH 04/27] 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. --- .../controller_uart_bridge.py | 62 ++++++++++++------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index cbf4104..4c05ffe 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -62,12 +62,10 @@ SDL_TRUE = True SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr( sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658 ) -GYRO_BIAS_SAMPLES = 200 +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 -# Gyro samples exceeding this magnitude (rad/s) during calibration are treated as -# motion and discarded. ~0.5 rad/s = ~28 deg/s covers all realistic hand tremor -# while excluding deliberate movement or controller-pickup events. -GYRO_MOTION_THRESHOLD = 0.5 def parse_mapping(value: str) -> Tuple[int, str]: @@ -264,6 +262,7 @@ class ControllerContext: 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 @@ -1366,32 +1365,51 @@ def handle_sensor_update( gx, gy, gz = float(data[0]), float(data[1]), float(data[2]) if not ctx.gyro_bias_locked: - # Reject motion samples — only accumulate when controller is still. - # This prevents startup movement (typing, setting down controller) from - # corrupting the bias estimate, which would cause constant camera drift. - magnitude = (gx * gx + gy * gy + gz * gz) ** 0.5 - if magnitude < GYRO_MOTION_THRESHOLD: + 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 - if config.debug_imu: - import math + import math - mag = math.sqrt( - ctx.gyro_bias_x**2 + ctx.gyro_bias_y**2 + ctx.gyro_bias_z**2 - ) - print( - f"[IMU idx={ctx.controller_index}] bias locked: " - f"({ctx.gyro_bias_x:.5f}, {ctx.gyro_bias_y:.5f}, {ctx.gyro_bias_z:.5f}) rad/s " - f"magnitude={mag:.5f} rad/s = {mag * 180 / math.pi:.2f} deg/s" - ) + 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 From e7c01d111642a8f6d8375c572d7c26c7be3fa9a9 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 18:32:45 -0600 Subject: [PATCH 05/27] fix(firmware): route 0x10/0x21 output reports to subcommand handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- switch_pro_driver.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index b84af59..b026473 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -794,13 +794,18 @@ void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_ if (switchReportID == REPORT_OUTPUT_00) { // No-op, just acknowledge to clear any stalls. return; + } else if (switchReportID == REPORT_OUTPUT_10 || switchReportID == REPORT_OUTPUT_21) { + // 0x10/0x21 output reports carry rumble (bytes 2-9) AND a subcommand + // at byte 10. The Switch sends IMU enable (0x40), SPI reads (0x10), + // vibration enable (0x48), player lights (0x30), etc. via these reports. + queued_report_id = report_id; + handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); } 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); - } else { } } @@ -817,6 +822,9 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, uint8_t con } if (switchReportID == REPORT_OUTPUT_00) { return; + } else if (switchReportID == REPORT_OUTPUT_10 || switchReportID == REPORT_OUTPUT_21) { + queued_report_id = report_id; + handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); } else if (switchReportID == REPORT_FEATURE) { queued_report_id = report_id; handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); From 0fbb18706885b2cde745d78bfe9c7aea6370dbb3 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 18:40:58 -0600 Subject: [PATCH 06/27] fix(firmware): revert broken 0x10 subcommand routing, add diagnostic logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- switch_pro_driver.cpp | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index b026473..d7d3ba3 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -331,6 +331,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; @@ -794,12 +796,6 @@ void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_ if (switchReportID == REPORT_OUTPUT_00) { // No-op, just acknowledge to clear any stalls. return; - } else if (switchReportID == REPORT_OUTPUT_10 || switchReportID == REPORT_OUTPUT_21) { - // 0x10/0x21 output reports carry rumble (bytes 2-9) AND a subcommand - // at byte 10. The Switch sends IMU enable (0x40), SPI reads (0x10), - // vibration enable (0x48), player lights (0x30), etc. via these reports. - queued_report_id = report_id; - handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); } else if (switchReportID == REPORT_FEATURE) { queued_report_id = report_id; handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); @@ -822,9 +818,6 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, uint8_t con } if (switchReportID == REPORT_OUTPUT_00) { return; - } else if (switchReportID == REPORT_OUTPUT_10 || switchReportID == REPORT_OUTPUT_21) { - queued_report_id = report_id; - handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); } else if (switchReportID == REPORT_FEATURE) { queued_report_id = report_id; handle_feature_report(switchReportID, switchReportSubID, buffer, bufsize); From 6884f2512176a2dd3cec44addc367a0faae1a0f7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 18:57:26 -0600 Subject: [PATCH 07/27] 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. --- .../controller_uart_bridge.py | 15 +++++++-- switch_pro_driver.cpp | 32 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index 4c05ffe..32203f8 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -1628,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 diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index d7d3ba3..7680e8f 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -192,9 +192,26 @@ static std::map spi_flash_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; @@ -634,6 +651,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; } From 731e1d8d152307cdefdb30005a35395a109e53a7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 19:18:37 -0600 Subject: [PATCH 08/27] fix(firmware): correct SPI 0x6080 horizontal offsets to match bridge output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- switch_pro_driver.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index 7680e8f..7e835e3 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -143,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, From 4f7e4a8de66dcfe9a0c0288b8533f35bf5443e3b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 20:34:05 -0600 Subject: [PATCH 09/27] 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 --- src/switch_pico_bridge/controller_uart_bridge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index a5a22af..f35a386 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -819,7 +819,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 From 0fe53a53b155ab0f62b3c702ff5d9772a1d4b346 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 20:43:32 -0600 Subject: [PATCH 10/27] feat(bridge): add controller-side ABXY swap combo (LB+RB+SELECT+START) Holding all four buttons simultaneously toggles the ABXY layout for that controller with a 200ms rumble confirmation pulse. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../controller_uart_bridge.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index a5a22af..c1db5b1 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -1385,10 +1385,31 @@ 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) @@ -1411,6 +1432,8 @@ 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( @@ -1622,7 +1645,7 @@ def run_bridge_loop( sdl3.SDL_EVENT_GAMEPAD_BUTTON_DOWN, sdl3.SDL_EVENT_GAMEPAD_BUTTON_UP, ): - handle_button_event(event, config, contexts) + handle_button_event(event, config, contexts, console) elif event.type == SDL_EVENT_GAMEPAD_SENSOR_UPDATE: handle_sensor_update(event, contexts, config) elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED: From a1dd3af6924cacc694f5390a9e9ef422aa79d775 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 11/27] Share input model Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch_input.h | 72 ++++++++++++++++++++++++++++ tests/firmware/test_switch_input.cpp | 52 ++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 switch_input.h create mode 100644 tests/firmware/test_switch_input.cpp diff --git a/switch_input.h b/switch_input.h new file mode 100644 index 0000000..93b65e0 --- /dev/null +++ b/switch_input.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#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; diff --git a/tests/firmware/test_switch_input.cpp b/tests/firmware/test_switch_input.cpp new file mode 100644 index 0000000..11781e0 --- /dev/null +++ b/tests/firmware/test_switch_input.cpp @@ -0,0 +1,52 @@ +#include "test_support.h" + +#include + +#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); +} From 05098bbdacdfa8948e2ec87017612ce73e44113a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 12/27] Define UART decoder Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch_uart_protocol.h | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 switch_uart_protocol.h diff --git a/switch_uart_protocol.h b/switch_uart_protocol.h new file mode 100644 index 0000000..8fc8d79 --- /dev/null +++ b/switch_uart_protocol.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +#include "switch_input.h" + +bool switch_uart_decode_input_frame( + const uint8_t* packet, + uint8_t length, + SwitchInputState* out_state); From a7f07c67b8b2514cda9414a027cbddef2a04d3a4 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 13/27] Implement UART decoder Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch_uart_protocol.cpp | 124 +++++++++ tests/firmware/test_switch_uart_protocol.cpp | 261 +++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 switch_uart_protocol.cpp create mode 100644 tests/firmware/test_switch_uart_protocol.cpp diff --git a/switch_uart_protocol.cpp b/switch_uart_protocol.cpp new file mode 100644 index 0000000..58bb6e9 --- /dev/null +++ b/switch_uart_protocol.cpp @@ -0,0 +1,124 @@ +#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(payload_len) + 4u != length) { + return false; + } + + uint16_t sum = 0; + for (uint16_t index = 0; index < static_cast(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(packet[3]) | + (static_cast(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(8u + static_cast(imu_count) * 12u); + if (payload_len < required_payload_len) { + return false; + } + + const auto expand_axis = [](uint8_t value) -> uint16_t { + return static_cast(value) << 8 | value; + }; + const auto read_int16 = [](const uint8_t* source) -> int16_t { + return static_cast( + static_cast(source[0]) | + (static_cast(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; +} diff --git a/tests/firmware/test_switch_uart_protocol.cpp b/tests/firmware/test_switch_uart_protocol.cpp new file mode 100644 index 0000000..0dd1edd --- /dev/null +++ b/tests/firmware/test_switch_uart_protocol.cpp @@ -0,0 +1,261 @@ +#include "test_support.h" + +#include +#include +#include +#include + +#include "../../switch_input.h" +#include "../../switch_uart_protocol.h" + +namespace { + +using ImuSamples = std::vector; + +void append_int16(std::vector& bytes, int16_t value) { + const uint16_t encoded = static_cast(value); + bytes.push_back(static_cast(encoded & 0xFF)); + bytes.push_back(static_cast(encoded >> 8)); +} + +std::vector make_frame( + uint16_t buttons = 0, + uint8_t hat = SWITCH_PRO_HAT_NOTHING, + uint8_t imu_count = 0, + const ImuSamples& samples = {}) { + std::vector frame = { + 0xAA, 0x02, 0x00, + static_cast(buttons & 0xFF), + static_cast(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(frame.size() - 3); + uint8_t checksum = 0; + for (uint8_t byte : frame) { + checksum = static_cast(checksum + byte); + } + frame.push_back(checksum); + return frame; +} + +bool decode(const std::vector& frame, SwitchInputState& state) { + return switch_uart_decode_input_frame( + frame.data(), static_cast(frame.size()), &state); +} + +bool uart_decoder_rejects_short_frame() { + // Given: a frame shorter than the legacy 12-byte minimum. + const std::array 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 wrong_header = make_frame(); + std::vector 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 wrong_length = make_frame(); + std::vector 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 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 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(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 capped = make_frame(0, SWITCH_PRO_HAT_NOTHING, 4, {sample, sample, sample}); + const std::vector 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 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); +} From 1a6859855ee875b3642ceaf37222d4c945476934 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 14/27] Add protocol facade Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch_legacy_protocol.cpp | 23 +++++++++++++++++++++++ switch_protocol.h | 13 +++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 switch_legacy_protocol.cpp create mode 100644 switch_protocol.h diff --git a/switch_legacy_protocol.cpp b/switch_legacy_protocol.cpp new file mode 100644 index 0000000..9f21073 --- /dev/null +++ b/switch_legacy_protocol.cpp @@ -0,0 +1,23 @@ +#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); +} diff --git a/switch_protocol.h b/switch_protocol.h new file mode 100644 index 0000000..28b4c8a --- /dev/null +++ b/switch_protocol.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#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); From 1b65893a69ba0ab2f908a97c85e9468a322aa1f7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 15/27] Define Switch2 commands Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_commands.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 switch2_commands.h diff --git a/switch2_commands.h b/switch2_commands.h new file mode 100644 index 0000000..bd2cfcd --- /dev/null +++ b/switch2_commands.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +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); From 950c5de1eaa3ee684d51d80344cdc1bf0cef1723 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 16/27] Implement Switch2 commands Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_commands.cpp | 79 ++++++++++ tests/firmware/test_switch2_commands.cpp | 185 +++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 switch2_commands.cpp create mode 100644 tests/firmware/test_switch2_commands.cpp diff --git a/switch2_commands.cpp b/switch2_commands.cpp new file mode 100644 index 0000000..de8716f --- /dev/null +++ b/switch2_commands.cpp @@ -0,0 +1,79 @@ +#include "switch2_commands.h" + +#include + +// 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(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; +} diff --git a/tests/firmware/test_switch2_commands.cpp b/tests/firmware/test_switch2_commands.cpp new file mode 100644 index 0000000..c9bb6d2 --- /dev/null +++ b/tests/firmware/test_switch2_commands.cpp @@ -0,0 +1,185 @@ +#include "test_support.h" + +#include +#include +#include +#include + +#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 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 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 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 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 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 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 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 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); +} From f79394cd8077c85f5c492c949f406b3364db5bc5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 17/27] Define Switch2 reports Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_reports.h | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 switch2_reports.h diff --git a/switch2_reports.h b/switch2_reports.h new file mode 100644 index 0000000..ae1a08b --- /dev/null +++ b/switch2_reports.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "switch_input.h" + +enum class Switch2InputReportId : uint8_t { + Common = 0x05, + Pro = 0x09, +}; + +struct Switch2InputReport { + Switch2InputReportId id; + std::array payload; +}; + +Switch2InputReport switch2_build_input_report( + Switch2InputReportId id, + const SwitchInputState& state, + uint32_t counter); From f8de986717dcf57d31d49e141acc2523ce87e1e5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 18/27] Implement Switch2 reports Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_reports.cpp | 89 ++++++++++++ tests/firmware/test_switch2_reports.cpp | 186 ++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 switch2_reports.cpp create mode 100644 tests/firmware/test_switch2_reports.cpp diff --git a/switch2_reports.cpp b/switch2_reports.cpp new file mode 100644 index 0000000..70b171c --- /dev/null +++ b/switch2_reports.cpp @@ -0,0 +1,89 @@ +#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(packed_x & 0xFF); + destination[1] = static_cast( + ((packed_x >> 8) & 0x0F) | ((packed_y & 0x0F) << 4)); + destination[2] = static_cast(packed_y >> 4); +} + +} // namespace + +Switch2InputReport switch2_build_input_report( + Switch2InputReportId id, + const SwitchInputState& state, + uint32_t counter) { + Switch2InputReport report{id, {}}; + std::array& payload = report.payload; + + switch (id) { + case Switch2InputReportId::Common: + payload[0] = static_cast(counter); + payload[1] = static_cast(counter >> 8); + payload[2] = static_cast(counter >> 16); + payload[3] = static_cast(counter >> 24); + payload[4] = static_cast( + (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( + (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( + (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(counter); + // USB external power is known; charging and battery bits stay zero. + payload[1] = 0x01; + payload[2] = static_cast( + (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( + (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( + (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; +} diff --git a/tests/firmware/test_switch2_reports.cpp b/tests/firmware/test_switch2_reports.cpp new file mode 100644 index 0000000..1496bc7 --- /dev/null +++ b/tests/firmware/test_switch2_reports.cpp @@ -0,0 +1,186 @@ +#include "test_support.h" + +#include +#include + +#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& 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(common.id) == 0x05); + CHECK(static_cast(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); +} From 0ec06676c3f2efd9a477e60ae05f92199e1d7a92 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 19/27] Define Switch2 descriptors Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_descriptors.h | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 switch2_descriptors.h diff --git a/switch2_descriptors.h b/switch2_descriptors.h new file mode 100644 index 0000000..b8848aa --- /dev/null +++ b/switch2_descriptors.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +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; From 3251e49191a95863fc590359450c75fd31d4da0d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 20/27] Implement Switch2 descriptors Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_descriptors.cpp | 64 ++++++++ tests/firmware/test_switch2_descriptors.cpp | 166 ++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 switch2_descriptors.cpp create mode 100644 tests/firmware/test_switch2_descriptors.cpp diff --git a/switch2_descriptors.cpp b/switch2_descriptors.cpp new file mode 100644 index 0000000..1de4fa6 --- /dev/null +++ b/switch2_descriptors.cpp @@ -0,0 +1,64 @@ +#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); diff --git a/tests/firmware/test_switch2_descriptors.cpp b/tests/firmware/test_switch2_descriptors.cpp new file mode 100644 index 0000000..2530ca5 --- /dev/null +++ b/tests/firmware/test_switch2_descriptors.cpp @@ -0,0 +1,166 @@ +#include "test_support.h" + +#include +#include +#include + +#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(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); +} From 95b0576d1d9e9b6711b01adac182d85620cfc588 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 21/27] Add Switch2 driver Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_driver.cpp | 237 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 switch2_driver.cpp diff --git a/switch2_driver.cpp b/switch2_driver.cpp new file mode 100644 index 0000000..e8d3133 --- /dev/null +++ b/switch2_driver.cpp @@ -0,0 +1,237 @@ +#include "switch_protocol.h" + +#include +#include +#include +#include + +#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 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(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( + 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(report.id), + report.payload.data(), + static_cast(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(Switch2InputReportId::Common)) { + id = Switch2InputReportId::Common; + } else if (report_id == static_cast(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(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 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(switch2_string_language[0]) | + (static_cast(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(0x0300 | (2 * length + 2)); + return descriptor; +} From f3067bfd2e51dedacfc74101891fc9d0accce32a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 22/27] Add legacy contracts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/firmware/test_legacy_descriptors.cpp | 98 ++++++++++++++++++++++ tests/firmware/test_support.h | 38 +++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/firmware/test_legacy_descriptors.cpp create mode 100644 tests/firmware/test_support.h diff --git a/tests/firmware/test_legacy_descriptors.cpp b/tests/firmware/test_legacy_descriptors.cpp new file mode 100644 index 0000000..e66bd77 --- /dev/null +++ b/tests/firmware/test_legacy_descriptors.cpp @@ -0,0 +1,98 @@ +#include "test_support.h" + +#include +#include +#include + +#include "../../switch_pro_descriptors.h" + +namespace { + +template +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; +} + +} // 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); +} diff --git a/tests/firmware/test_support.h b/tests/firmware/test_support.h new file mode 100644 index 0000000..8b47685 --- /dev/null +++ b/tests/firmware/test_support.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +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); From 01cf6ed26fabfa88ca3117559ce7459625d32d0c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 23/27] Enable firmware tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/firmware/CMakeLists.txt | 24 ++++++++++++++++++++++++ tests/firmware/test_main.cpp | 12 ++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/firmware/CMakeLists.txt create mode 100644 tests/firmware/test_main.cpp diff --git a/tests/firmware/CMakeLists.txt b/tests/firmware/CMakeLists.txt new file mode 100644 index 0000000..2d26b4d --- /dev/null +++ b/tests/firmware/CMakeLists.txt @@ -0,0 +1,24 @@ +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) diff --git a/tests/firmware/test_main.cpp b/tests/firmware/test_main.cpp new file mode 100644 index 0000000..d8b7ba8 --- /dev/null +++ b/tests/firmware/test_main.cpp @@ -0,0 +1,12 @@ +#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(); +} From 05e04b263212df96535ae1efccf93283f9966f97 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 24/27] Wire protocol modes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- CMakeLists.txt | 26 ++++++++- switch-pico.cpp | 20 ++++--- switch_pro_descriptors.h | 33 +---------- switch_pro_driver.cpp | 117 +-------------------------------------- switch_pro_driver.h | 45 +-------------- tusb_config.h | 15 ++++- 6 files changed, 53 insertions(+), 203 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 10a816f..ebca586 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,8 +24,29 @@ 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) @@ -38,9 +59,12 @@ pico_sdk_init() add_executable(switch-pico switch-pico.cpp - switch_pro_driver.cpp + switch_uart_protocol.cpp + ${SWITCH_PICO_PROTOCOL_SOURCES} ) +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") diff --git a/switch-pico.cpp b/switch-pico.cpp index 22beaef..dc93a67 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -4,7 +4,9 @@ #include "hardware/uart.h" #include "pico/stdlib.h" #include "tusb.h" -#include "switch_pro_driver.h" +#include "switch_input.h" +#include "switch_protocol.h" +#include "switch_uart_protocol.h" #ifdef SWITCH_PICO_LOG #define LOG_PRINTF(...) printf(__VA_ARGS__) @@ -60,7 +62,7 @@ static void on_rumble_from_switch(const uint8_t rumble[8]) { send_rumble_uart_frame(rumble); } -// Consume UART bytes and forward complete frames to the Switch Pro driver. +// Consume UART bytes and decode complete input frames. static bool poll_uart_frames() { static uint8_t buffer[64]; static uint8_t index = 0; @@ -103,7 +105,7 @@ static bool poll_uart_frames() { if (expected_len > 0 && index >= expected_len) { SwitchInputState parsed{}; - if (switch_pro_apply_uart_packet(buffer, expected_len, &parsed)) { + if (switch_uart_decode_input_frame(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", @@ -137,7 +139,7 @@ static bool poll_uart_frames() { static void log_usb_state() { bool mounted = tud_mounted(); - bool ready = switch_pro_is_ready(); + bool ready = switch_protocol_is_ready(); if (mounted != g_last_mounted) { g_last_mounted = mounted; @@ -156,10 +158,10 @@ int main() { init_uart_input(); tusb_init(); - switch_pro_init(); - switch_pro_set_rumble_callback(on_rumble_from_switch); + switch_protocol_init(); + switch_protocol_set_rumble_callback(on_rumble_from_switch); g_user_state = neutral_input(); - switch_pro_set_input(g_user_state); + switch_protocol_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", @@ -170,8 +172,8 @@ int main() { bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; SwitchInputState state = g_user_state; - switch_pro_set_input(state); - switch_pro_task(); // Push state to the Switch host + switch_protocol_set_input(state); + switch_protocol_task(); // Push state to the Switch host log_usb_state(); } } diff --git a/switch_pro_descriptors.h b/switch_pro_descriptors.h index 2ec3444..236b8b3 100644 --- a/switch_pro_descriptors.h +++ b/switch_pro_descriptors.h @@ -9,39 +9,10 @@ #include +#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, diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index ee68f68..57580a9 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -6,6 +6,7 @@ #include #include "pico/rand.h" #include "pico/time.h" +#include "switch_pro_descriptors.h" #include "tusb.h" #ifdef SWITCH_PICO_LOG @@ -210,16 +211,6 @@ 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; @@ -643,112 +634,6 @@ 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(packet[3]) | (static_cast(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(8u + static_cast(imu_count) * 12u); - if (payload_len < required_payload_len) { - return false; - } - - auto expand_axis = [](uint8_t v) -> uint16_t { - return static_cast(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(static_cast(src[0]) | (static_cast(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; } diff --git a/switch_pro_driver.h b/switch_pro_driver.h index 951d68b..fe3c4f5 100644 --- a/switch_pro_driver.h +++ b/switch_pro_driver.h @@ -8,46 +8,8 @@ #include #include -#include "switch_pro_descriptors.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; +#include "switch_protocol.h" // Initialize USB state and calibration before entering the main loop. void switch_pro_init(); @@ -58,13 +20,8 @@ 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); diff --git a/tusb_config.h b/tusb_config.h index 7f291ec..3c25f26 100644 --- a/tusb_config.h +++ b/tusb_config.h @@ -1,8 +1,11 @@ -// TinyUSB configuration tailored for a single Switch Pro style HID interface. -// Data is derived from TinyUSB examples and tuned for a 64-byte HID endpoint. +// TinyUSB configuration for the selected 64-byte controller protocol. #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 @@ -27,7 +30,15 @@ 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 From 05790b4f99065f7d990ab3f89911ff186a99f6a7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 25/27] Update build helper Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- build.py | 121 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 93 insertions(+), 28 deletions(-) mode change 100644 => 100755 build.py diff --git a/build.py b/build.py old mode 100644 new mode 100755 index dabefe8..190bee6 --- a/build.py +++ b/build.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Build and flash the project with optional grip color overrides.""" + import argparse import os import random @@ -8,12 +9,15 @@ 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", BUILD_DIR / "switch-pico.elf")).expanduser() +ELF_PATH = Path(os.environ.get("ELF_PATH", str(BUILD_ELF_PATH))).expanduser() MACROS = ( "SWITCH_COLOR_LEFT_GRIP_R", @@ -24,36 +28,73 @@ MACROS = ( "SWITCH_COLOR_RIGHT_GRIP_B", ) -def parse_args(): +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: 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.", ) - return parser.parse_args() + 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 -def random_hex_color(): + +def random_hex_color() -> str: return "".join(f"{random.randrange(256):02X}" for _ in range(3)) -def validate_custom_color(value): + +def validate_custom_color(value: str) -> str: if not re.fullmatch(r"[0-9A-Fa-f]{6}", value): - raise ValueError("Color must be a 6-digit hex value like FF8800.") + raise argparse.ArgumentTypeError( + "Color must be a 6-digit hex value like FF8800." + ) return value -def update_grip_colors(rgb_hex): + +def update_grip_colors(rgb_hex: str) -> None: 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] @@ -61,14 +102,14 @@ def update_grip_colors(rgb_hex): 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, val, data): + def replace(name: str, val: str, data: str) -> str: 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 @@ -77,26 +118,30 @@ def update_grip_colors(rgb_hex): 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): + +def run_cmd(command: list[str]) -> None: 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(): + +def resolve_picotool() -> Path: 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 @@ -104,10 +149,13 @@ def resolve_picotool(): 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(): + +def build(protocol: BuildProtocol) -> None: run_cmd( [ "cmake", @@ -116,20 +164,23 @@ def build(): "-B", str(BUILD_DIR), "-DSWITCH_PICO_LOG=OFF", + f"-DSWITCH_PICO_PROTOCOL={protocol}", ] ) run_cmd(["cmake", "--build", str(BUILD_DIR)]) -def flash(): + +def flash() -> None: 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(): + +def main() -> None: args = parse_args() color = None @@ -138,16 +189,30 @@ def main(): elif args.grip_color: try: color = validate_custom_color(args.grip_color) - except ValueError as exc: - sys.stderr.write(f"Error: {exc}\n") + except argparse.ArgumentTypeError 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() + 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 + flash() + if __name__ == "__main__": main() From 6f18b652170c09ef50a9a82b1663b176d3f109c7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 26/27] Document protocol modes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 01b46e6..ddcba0d 100644 --- a/README.md +++ b/README.md @@ -103,19 +103,62 @@ Filters you can use: ## Building and flashing firmware Prereqs: Pico SDK + CMake toolchain set up. -### One-shot build + flash (picotool) +### 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: + ```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`. -### 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`). +### 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 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: @@ -135,6 +178,7 @@ 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. From e2a7635f2f7632c188420fa23e93e8cab0e80e65 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:51:36 +0900 Subject: [PATCH 27/27] Harden legacy bounds Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch_pro_bounds.h | 71 +++++++++++ switch_pro_driver.cpp | 131 +++++++++++++-------- tests/firmware/test_legacy_descriptors.cpp | 75 ++++++++++++ 3 files changed, 228 insertions(+), 49 deletions(-) create mode 100644 switch_pro_bounds.h diff --git a/switch_pro_bounds.h b/switch_pro_bounds.h new file mode 100644 index 0000000..183fb36 --- /dev/null +++ b/switch_pro_bounds.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +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; +} diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index 57580a9..a199e67 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -1,4 +1,5 @@ #include "switch_pro_driver.h" +#include "switch_pro_bounds.h" #include #include @@ -178,9 +179,14 @@ static const uint8_t user_calibration_data[0x3F] = { static const SwitchFactoryConfig* factory_config = reinterpret_cast(factory_config_data); static const SwitchUserCalibration* user_calibration [[maybe_unused]] = reinterpret_cast(user_calibration_data); -static std::map spi_flash_data = { - {0x6000, factory_config_data}, - {0x8000, user_calibration_data} +struct SpiFlashRegion { + const uint8_t* data; + std::size_t size; +}; + +static const std::map spi_flash_data = { + {0x6000, {factory_config_data, sizeof(factory_config_data)}}, + {0x8000, {user_calibration_data, sizeof(user_calibration_data)}} }; static inline uint16_t scale16To12(uint16_t pos) { return pos >> 4; } @@ -235,16 +241,32 @@ static bool send_report(uint8_t reportID, const void* reportData, uint16_t repor return result; } -static void read_spi_flash(uint8_t* dest, uint32_t address, uint8_t size) { +static void read_spi_flash( + uint8_t* destination, + std::size_t destination_capacity, + 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 uint8_t* data = it->second; - memcpy(dest, data + addressOffset, size); + const SpiFlashRegion& region = it->second; + switch_pro_fill_flash_read( + destination, + destination_capacity, + region.data, + region.size, + addressOffset, + size); } else { - memset(dest, 0xFF, size); + switch_pro_fill_flash_read( + destination, + destination_capacity, + nullptr, + 0, + 0, + size); } } @@ -371,7 +393,11 @@ 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], spiReadAddress, spiReadSize); + read_spi_flash( + &report_buffer[20], + sizeof(report_buffer) - 20, + spiReadAddress, + spiReadSize); canSend = true; LOG_PRINTF("[HID] FEATURE SPI_READ addr=0x%08lx size=%u\n", (unsigned long)spiReadAddress, spiReadSize); break; @@ -642,6 +668,52 @@ 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; @@ -656,51 +728,12 @@ 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; - - 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); - } else { - } + dispatch_output_report(instance, report_id, buffer, bufsize); } void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, uint8_t const* buffer, uint16_t 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); - } + dispatch_output_report(instance, report_id, buffer, bufsize); } uint8_t const * tud_hid_descriptor_report_cb(uint8_t itf) { diff --git a/tests/firmware/test_legacy_descriptors.cpp b/tests/firmware/test_legacy_descriptors.cpp index e66bd77..47c0064 100644 --- a/tests/firmware/test_legacy_descriptors.cpp +++ b/tests/firmware/test_legacy_descriptors.cpp @@ -5,6 +5,7 @@ #include #include "../../switch_pro_descriptors.h" +#include "../../switch_pro_bounds.h" namespace { @@ -88,6 +89,73 @@ bool legacy_string_descriptors_match_exact_bytes() { 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) { @@ -95,4 +163,11 @@ void run_legacy_descriptor_tests(TestRunner& runner) { 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); }