From a1dd3af6924cacc694f5390a9e9ef422aa79d775 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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); }