From 8df82bf543f5f71a56fee3956ed465d51237e0b0 Mon Sep 17 00:00:00 2001 From: Simon Ho Date: Fri, 15 May 2026 15:17:30 -0400 Subject: [PATCH] refactor: modernize build system, add tests, CI, and auto-versioning Complete overhaul of the procon2droid Magisk module with modern tooling: Build System - Replace monolithic source files with CMake + Ninja build - Add CMakePresets.json for native (tests) and Android cross-compile - Auto-generate module.prop from CMake version via configure_file Code Quality - Restructure src/ into common/, enable/, rumble/ - Modern C++20: constexpr, std::array, std::span, std::optional, std::copy - Use std::unique_ptr with custom deleter for DIR* RAII - Add const correctness, standard EXIT_SUCCESS/EXIT_FAILURE return codes - Add file header comments explaining each component Testing - Add 18 Catch2 tests for init sequence and haptic report - Tests run natively on Windows/macOS/Linux via CMake presets CI/CD - GitHub Actions: lint (clang-format + clang-tidy), test, Android cross-compile - Use egor-tensin/setup-clang@v2 for consistent LLVM 22 tooling - Cross-compile for arm64-v8a and armeabi-v7a with NDK r27c - Auto-strip Android binaries, package Magisk zips on releases Service Script - Replace pgrep substring matching with PID file tracking - Add timestamped logging, cleanup traps, binary sanity checks - Handle enable exit status and controller removal gracefully Tooling - Add .clang-format (LLVM style), .clang-tidy with suppressions - Add .gitignore, .gitattributes with LF normalization --- .clang-format | 18 +++ .clang-tidy | 36 +++++ .gitattributes | 20 +++ .github/workflows/ci.yml | 118 +++++++++++++++++ .gitignore | 28 ++++ CMakeLists.txt | 44 +++++++ CMakePresets.json | 91 +++++++++++++ README.md | 114 +++++++++++++++- module.prop => module.prop.in | 4 +- service.sh | 84 +++++++++--- src/CMakeLists.txt | 64 +++++++++ src/common/device_finder.cpp | 179 +++++++++++++++++++++++++ src/common/device_finder.hpp | 24 ++++ src/common/haptic_report.cpp | 48 +++++++ src/common/haptic_report.hpp | 21 +++ src/common/init_sequence.hpp | 81 ++++++++++++ src/enable.cpp | 125 ------------------ src/enable/main.cpp | 115 ++++++++++++++++ src/rumble.cpp | 238 ---------------------------------- src/rumble/main.cpp | 35 +++++ src/rumble/uinput_proxy.cpp | 215 ++++++++++++++++++++++++++++++ src/rumble/uinput_proxy.hpp | 39 ++++++ tests/CMakeLists.txt | 32 +++++ tests/test_haptic_report.cpp | 114 ++++++++++++++++ tests/test_init_sequence.cpp | 51 ++++++++ 25 files changed, 1552 insertions(+), 386 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json rename module.prop => module.prop.in (69%) create mode 100644 src/CMakeLists.txt create mode 100644 src/common/device_finder.cpp create mode 100644 src/common/device_finder.hpp create mode 100644 src/common/haptic_report.cpp create mode 100644 src/common/haptic_report.hpp create mode 100644 src/common/init_sequence.hpp delete mode 100644 src/enable.cpp create mode 100644 src/enable/main.cpp delete mode 100644 src/rumble.cpp create mode 100644 src/rumble/main.cpp create mode 100644 src/rumble/uinput_proxy.cpp create mode 100644 src/rumble/uinput_proxy.hpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test_haptic_report.cpp create mode 100644 tests/test_init_sequence.cpp diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..b7f2e2f --- /dev/null +++ b/.clang-format @@ -0,0 +1,18 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 120 +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +BreakBeforeBraces: Allman +PointerAlignment: Left +AccessModifierOffset: -4 +NamespaceIndentation: None +FixNamespaceComments: true +IncludeBlocks: Regroup +SortIncludes: true +SpaceAfterCStyleCast: true +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..f4471a9 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,36 @@ +Checks: > + -*, + bugprone-*, + -bugprone-easily-swappable-parameters, + modernize-*, + -modernize-use-trailing-return-type, + -modernize-use-ranges, + performance-*, + readability-*, + -readability-identifier-length, + -readability-magic-numbers, + -readability-function-cognitive-complexity, + readability-implicit-bool-conversion, + cppcoreguidelines-*, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-vararg, + -cppcoreguidelines-owning-memory, + -cppcoreguidelines-special-member-functions, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-bounds-constant-array-index, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-avoid-c-arrays, + -cppcoreguidelines-pro-type-const-cast, + -cppcoreguidelines-pro-type-member-init, + -modernize-avoid-c-arrays, + -cppcoreguidelines-pro-bounds-avoid-unchecked-container-access, + misc-*, + -misc-non-private-member-variables-in-classes, + -misc-include-cleaner, + -misc-use-anonymous-namespace, + portability-*, + -portability-avoid-pragma-once + +WarningsAsErrors: '' +HeaderFilterRegex: '.*' diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8fb0d34 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +* text=auto + +*.cpp text eol=lf +*.hpp text eol=lf +*.h text eol=lf +*.c text eol=lf +*.txt text eol=lf +*.md text eol=lf +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +CMakeLists.txt text eol=lf +*.cmake text eol=lf +*.json text eol=lf +module.prop text eol=lf + +*.png binary +*.jpg binary +*.zip binary +*.tar.gz binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0129b14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + release: + types: [published] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install LLVM tools + uses: egor-tensin/setup-clang@v2 + with: + version: 22 + + - name: Check formatting + run: clang-format-22 --dry-run --Werror $(find src tests -name '*.cpp' -o -name '*.hpp' -o -name '*.h') + + - name: Generate compile commands for lint + run: cmake -S . -B build_lint -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + + - name: Run clang-tidy + run: clang-tidy-22 -p build_lint $(find src tests -name '*.cpp') + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Configure + run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --target procon2droid_tests + + - name: Run tests + run: ctest --test-dir build --output-on-failure + + build-android: + runs-on: ubuntu-latest + strategy: + matrix: + abi: [arm64-v8a, armeabi-v7a] + steps: + - uses: actions/checkout@v6 + + - name: Set up Android NDK + uses: nttld/setup-ndk@v1 + with: + ndk-version: r27c + link-to-sdk: true + + - name: Configure ${{ matrix.abi }} + run: | + cmake -S . -B build_${{ matrix.abi }} \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \ + -DANDROID_ABI=${{ matrix.abi }} \ + -DANDROID_PLATFORM=android-24 \ + -DPROCON2DROID_BUILD_TESTS=OFF + + - name: Build ${{ matrix.abi }} + run: cmake --build build_${{ matrix.abi }} --target enable rumble + + - name: Upload binaries + uses: actions/upload-artifact@v7 + with: + name: binaries-${{ matrix.abi }} + path: | + build_${{ matrix.abi }}/src/enable + build_${{ matrix.abi }}/src/rumble + + package: + needs: build-android + runs-on: ubuntu-latest + if: github.event_name == 'release' + steps: + - uses: actions/checkout@v6 + + - name: Download arm64 binaries + uses: actions/download-artifact@v8 + with: + name: binaries-arm64-v8a + path: dist/arm64-v8a + + - name: Download armv7 binaries + uses: actions/download-artifact@v8 + with: + name: binaries-armeabi-v7a + path: dist/armeabi-v7a + + - name: Regenerate module.prop + run: cmake -S . -B build_dummy + + - name: Package Magisk module + run: | + for abi in arm64-v8a armeabi-v7a; do + mkdir -p "package_$abi/system/bin" + cp module.prop "package_$abi/" + cp service.sh "package_$abi/" + cp "dist/$abi/enable" "package_$abi/system/bin/" + cp "dist/$abi/rumble" "package_$abi/system/bin/" + chmod +x "package_$abi/system/bin/"* + zip -r "procon2droid-${GITHUB_REF_NAME}-$abi.zip" "package_$abi/" + done + + - name: Upload release assets + uses: softprops/action-gh-release@v3 + with: + files: | + procon2droid-*.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1321669 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +build/ +build-debug/ +build-android-arm64/ +build-android-armv7/ +build_android/ +build_dummy/ +compile_commands.json +*.o +*.obj +*.exe +*.out +*.app + +# Packaging outputs +*.zip +*.tar.gz + +# Generated files +module.prop + +# IDE / editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4015581 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.21) + +project(procon2droid + VERSION 1.1.0 + DESCRIPTION "Magisk/KernelSU module for Nintendo Switch 2 Pro Controller" + LANGUAGES CXX +) + +# Generate module.prop from template so version stays in sync with CMake +math(EXPR MODULE_VERSION_CODE "${PROJECT_VERSION_MAJOR} * 10000 + ${PROJECT_VERSION_MINOR} * 100 + ${PROJECT_VERSION_PATCH}") +configure_file( + "${CMAKE_SOURCE_DIR}/module.prop.in" + "${CMAKE_SOURCE_DIR}/module.prop" + @ONLY +) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +if(CMAKE_SYSTEM_NAME STREQUAL "Android") + set(CMAKE_POSITION_INDEPENDENT_CODE ON) +endif() + +include(FetchContent) + +FetchContent_Declare( + spdlog + GIT_REPOSITORY https://github.com/gabime/spdlog.git + GIT_TAG v1.17.0 +) +set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(spdlog) + +add_subdirectory(src) + +option(PROCON2DROID_BUILD_TESTS "Build test suite" ON) + +if(PROCON2DROID_BUILD_TESTS AND NOT CMAKE_SYSTEM_NAME STREQUAL "Android") + enable_testing() + add_subdirectory(tests) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..a129598 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,91 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 21, + "patch": 0 + }, + "configurePresets": [ + { + "name": "native", + "hidden": false, + "description": "Native build with tests (Linux/macOS/Windows with VS+Clang)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "PROCON2DROID_BUILD_TESTS": "ON" + } + }, + { + "name": "native-debug", + "hidden": false, + "description": "Native debug build with tests and debug info", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build-debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "PROCON2DROID_BUILD_TESTS": "ON" + } + }, + { + "name": "android-arm64", + "hidden": false, + "description": "Android cross-compile for arm64-v8a. Requires ANDROID_NDK_HOME env var.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build-android-arm64", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_TOOLCHAIN_FILE": "$env{ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake", + "ANDROID_ABI": "arm64-v8a", + "ANDROID_PLATFORM": "android-24", + "PROCON2DROID_BUILD_TESTS": "OFF" + } + }, + { + "name": "android-armv7", + "hidden": false, + "description": "Android cross-compile for armeabi-v7a. Requires ANDROID_NDK_HOME env var.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build-android-armv7", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_TOOLCHAIN_FILE": "$env{ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake", + "ANDROID_ABI": "armeabi-v7a", + "ANDROID_PLATFORM": "android-24", + "PROCON2DROID_BUILD_TESTS": "OFF" + } + } + ], + "buildPresets": [ + { + "name": "native", + "configurePreset": "native", + "targets": ["procon2droid_tests"] + }, + { + "name": "native-all", + "configurePreset": "native", + "description": "Build everything (tests only on native)" + }, + { + "name": "android-arm64", + "configurePreset": "android-arm64", + "targets": ["enable", "rumble"] + }, + { + "name": "android-armv7", + "configurePreset": "android-armv7", + "targets": ["enable", "rumble"] + } + ], + "testPresets": [ + { + "name": "native", + "configurePreset": "native", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/README.md b/README.md index b5367f6..b6aae46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,114 @@ # procon2droid -This is a root module that adds support for the Nintendo Switch 2 Pro Controller to Android. It supports normal input as well as HD Rumble. This project is greatly inspired by https://handheldlegend.github.io/procon2tool/. -Note that only USB (not Bluetooth) is supported. +[![CI](https://github.com/hapqe/procon2droid/actions/workflows/ci.yml/badge.svg)](https://github.com/hapqe/procon2droid/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![C++20](https://img.shields.io/badge/C%2B%2B-20-blue.svg)](https://en.cppreference.com/w/cpp/20) -For installation grab the [latest release](https://github.com/hapqe/procon2droid/releases/tag/v1.0). +> Magisk/KernelSU module that adds **Nintendo Switch 2 Pro Controller** support to Android — with full **HD Rumble**. + +**USB only** (not Bluetooth). + +--- + +## Features + +- **Full input forwarding** — All buttons, sticks, and sensors work through a virtual uinput device +- **HD Rumble** — Translates Android `FF_RUMBLE` effects into Switch 2 Pro Controller haptic HID reports +- **Zero UI** — Runs entirely headless via Magisk/KernelSU service +- **Lightweight** — Two small statically-linked binaries + +--- + +## Requirements + +- Rooted Android device with **Magisk** or **KernelSU** +- Nintendo Switch 2 Pro Controller connected via **USB cable** + +--- + +## Installation + +Grab the [latest release](https://github.com/hapqe/procon2droid/releases) and flash the zip in Magisk/KernelSU. + +```bash +# Or via Magisk CLI +magisk --install-module procon2droid-v1.1-arm64-v8a.zip +``` + +Then reboot. The controller will be automatically detected when plugged in. + +--- + +## How it works + +```mermaid +flowchart TB + A["Switch 2 Pro Controller"] -- USB --> B["Android Kernel
(HID driver)"] + B -- /dev/input/event* --> C["rumble daemon
(uinput proxy)"] + C -- /dev/hidraw* --> D["enable
(one-shot init)"] + C --> E["Virtual gamepad
with FF_RUMBLE"] + D --> A +``` + +1. **`service.sh`** polls `lsusb` for VID `057e` / PID `2069` +2. On detect: runs **`enable`** → sends the 17-packet USB init sequence to wake the controller, then releases the interface back to the kernel +3. Then launches **`rumble`** → creates a virtual uinput device, forwards all input events, and intercepts `EV_FF` rumble commands to translate them into HID haptic reports + +--- + +## Building + +### Prerequisites + +| Target | Tools | +|--------|-------| +| Tests (native) | CMake, Ninja, Clang, Catch2 (fetched automatically) | +| Android binaries | Android NDK r26+ | + +### Run tests + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build --target procon2droid_tests +ctest --test-dir build --output-on-failure +``` + +### Cross-compile for Android + +Requires the [Android NDK](https://developer.android.com/ndk/downloads). Set the `ANDROID_NDK_HOME` environment variable to the NDK root, then use the preset: + +```bash +# Windows (PowerShell) +$env:ANDROID_NDK_HOME = "C:\path\to\android-ndk" +cmake --preset=android-arm64 +cmake --build --preset=android-arm64 + +# Linux/macOS +export ANDROID_NDK_HOME=/path/to/android-ndk +cmake --preset=android-arm64 +cmake --build --preset=android-arm64 +``` + +For `armeabi-v7a`, use the `android-armv7` preset instead. + +Or manually: + +```bash +cmake -S . -B build_android -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a \ + -DANDROID_PLATFORM=android-24 \ + -DPROCON2DROID_BUILD_TESTS=OFF + +cmake --build build_android --target enable rumble +``` + +## Credits + +- Init sequence derived from [HandHeldLegend's procon2tool](https://handheldlegend.github.io/procon2tool/) + +--- + +## License + +MIT diff --git a/module.prop b/module.prop.in similarity index 69% rename from module.prop rename to module.prop.in index 87161d6..e88015b 100644 --- a/module.prop +++ b/module.prop.in @@ -1,6 +1,6 @@ id=procon2droid name=Switch 2 Pro Controller Support -version=v1.0 -versionCode=1 +version=v@PROJECT_VERSION@ +versionCode=@MODULE_VERSION_CODE@ author=hapqe description=Enables the Switch 2 Pro Controller and forwards rumble data diff --git a/service.sh b/service.sh index be62c42..5b1cd30 100755 --- a/service.sh +++ b/service.sh @@ -1,32 +1,80 @@ #!/system/bin/sh -# wait for the boot -while [ "$(getprop sys.boot_completed)" != "1" ]; do - sleep 5 -done +# Magisk late_start service: monitors for Switch 2 Pro Controller (057e:2069) +# and runs enable (one-shot init) then rumble (persistent proxy). -MODDIR="/data/adb/modules/procon2droid" +MODDIR="${MODDIR:-/data/adb/modules/procon2droid}" ENABLE_BIN="$MODDIR/system/bin/enable" RUMBLE_BIN="$MODDIR/system/bin/rumble" +PIDFILE="$MODDIR/rumble.pid" +LOGFILE="$MODDIR/service.log" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE" +} + +# Sanity checks +if [ ! -x "$ENABLE_BIN" ]; then + log "ERROR: enable binary not found or not executable: $ENABLE_BIN" + exit 1 +fi -# make binaries executable +if [ ! -x "$RUMBLE_BIN" ]; then + log "ERROR: rumble binary not found or not executable: $RUMBLE_BIN" + exit 1 +fi + +# Ensure binaries are executable (in case module was pushed manually) chmod +x "$ENABLE_BIN" "$RUMBLE_BIN" +# Clean up rumble if this script is killed +cleanup() { + if [ -f "$PIDFILE" ]; then + PID=$(cat "$PIDFILE") + if kill -0 "$PID" 2>/dev/null; then + log "Cleanup: stopping rumble ($PID)" + kill "$PID" + fi + rm -f "$PIDFILE" + fi +} +trap cleanup TERM INT EXIT + +log "Service started. Monitoring for 057e:2069..." + while true; do - if lsusb | grep -q "057e:2069"; then - - # no rumble, controller was plugged in - if ! pgrep -f "$RUMBLE_BIN" > /dev/null; then - echo "Pro Controller Detected: Enabling features..." - "$ENABLE_BIN" - "$RUMBLE_BIN" & - echo -1000 > /proc/$!/oom_score_adj + CONTROLLER_PRESENT=false + if command -v lsusb >/dev/null 2>&1; then + if lsusb | grep -q "057e:2069"; then + CONTROLLER_PRESENT=true + fi + fi + + if [ "$CONTROLLER_PRESENT" = true ]; then + if [ ! -f "$PIDFILE" ] || ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + log "Controller detected. Running enable + rumble..." + + "$ENABLE_BIN" >> "$LOGFILE" 2>&1 + ENABLE_STATUS=$? + + if [ $ENABLE_STATUS -ne 0 ]; then + log "WARNING: enable exited with status $ENABLE_STATUS" + fi + + "$RUMBLE_BIN" >> "$LOGFILE" 2>&1 & + RUMBLE_PID=$! + echo "$RUMBLE_PID" > "$PIDFILE" + echo -1000 > /proc/$RUMBLE_PID/oom_score_adj + log "Rumble started (pid=$RUMBLE_PID)" fi else - # cleanup - if pgrep -f "$RUMBLE_BIN" > /dev/null; then - echo "Pro Controller Removed: Cleaning up..." - pkill -f "$RUMBLE_BIN" + if [ -f "$PIDFILE" ]; then + PID=$(cat "$PIDFILE") + if kill -0 "$PID" 2>/dev/null; then + log "Controller removed. Stopping rumble (pid=$PID)..." + kill "$PID" + fi + rm -f "$PIDFILE" fi fi diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..8052055 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,64 @@ +add_library(procon2droid_common STATIC + common/device_finder.cpp + common/haptic_report.cpp +) + +target_include_directories(procon2droid_common PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(procon2droid_common PUBLIC + spdlog::spdlog +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + target_compile_options(procon2droid_common PRIVATE /W4 /WX) +else() + target_compile_options(procon2droid_common PRIVATE -Wall -Wextra -Wpedantic -Werror) +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Android") + target_link_options(procon2droid_common PUBLIC -static-libstdc++) +endif() + +# Main binaries are Linux/Android only (require kernel headers) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR CMAKE_SYSTEM_NAME STREQUAL "Android") + add_executable(enable + enable/main.cpp + ) + + target_link_libraries(enable PRIVATE procon2droid_common) + + target_compile_options(enable PRIVATE + -Wall -Wextra -Wpedantic -Werror + ) + + if(CMAKE_SYSTEM_NAME STREQUAL "Android") + set_target_properties(enable PROPERTIES + LINK_FLAGS "-static-libstdc++" + ) + add_custom_command(TARGET enable POST_BUILD + COMMAND ${CMAKE_STRIP} --strip-all $ + ) + endif() + + add_executable(rumble + rumble/main.cpp + rumble/uinput_proxy.cpp + ) + + target_link_libraries(rumble PRIVATE procon2droid_common) + + target_compile_options(rumble PRIVATE + -Wall -Wextra -Wpedantic -Werror + ) + + if(CMAKE_SYSTEM_NAME STREQUAL "Android") + set_target_properties(rumble PROPERTIES + LINK_FLAGS "-static-libstdc++" + ) + add_custom_command(TARGET rumble POST_BUILD + COMMAND ${CMAKE_STRIP} --strip-all $ + ) + endif() +endif() diff --git a/src/common/device_finder.cpp b/src/common/device_finder.cpp new file mode 100644 index 0000000..6a81a4a --- /dev/null +++ b/src/common/device_finder.cpp @@ -0,0 +1,179 @@ +#include "device_finder.hpp" + +#include +#include +#include +#include +#include + +namespace procon2droid +{ + +namespace +{ + +bool matches_target_vidpid(std::string_view vid, std::string_view pid) +{ + return vid == kTargetVid && pid == kTargetPid; +} + +using DirPtr = std::unique_ptr; + +inline constexpr std::string_view kLogTag = "device_finder"; +inline constexpr std::string_view kEventPrefix = "event"; +inline constexpr std::string_view kHidrawPrefix = "hidraw"; +inline constexpr std::string_view kHidIdKey = "HID_ID="; + +} // namespace + +std::optional find_usb_device() +{ + DIR* dp = opendir("/sys/bus/usb/devices"); + if (!dp) + { + spdlog::error("{}: cannot open /sys/bus/usb/devices", kLogTag); + return std::nullopt; + } + + DirPtr dp_guard{dp, closedir}; + const struct dirent* entry = nullptr; + + while ((entry = readdir(dp_guard.get()))) + { + if (entry->d_name[0] == '.') + { + continue; + } + + const std::string dir = std::string("/sys/bus/usb/devices/") + entry->d_name; + std::ifstream vid_file(dir + "/idVendor"); + std::ifstream pid_file(dir + "/idProduct"); + std::string vid; + std::string pid; + + if (!(vid_file >> vid && pid_file >> pid)) + { + continue; + } + if (!matches_target_vidpid(vid, pid)) + { + continue; + } + + std::ifstream bus_file(dir + "/busnum"); + std::ifstream dev_file(dir + "/devnum"); + int busnum = 0; + int devnum = 0; + + if (bus_file >> busnum && dev_file >> devnum) + { + char path[64]; + snprintf(path, sizeof(path), "/dev/bus/usb/%03d/%03d", busnum, devnum); + spdlog::info("{}: found USB device at {}", kLogTag, path); + return std::string(path); + } + } + + return std::nullopt; +} + +std::optional find_input_event() +{ + DIR* dir = opendir("/sys/class/input/"); + if (!dir) + { + spdlog::error("{}: cannot open /sys/class/input/", kLogTag); + return std::nullopt; + } + + DirPtr dir_guard{dir, closedir}; + const struct dirent* entry = nullptr; + + while ((entry = readdir(dir_guard.get())) != nullptr) + { + const std::string name = entry->d_name; + if (!name.starts_with(kEventPrefix)) + { + continue; + } + + const std::string base = "/sys/class/input/" + name + "/device/id/"; + std::ifstream vid_file(base + "vendor"); + std::ifstream pid_file(base + "product"); + std::string vid_str; + std::string pid_str; + + if (!(vid_file >> vid_str && pid_file >> pid_str)) + { + continue; + } + + try + { + const uint32_t vid = std::stoul(vid_str, nullptr, 16); + const uint32_t pid = std::stoul(pid_str, nullptr, 16); + + if (vid == kTargetVidHex && pid == kTargetPidHex) + { + const std::string path = "/dev/input/" + name; + spdlog::info("{}: found input at {}", kLogTag, path); + return path; + } + } + catch (...) + { + continue; + } + } + + return std::nullopt; +} + +std::optional find_hidraw_device() +{ + DIR* dir = opendir("/sys/class/hidraw/"); + if (!dir) + { + spdlog::error("{}: cannot open /sys/class/hidraw/", kLogTag); + return std::nullopt; + } + + DirPtr dir_guard{dir, closedir}; + const struct dirent* entry = nullptr; + + while ((entry = readdir(dir_guard.get())) != nullptr) + { + const std::string name = entry->d_name; + if (!name.starts_with(kHidrawPrefix)) + { + continue; + } + + std::ifstream file("/sys/class/hidraw/" + name + "/device/uevent"); + std::string line; + while (std::getline(file, line)) + { + if (line.find(kHidIdKey) == std::string::npos) + { + continue; + } + + // HID_ID uevent lines may use uppercase hex; do case-insensitive check. + std::string upper_line = line; + std::transform(upper_line.begin(), upper_line.end(), upper_line.begin(), ::toupper); + const bool vid_match = upper_line.find(kTargetVid) != std::string::npos; + const bool pid_match = upper_line.find(kTargetPid) != std::string::npos; + + if (vid_match && pid_match) + { + const std::string path = "/dev/" + name; + spdlog::info("device_finder: found hidraw at {}", path); + return path; + } + } + } + + return std::nullopt; +} + +} // namespace procon2droid diff --git a/src/common/device_finder.hpp b/src/common/device_finder.hpp new file mode 100644 index 0000000..e71225e --- /dev/null +++ b/src/common/device_finder.hpp @@ -0,0 +1,24 @@ +// Scans sysfs for the Nintendo Switch 2 Pro Controller (VID 057e / PID 2069) +// and returns device paths for USB, input event, and hidraw nodes. +#pragma once + +#include +#include +#include +#include + +namespace procon2droid +{ + +inline constexpr std::string_view kTargetVid = "057e"; +inline constexpr std::string_view kTargetPid = "2069"; +inline constexpr uint32_t kTargetVidHex = 0x057E; +inline constexpr uint32_t kTargetPidHex = 0x2069; + +std::optional find_usb_device(); + +std::optional find_input_event(); + +std::optional find_hidraw_device(); + +} // namespace procon2droid diff --git a/src/common/haptic_report.cpp b/src/common/haptic_report.cpp new file mode 100644 index 0000000..b2b1d79 --- /dev/null +++ b/src/common/haptic_report.cpp @@ -0,0 +1,48 @@ +#include "haptic_report.hpp" + +#include + +namespace procon2droid +{ + +[[nodiscard]] std::array +build_haptic_report(uint16_t strong_magnitude, uint16_t weak_magnitude, uint8_t counter, bool active) +{ + std::array report{}; + report[0] = kHapticCommandId; + + const uint8_t seq_byte = 0x50 | (counter & 0x0F); + report[1] = seq_byte; + report[17] = seq_byte; + + auto l_pattern = kHapticPatternOff; + auto r_pattern = kHapticPatternOff; + + if (active) + { + if (strong_magnitude > 32768) + { + l_pattern = kHapticPatternStrong; + } + else if (strong_magnitude > 0) + { + l_pattern = kHapticPatternWeak; + } + + if (weak_magnitude > 32768) + { + r_pattern = kHapticPatternStrong; + } + else if (weak_magnitude > 0) + { + r_pattern = kHapticPatternWeak; + } + } + + std::copy(l_pattern.begin(), l_pattern.end(), report.begin() + 2); + std::copy(r_pattern.begin(), r_pattern.end(), report.begin() + 18); + + return report; +} + +} // namespace procon2droid diff --git a/src/common/haptic_report.hpp b/src/common/haptic_report.hpp new file mode 100644 index 0000000..cbbb7a1 --- /dev/null +++ b/src/common/haptic_report.hpp @@ -0,0 +1,21 @@ +// Builds the 64-byte HID haptic report from FF_RUMBLE magnitudes. +#pragma once + +#include +#include +#include + +namespace procon2droid +{ + +inline constexpr size_t kHapticReportSize = 64; +inline constexpr uint8_t kHapticCommandId = 0x02; + +inline constexpr std::array kHapticPatternStrong{0x93, 0x35, 0x36, 0x1c, 0x0d}; +inline constexpr std::array kHapticPatternWeak{0x4b, 0x7d, 0x80, 0x5a, 0x02}; +inline constexpr std::array kHapticPatternOff{0x00, 0x00, 0x00, 0x00, 0x00}; + +[[nodiscard]] std::array +build_haptic_report(uint16_t strong_magnitude, uint16_t weak_magnitude, uint8_t counter, bool active); + +} // namespace procon2droid diff --git a/src/common/init_sequence.hpp b/src/common/init_sequence.hpp new file mode 100644 index 0000000..3fb78c1 --- /dev/null +++ b/src/common/init_sequence.hpp @@ -0,0 +1,81 @@ +// constexpr init packet data for waking the Switch 2 Pro Controller over USB. +// Derived from HandHeldLegend's procon2tool. +#pragma once + +#include +#include +#include +#include + +namespace procon2droid +{ + +struct InitPacket +{ + std::span data; +}; + +constexpr std::array kCmd03{0x03, 0x91, 0x00, 0x0d, 0x00, 0x08, 0x00, 0x00, + 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +constexpr std::array kCmd07{0x07, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd16{0x16, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmdMac{0x15, 0x91, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x02, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +constexpr std::array kCmdLtk{0x15, 0x91, 0x00, 0x02, 0x00, 0x11, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +constexpr std::array kCmd15_03{0x15, 0x91, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd09{0x09, 0x91, 0x00, 0x07, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmdImu02{0x0c, 0x91, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd11{0x11, 0x91, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd0a{0x0a, 0x91, 0x00, 0x08, 0x00, 0x14, 0x00, 0x00, 0x01, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x35, 0x00, 0x46, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmdImu04{0x0c, 0x91, 0x00, 0x04, 0x00, 0x04, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00}; + +constexpr std::array kCmdHaptics{0x03, 0x91, 0x00, 0x0a, 0x00, 0x04, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd10{0x10, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd01{0x01, 0x91, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd03Alt{0x03, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00}; + +constexpr std::array kCmd0aAlt{0x0a, 0x91, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x00}; + +constexpr std::array kCmdLed{0x09, 0x91, 0x00, 0x07, 0x00, 0x08, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +inline constexpr std::array kInitSequence{{ + {kCmd03}, + {kCmd07}, + {kCmd16}, + {kCmdMac}, + {kCmdLtk}, + {kCmd15_03}, + {kCmd09}, + {kCmdImu02}, + {kCmd11}, + {kCmd0a}, + {kCmdImu04}, + {kCmdHaptics}, + {kCmd10}, + {kCmd01}, + {kCmd03Alt}, + {kCmd0aAlt}, + {kCmdLed}, +}}; + +inline constexpr size_t kInitSequenceLength = kInitSequence.size(); + +} // namespace procon2droid diff --git a/src/enable.cpp b/src/enable.cpp deleted file mode 100644 index ec54885..0000000 --- a/src/enable.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -// --- initialization commands as found on https://github.com/HandHeldLegend/handheldlegend.github.io/blob/master/procon2tool/index.html --- -const uint8_t CMD_03[] = {0x03, 0x91, 0x00, 0x0d, 0x00, 0x08, 0x00, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; -const uint8_t CMD_07[] = {0x07, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_16[] = {0x16, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_MAC[] = {0x15, 0x91, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; -const uint8_t CMD_LTK[] = {0x15, 0x91, 0x00, 0x02, 0x00, 0x11, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; -const uint8_t CMD_15_03[] = {0x15, 0x91, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00}; -const uint8_t CMD_09[] = {0x09, 0x91, 0x00, 0x07, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_IMU_02[] = {0x0c, 0x91, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00}; -const uint8_t CMD_11[] = {0x11, 0x91, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_0A[] = {0x0a, 0x91, 0x00, 0x08, 0x00, 0x14, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x35, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_IMU_04[] = {0x0c, 0x91, 0x00, 0x04, 0x00, 0x04, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00}; -const uint8_t CMD_HAPTICS[] = {0x03, 0x91, 0x00, 0x0a, 0x00, 0x04, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00}; -const uint8_t CMD_10[] = {0x10, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_01[] = {0x01, 0x91, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00}; -const uint8_t CMD_03_ALT[] = {0x03, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00}; -const uint8_t CMD_0A_ALT[] = {0x0a, 0x91, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x00}; -const uint8_t CMD_LED[] = {0x09, 0x91, 0x00, 0x07, 0x00, 0x08, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - -struct InitPacket { const uint8_t* data; size_t len; }; -InitPacket init_sequence[] = { - {CMD_03, sizeof(CMD_03)}, {CMD_07, sizeof(CMD_07)}, {CMD_16, sizeof(CMD_16)}, - {CMD_MAC, sizeof(CMD_MAC)}, {CMD_LTK, sizeof(CMD_LTK)}, {CMD_15_03, sizeof(CMD_15_03)}, - {CMD_09, sizeof(CMD_09)}, {CMD_IMU_02, sizeof(CMD_IMU_02)}, {CMD_11, sizeof(CMD_11)}, - {CMD_0A, sizeof(CMD_0A)}, {CMD_IMU_04, sizeof(CMD_IMU_04)}, {CMD_HAPTICS, sizeof(CMD_HAPTICS)}, - {CMD_10, sizeof(CMD_10)}, {CMD_01, sizeof(CMD_01)}, {CMD_03_ALT, sizeof(CMD_03_ALT)}, - {CMD_0A_ALT, sizeof(CMD_0A_ALT)}, {CMD_LED, sizeof(CMD_LED)} -}; - -std::string find_raw_usb_device() { - DIR *dp = opendir("/sys/bus/usb/devices"); - if (!dp) return ""; - struct dirent *entry; - while ((entry = readdir(dp))) { - if (entry->d_name[0] == '.') continue; - std::string dir = std::string("/sys/bus/usb/devices/") + entry->d_name; - std::ifstream vid_file(dir + "/idVendor"), pid_file(dir + "/idProduct"); - std::string vid, pid; - if (vid_file >> vid && pid_file >> pid && vid == "057e" && pid == "2069") { // Switch 2 Pro Controller - std::ifstream bus_file(dir + "/busnum"), dev_file(dir + "/devnum"); - int busnum, devnum; - if (bus_file >> busnum && dev_file >> devnum) { - char path[64]; - snprintf(path, sizeof(path), "/dev/bus/usb/%03d/%03d", busnum, devnum); - closedir(dp); - return std::string(path); - } - } - } - closedir(dp); - return ""; -} - -int main() { - std::cout << "Locating the Controller" << std::endl; - - std::string usbPath = find_raw_usb_device(); - if (usbPath.empty()) { - std::cerr << "Error: Controller not found. Is it plugged in?" << std::endl; - return 1; - } - - int fd = open(usbPath.c_str(), O_RDWR); - if (fd < 0) { - std::cerr << "Error: Could not open USB device. Are you running as root/su?" << std::endl; - return 1; - } - - // Detach kernel driver momentarily from Interface 1 - struct usbdevfs_ioctl disconnect_cmd = {1, USBDEVFS_DISCONNECT, NULL}; - ioctl(fd, USBDEVFS_IOCTL, &disconnect_cmd); - - int iface = 1; - if (ioctl(fd, USBDEVFS_CLAIMINTERFACE, &iface) < 0) { - std::cerr << "Error: Failed to claim USB Interface 1." << std::endl; - close(fd); - return 1; - } - - std::cout << "Waking up controller..." << std::endl; - - // Discover correct Bulk OUT endpoint silently - int target_ep = -1; - for (int test_ep = 1; test_ep <= 5; test_ep++) { - struct usbdevfs_bulktransfer bulk_test = {(unsigned int)test_ep, sizeof(CMD_03), 1000, (void*)CMD_03}; - if (ioctl(fd, USBDEVFS_BULK, &bulk_test) >= 0) { - target_ep = test_ep; - break; - } - } - - if (target_ep == -1) { - std::cerr << "Error: Could not find valid bulk endpoint." << std::endl; - close(fd); - return 1; - } - - // Send the rest of the command sequence - for (size_t i = 1; i < sizeof(init_sequence) / sizeof(init_sequence[0]); i++) { - struct usbdevfs_bulktransfer bulk = {(unsigned int)target_ep, (unsigned int)init_sequence[i].len, 1000, (void*)init_sequence[i].data}; - ioctl(fd, USBDEVFS_BULK, &bulk); - usleep(10000); // 10ms wait - - // Dummy read to clear potential ACKs - unsigned char dummy[32]; - struct usbdevfs_bulktransfer bulk_in = {(unsigned int)(target_ep | 0x80), 32, 20, dummy}; - ioctl(fd, USBDEVFS_BULK, &bulk_in); - } - - // Clean up and release control back to Android OS - ioctl(fd, USBDEVFS_RELEASEINTERFACE, &iface); - close(fd); - - std::cout << "Success! Controller is awake. Handing control back to Android." << std::endl; - return 0; -} diff --git a/src/enable/main.cpp b/src/enable/main.cpp new file mode 100644 index 0000000..d5cc065 --- /dev/null +++ b/src/enable/main.cpp @@ -0,0 +1,115 @@ +// One-shot USB init binary: claims interface 1, sends the 17-packet init +// sequence, then releases the interface so Android HID can take over. +#include "../common/device_finder.hpp" +#include "../common/init_sequence.hpp" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +int find_bulk_out_endpoint(int fd, const uint8_t* probe_data, size_t probe_len) +{ + for (int ep = 1; ep <= 5; ++ep) + { + struct usbdevfs_bulktransfer bt{}; + bt.ep = static_cast(ep); + bt.len = static_cast(probe_len); + bt.timeout = 1000; + bt.data = const_cast(probe_data); // NOLINT(cppcoreguidelines-pro-type-const-cast) + + if (ioctl(fd, USBDEVFS_BULK, &bt) >= 0) + { + return ep; + } + } + return -1; +} + +void send_init_sequence(int fd, int bulk_out_ep) +{ + const unsigned int in_ep = static_cast(bulk_out_ep) | 0x80; + + for (size_t i = 1; i < procon2droid::kInitSequenceLength; ++i) + { + const auto& pkt = procon2droid::kInitSequence[i]; + + struct usbdevfs_bulktransfer bulk_out{}; + bulk_out.ep = static_cast(bulk_out_ep); + bulk_out.len = static_cast(pkt.data.size()); + bulk_out.timeout = 1000; + bulk_out.data = const_cast(pkt.data.data()); // NOLINT(cppcoreguidelines-pro-type-const-cast) + + ioctl(fd, USBDEVFS_BULK, &bulk_out); + usleep(10000); + + unsigned char dummy[32]{}; + struct usbdevfs_bulktransfer bulk_in{}; + bulk_in.ep = in_ep; + bulk_in.len = sizeof(dummy); + bulk_in.timeout = 20; + bulk_in.data = dummy; + + ioctl(fd, USBDEVFS_BULK, &bulk_in); + } +} + +} // namespace + +int main() +{ + using namespace procon2droid; + + spdlog::info("Locating the Controller"); + + auto usb_path = find_usb_device(); + if (!usb_path) + { + spdlog::error("Controller not found. Is it plugged in?"); + return EXIT_FAILURE; + } + + const int fd = open(usb_path->c_str(), O_RDWR); + if (fd < 0) + { + spdlog::error("Could not open USB device. Are you running as root/su?"); + return EXIT_FAILURE; + } + + struct usbdevfs_ioctl disconnect_cmd{}; + disconnect_cmd.ifno = 1; + disconnect_cmd.ioctl_code = USBDEVFS_DISCONNECT; + ioctl(fd, USBDEVFS_IOCTL, &disconnect_cmd); + + int iface = 1; + if (ioctl(fd, USBDEVFS_CLAIMINTERFACE, &iface) < 0) + { + spdlog::error("Failed to claim USB Interface 1"); + close(fd); + return EXIT_FAILURE; + } + + spdlog::info("Waking up controller..."); + + const int bulk_ep = find_bulk_out_endpoint(fd, kInitSequence[0].data.data(), kInitSequence[0].data.size()); + if (bulk_ep < 0) + { + spdlog::error("Could not find valid bulk endpoint"); + ioctl(fd, USBDEVFS_RELEASEINTERFACE, &iface); + close(fd); + return EXIT_FAILURE; + } + + send_init_sequence(fd, bulk_ep); + + ioctl(fd, USBDEVFS_RELEASEINTERFACE, &iface); + close(fd); + + spdlog::info("Success! Controller is awake. Handing control back to Android."); + return EXIT_SUCCESS; +} diff --git a/src/rumble.cpp b/src/rumble.cpp deleted file mode 100644 index db15cee..0000000 --- a/src/rumble.cpp +++ /dev/null @@ -1,238 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// haptic patterns -const uint8_t HAPTIC_PATTERN_STRONG[5] = {0x93, 0x35, 0x36, 0x1c, 0x0d}; -const uint8_t HAPTIC_PATTERN_WEAK[5] = {0x4b, 0x7d, 0x80, 0x5a, 0x02}; -const uint8_t HAPTIC_PATTERN_OFF[5] = {0x00, 0x00, 0x00, 0x00, 0x00}; - -// Use hex literals for easier reading -const uint32_t TARGET_VID = 0x057e; -const uint32_t TARGET_PID = 0x2069; - -std::string find_nintendo_event() { - DIR* dir = opendir("/sys/class/input/"); - if (!dir) return ""; - struct dirent* entry; - - while ((entry = readdir(dir)) != nullptr) { - std::string name = entry->d_name; - if (name.find("event") == 0) { - std::string base_path = "/sys/class/input/" + name + "/device/id/"; - std::ifstream vid_file(base_path + "vendor"); - std::ifstream pid_file(base_path + "product"); - - std::string vid_str, pid_str; - if (vid_file >> vid_str && pid_file >> pid_str) { - try { - uint32_t vid = std::stoul(vid_str, nullptr, 16); - uint32_t pid = std::stoul(pid_str, nullptr, 16); - - if (vid == TARGET_VID && pid == TARGET_PID) { - closedir(dir); - return "/dev/input/" + name; - } - } catch (...) { continue; } - } - } - } - closedir(dir); - return ""; -} - -std::string find_nintendo_hidraw() { - DIR* dir = opendir("/sys/class/hidraw/"); - if (!dir) return ""; - struct dirent* entry; - - while ((entry = readdir(dir)) != nullptr) { - std::string name = entry->d_name; - if (name.find("hidraw") == 0) { - std::ifstream file("/sys/class/hidraw/" + name + "/device/uevent"); - std::string line; - while (std::getline(file, line)) { - // HID_ID lines look like: HID_ID=0003:0000057E:00002069 - if (line.find("HID_ID=") != std::string::npos) { - if ((line.find("057E") != std::string::npos || line.find("057e") != std::string::npos) && - (line.find("2069") != std::string::npos)) { - closedir(dir); - return "/dev/" + name; - } - } - } - } - } - closedir(dir); - return ""; -} - -int main() { - std::cout << "Searching for Nintendo Switch 2 Pro Controller..." << std::endl; - - std::string event_path = find_nintendo_event(); - std::string hidraw_path = find_nintendo_hidraw(); - - if (event_path.empty() || hidraw_path.empty()) { - std::cerr << "Error: Could not find the controller automatically." << std::endl; - std::cerr << "Is it plugged in/connected? Are you running as root/su?" << std::endl; - return 1; - } - - std::cout << "Found Input Node: " << event_path << std::endl; - std::cout << "Found Hidraw Node: " << hidraw_path << std::endl; - - int event_fd = open(event_path.c_str(), O_RDONLY | O_NONBLOCK); - int hid_fd = open(hidraw_path.c_str(), O_WRONLY | O_NONBLOCK); - int u_fd = open("/dev/uinput", O_RDWR | O_NONBLOCK); - - if (event_fd < 0 || hid_fd < 0 || u_fd < 0) { - std::cerr << "Failed to open device nodes. Need root." << std::endl; - return 1; - } - - if (ioctl(event_fd, EVIOCGRAB, 1) < 0) { - std::cerr << "Warning: Could not grab exclusive access to original device." << std::endl; - } - - struct input_id original_id; - ioctl(event_fd, EVIOCGID, &original_id); - char dev_name[256] = {0}; - ioctl(event_fd, EVIOCGNAME(sizeof(dev_name)), dev_name); - - ioctl(u_fd, UI_SET_EVBIT, EV_SYN); - ioctl(u_fd, UI_SET_EVBIT, EV_KEY); - ioctl(u_fd, UI_SET_EVBIT, EV_ABS); - - // clone all advertised keys/buttons - uint8_t key_bits[KEY_MAX / 8 + 1] = {0}; - ioctl(event_fd, EVIOCGBIT(EV_KEY, sizeof(key_bits)), key_bits); - for (int i = 0; i < KEY_MAX; i++) { - if (key_bits[i / 8] & (1 << (i % 8))) { - ioctl(u_fd, UI_SET_KEYBIT, i); - } - } - - // clone all advertised absolute axes - uint8_t abs_bits[ABS_MAX / 8 + 1] = {0}; - ioctl(event_fd, EVIOCGBIT(EV_ABS, sizeof(abs_bits)), abs_bits); - for (int i = 0; i < ABS_MAX; i++) { - if (abs_bits[i / 8] & (1 << (i % 8))) { - ioctl(u_fd, UI_SET_ABSBIT, i); - struct input_absinfo absinfo; - if (ioctl(event_fd, EVIOCGABS(i), &absinfo) >= 0) { - struct uinput_abs_setup abs_setup = {0}; - abs_setup.code = i; - abs_setup.absinfo = absinfo; - ioctl(u_fd, UI_ABS_SETUP, &abs_setup); - } - } - } - - // inject force feedback capability - ioctl(u_fd, UI_SET_EVBIT, EV_FF); - ioctl(u_fd, UI_SET_FFBIT, FF_RUMBLE); - - struct uinput_setup usetup = {0}; - usetup.id = original_id; - usetup.ff_effects_max = 64; - snprintf(usetup.name, sizeof(usetup.name), "%s with Rumble", dev_name); - - ioctl(u_fd, UI_DEV_SETUP, &usetup); - ioctl(u_fd, UI_DEV_CREATE); - - struct pollfd fds[2]; - fds[0].fd = event_fd; - fds[0].events = POLLIN; - fds[1].fd = u_fd; - fds[1].events = POLLIN; - - struct input_event ev; - std::map rumble_effects; - uint8_t haptic_counter = 0; - - std::cout << "Proxying inputs and listening for rumble..." << std::endl; - - while (poll(fds, 2, -1) > 0) { - // input forwaring - if (fds[0].revents & POLLIN) { - while (read(event_fd, &ev, sizeof(ev)) > 0) { - write(u_fd, &ev, sizeof(ev)); // dump exactly what was read - } - } - - // rumble interception and translation - if (fds[1].revents & POLLIN) { - while (read(u_fd, &ev, sizeof(ev)) > 0) { - if (ev.type == EV_UINPUT) { - if (ev.code == UI_FF_UPLOAD) { - struct uinput_ff_upload upload; - memset(&upload, 0, sizeof(upload)); - upload.request_id = ev.value; - if (ioctl(u_fd, UI_BEGIN_FF_UPLOAD, &upload) == 0) { - rumble_effects[upload.effect.id] = upload.effect; - upload.retval = 0; - ioctl(u_fd, UI_END_FF_UPLOAD, &upload); - } - } else if (ev.code == UI_FF_ERASE) { - struct uinput_ff_erase erase; - memset(&erase, 0, sizeof(erase)); - erase.request_id = ev.value; - if (ioctl(u_fd, UI_BEGIN_FF_ERASE, &erase) == 0) { - rumble_effects.erase(erase.effect_id); - erase.retval = 0; - ioctl(u_fd, UI_END_FF_ERASE, &erase); - } - } - } - else if (ev.type == EV_FF) { - int effect_id = ev.code; - int play_count = ev.value; - - unsigned char r[64] = {0}; - r[0] = 0x02; // restored your original command ID - r[1] = 0x50 | (haptic_counter & 0x0F); - r[17] = r[1]; // restored your original mirror byte - - const uint8_t* l_pattern = HAPTIC_PATTERN_OFF; - const uint8_t* r_pattern = HAPTIC_PATTERN_OFF; - - if (play_count > 0 && rumble_effects.count(effect_id)) { - uint16_t strong = rumble_effects[effect_id].u.rumble.strong_magnitude; - uint16_t weak = rumble_effects[effect_id].u.rumble.weak_magnitude; - - if (strong > 32768) l_pattern = HAPTIC_PATTERN_STRONG; - else if (strong > 0) l_pattern = HAPTIC_PATTERN_WEAK; - - if (weak > 32768) r_pattern = HAPTIC_PATTERN_STRONG; - else if (weak > 0) r_pattern = HAPTIC_PATTERN_WEAK; - } - - for(int i = 0; i < 5; i++) { - r[2 + i] = l_pattern[i]; - r[18 + i] = r_pattern[i]; // restored your correct memory offset - } - - write(hid_fd, r, 64); - haptic_counter = (haptic_counter + 1) & 0x0F; - } - } - } - } - - ioctl(event_fd, EVIOCGRAB, 0); - ioctl(u_fd, UI_DEV_DESTROY); - close(event_fd); - close(hid_fd); - close(u_fd); - return 0; -} diff --git a/src/rumble/main.cpp b/src/rumble/main.cpp new file mode 100644 index 0000000..9518ad7 --- /dev/null +++ b/src/rumble/main.cpp @@ -0,0 +1,35 @@ +#include "../common/device_finder.hpp" +#include "uinput_proxy.hpp" + +#include +#include + +int main() +{ + using namespace procon2droid; + + spdlog::info("Searching for Nintendo Switch 2 Pro Controller..."); + + const auto event_path = find_input_event(); + const auto hidraw_path = find_hidraw_device(); + + if (!event_path || !hidraw_path) + { + spdlog::error("Could not find the controller automatically."); + spdlog::error("Is it plugged in/connected? Are you running as root/su?"); + return EXIT_FAILURE; + } + + spdlog::info("Found Input Node: {}", *event_path); + spdlog::info("Found Hidraw Node: {}", *hidraw_path); + + UInputProxy proxy(*event_path, *hidraw_path); + + if (!proxy.init()) + { + return EXIT_FAILURE; + } + + proxy.run(); + return EXIT_SUCCESS; +} diff --git a/src/rumble/uinput_proxy.cpp b/src/rumble/uinput_proxy.cpp new file mode 100644 index 0000000..06145a9 --- /dev/null +++ b/src/rumble/uinput_proxy.cpp @@ -0,0 +1,215 @@ +#include "uinput_proxy.hpp" + +#include "../common/haptic_report.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace procon2droid +{ + +UInputProxy::UInputProxy(std::string event_path, std::string hidraw_path) + : event_path_(std::move(event_path)), hidraw_path_(std::move(hidraw_path)) +{ +} + +UInputProxy::~UInputProxy() +{ + if (event_fd_ >= 0) + { + ioctl(event_fd_, EVIOCGRAB, 0); + close(event_fd_); + } + if (uinput_fd_ >= 0) + { + ioctl(uinput_fd_, UI_DEV_DESTROY); + close(uinput_fd_); + } + if (hid_fd_ >= 0) + { + close(hid_fd_); + } +} + +bool UInputProxy::init() +{ + event_fd_ = open(event_path_.c_str(), O_RDONLY | O_NONBLOCK); + hid_fd_ = open(hidraw_path_.c_str(), O_WRONLY | O_NONBLOCK); + uinput_fd_ = open("/dev/uinput", O_RDWR | O_NONBLOCK); + + if (event_fd_ < 0 || hid_fd_ < 0 || uinput_fd_ < 0) + { + spdlog::error("Failed to open device nodes. Need root."); + return false; + } + + if (ioctl(event_fd_, EVIOCGRAB, 1) < 0) + { + spdlog::warn("Could not grab exclusive access to original device."); + } + + struct input_id original_id{}; + ioctl(event_fd_, EVIOCGID, &original_id); + char dev_name[256]{}; + ioctl(event_fd_, EVIOCGNAME(sizeof(dev_name)), dev_name); + + ioctl(uinput_fd_, UI_SET_EVBIT, EV_SYN); + ioctl(uinput_fd_, UI_SET_EVBIT, EV_KEY); + ioctl(uinput_fd_, UI_SET_EVBIT, EV_ABS); + + clone_device_capabilities(); + + ioctl(uinput_fd_, UI_SET_EVBIT, EV_FF); + ioctl(uinput_fd_, UI_SET_FFBIT, FF_RUMBLE); + + struct uinput_setup usetup{}; + usetup.id = original_id; + usetup.ff_effects_max = 64; + snprintf(usetup.name, sizeof(usetup.name), "%s with Rumble", dev_name); + + ioctl(uinput_fd_, UI_DEV_SETUP, &usetup); + ioctl(uinput_fd_, UI_DEV_CREATE); + + return true; +} + +void UInputProxy::clone_device_capabilities() +{ + uint8_t key_bits[KEY_MAX / 8 + 1]{}; + ioctl(event_fd_, EVIOCGBIT(EV_KEY, sizeof(key_bits)), key_bits); + for (int i = 0; i < KEY_MAX; ++i) + { + if (key_bits[i / 8] & (1 << (i % 8))) + { + ioctl(uinput_fd_, UI_SET_KEYBIT, i); + } + } + + uint8_t abs_bits[ABS_MAX / 8 + 1]{}; + ioctl(event_fd_, EVIOCGBIT(EV_ABS, sizeof(abs_bits)), abs_bits); + for (int i = 0; i < ABS_MAX; ++i) + { + if (abs_bits[i / 8] & (1 << (i % 8))) + { + ioctl(uinput_fd_, UI_SET_ABSBIT, i); + struct input_absinfo absinfo{}; + if (ioctl(event_fd_, EVIOCGABS(i), &absinfo) >= 0) + { + struct uinput_abs_setup abs_setup{}; + abs_setup.code = i; + abs_setup.absinfo = absinfo; + ioctl(uinput_fd_, UI_ABS_SETUP, &abs_setup); + } + } + } +} + +void UInputProxy::handle_ff_event(const struct input_event& ev) +{ + if (ev.type == EV_UINPUT) + { + if (ev.code == UI_FF_UPLOAD) + { + struct uinput_ff_upload upload{}; + upload.request_id = ev.value; + if (ioctl(uinput_fd_, UI_BEGIN_FF_UPLOAD, &upload) == 0) + { + rumble_effects_[upload.effect.id] = upload.effect; + upload.retval = 0; + ioctl(uinput_fd_, UI_END_FF_UPLOAD, &upload); + } + } + else if (ev.code == UI_FF_ERASE) + { + struct uinput_ff_erase erase{}; + erase.request_id = ev.value; + if (ioctl(uinput_fd_, UI_BEGIN_FF_ERASE, &erase) == 0) + { + rumble_effects_.erase(erase.effect_id); + erase.retval = 0; + ioctl(uinput_fd_, UI_END_FF_ERASE, &erase); + } + } + } + else if (ev.type == EV_FF) + { + const int effect_id = ev.code; + const int play_count = ev.value; + + const bool active = (play_count > 0); + uint16_t strong_mag = 0; + uint16_t weak_mag = 0; + + if (active) + { + auto it = rumble_effects_.find(effect_id); + if (it != rumble_effects_.end()) + { + strong_mag = it->second.u.rumble.strong_magnitude; + weak_mag = it->second.u.rumble.weak_magnitude; + } + } + + static uint8_t haptic_counter = 0; + const auto report = build_haptic_report(strong_mag, weak_mag, haptic_counter, active); + haptic_counter = (haptic_counter + 1) & 0x0F; + + if (write(hid_fd_, report.data(), report.size()) < 0) + { + spdlog::warn("Failed to write haptic report to hidraw: {}", std::strerror(errno)); + } + } +} + +void UInputProxy::run() +{ + struct pollfd fds[2]{}; + fds[0].fd = event_fd_; + fds[0].events = POLLIN; + fds[1].fd = uinput_fd_; + fds[1].events = POLLIN; + + spdlog::info("Proxying inputs and listening for rumble..."); + + struct input_event ev{}; + + while (true) + { + const int ret = poll(fds, 2, -1); + if (ret < 0) + { + if (errno == EINTR) + { + continue; + } + spdlog::error("poll() failed: {}", std::strerror(errno)); + break; + } + + if (fds[0].revents & POLLIN) + { + while (read(event_fd_, &ev, sizeof(ev)) > 0) + { + if (write(uinput_fd_, &ev, sizeof(ev)) < 0) + { + spdlog::warn("Failed to forward input event: {}", std::strerror(errno)); + } + } + } + + if (fds[1].revents & POLLIN) + { + while (read(uinput_fd_, &ev, sizeof(ev)) > 0) + { + handle_ff_event(ev); + } + } + } +} + +} // namespace procon2droid diff --git a/src/rumble/uinput_proxy.hpp b/src/rumble/uinput_proxy.hpp new file mode 100644 index 0000000..6eaddb3 --- /dev/null +++ b/src/rumble/uinput_proxy.hpp @@ -0,0 +1,39 @@ +// Persistent input proxy: clones the controller's input device to a virtual +// uinput node, forwarding events and translating EV_FF rumble into HID haptic +// reports sent to the hidraw device. +#pragma once + +#include +#include +#include +#include +#include + +namespace procon2droid +{ + +class UInputProxy +{ +public: + UInputProxy(std::string event_path, std::string hidraw_path); + ~UInputProxy(); + + UInputProxy(const UInputProxy&) = delete; + UInputProxy& operator=(const UInputProxy&) = delete; + + [[nodiscard]] bool init(); + void run(); + +private: + void clone_device_capabilities(); + void handle_ff_event(const struct input_event& ev); + + std::string event_path_; + std::string hidraw_path_; + int event_fd_ = -1; + int hid_fd_ = -1; + int uinput_fd_ = -1; + std::map rumble_effects_; +}; + +} // namespace procon2droid diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..cb82779 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,32 @@ +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.15.0 +) + +FetchContent_MakeAvailable(Catch2) + +add_executable(procon2droid_tests + test_init_sequence.cpp + test_haptic_report.cpp + ${CMAKE_SOURCE_DIR}/src/common/haptic_report.cpp +) + +target_include_directories(procon2droid_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src +) + +target_link_libraries(procon2droid_tests PRIVATE + Catch2::Catch2WithMain +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + target_compile_options(procon2droid_tests PRIVATE /W4) +else() + target_compile_options(procon2droid_tests PRIVATE -Wall -Wextra -Wpedantic) +endif() + +include(Catch) +catch_discover_tests(procon2droid_tests) diff --git a/tests/test_haptic_report.cpp b/tests/test_haptic_report.cpp new file mode 100644 index 0000000..ad54143 --- /dev/null +++ b/tests/test_haptic_report.cpp @@ -0,0 +1,114 @@ +// Tests for the HD Rumble HID report builder. +#include "common/haptic_report.hpp" + +#include + +using namespace procon2droid; + +TEST_CASE("Report is exactly 64 bytes", "[haptic_report]") +{ + auto report = build_haptic_report(0, 0, 0, false); + REQUIRE(report.size() == kHapticReportSize); +} + +TEST_CASE("Command ID byte is 0x02", "[haptic_report]") +{ + auto report = build_haptic_report(0, 0, 0, false); + REQUIRE(report[0] == kHapticCommandId); +} + +TEST_CASE("Counter byte is embedded at positions 1 and 17", "[haptic_report]") +{ + auto report = build_haptic_report(0, 0, 5, false); + uint8_t expected = 0x50 | 0x05; + REQUIRE(report[1] == expected); + REQUIRE(report[17] == expected); +} + +TEST_CASE("Counter wraps at 4 bits", "[haptic_report]") +{ + auto report15 = build_haptic_report(0, 0, 0x0F, false); + uint8_t expected15 = 0x50 | 0x0F; + REQUIRE(report15[1] == expected15); +} + +TEST_CASE("OFF state produces zero patterns", "[haptic_report]") +{ + auto report = build_haptic_report(0, 0, 0, false); + for (size_t i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == 0x00); + REQUIRE(report[18 + i] == 0x00); + } +} + +TEST_CASE("Active with strong_magnitude 65535 produces strong pattern on left", "[haptic_report]") +{ + auto report = build_haptic_report(65535, 0, 0, true); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternStrong[i]); + REQUIRE(report[18 + i] == kHapticPatternOff[i]); + } +} + +TEST_CASE("Active with weak_magnitude 65535 produces strong pattern on right", "[haptic_report]") +{ + auto report = build_haptic_report(0, 65535, 0, true); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternOff[i]); + REQUIRE(report[18 + i] == kHapticPatternStrong[i]); + } +} + +TEST_CASE("Active with strong_magnitude 32768 produces weak pattern on left", "[haptic_report]") +{ + auto report = build_haptic_report(32768, 0, 0, true); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternWeak[i]); + REQUIRE(report[18 + i] == kHapticPatternOff[i]); + } +} + +TEST_CASE("Active with strong_magnitude 32769 produces strong pattern on left", "[haptic_report]") +{ + auto report = build_haptic_report(32769, 0, 0, true); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternStrong[i]); + REQUIRE(report[18 + i] == kHapticPatternOff[i]); + } +} + +TEST_CASE("Both channels active with strong", "[haptic_report]") +{ + auto report = build_haptic_report(65535, 65535, 0, true); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternStrong[i]); + REQUIRE(report[18 + i] == kHapticPatternStrong[i]); + } +} + +TEST_CASE("Active with magnitudes but missing effect falls back to OFF", "[haptic_report]") +{ + // active=true but missing effect → looks up effect, finds none → OFF + auto report = build_haptic_report(65535, 65535, 0, false); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternOff[i]); + REQUIRE(report[18 + i] == kHapticPatternOff[i]); + } +} + +TEST_CASE("Zero magnitude produces OFF even when active", "[haptic_report]") +{ + auto report = build_haptic_report(0, 0, 0, true); + for (int i = 0; i < 5; ++i) + { + REQUIRE(report[2 + i] == kHapticPatternOff[i]); + REQUIRE(report[18 + i] == kHapticPatternOff[i]); + } +} diff --git a/tests/test_init_sequence.cpp b/tests/test_init_sequence.cpp new file mode 100644 index 0000000..0e6dbce --- /dev/null +++ b/tests/test_init_sequence.cpp @@ -0,0 +1,51 @@ +// Tests for the USB init packet sequence (17 packets). +#include "common/init_sequence.hpp" + +#include + +using namespace procon2droid; + +TEST_CASE("Init sequence has exactly 17 packets", "[init_sequence]") +{ + REQUIRE(kInitSequence.size() == 17); +} + +TEST_CASE("First packet is CMD_03 with correct size", "[init_sequence]") +{ + REQUIRE(kInitSequence[0].data.size() == kCmd03.size()); + REQUIRE(kInitSequence[0].data[0] == 0x03); + REQUIRE(kInitSequence[0].data[1] == 0x91); +} + +TEST_CASE("CMD_LED packet has correct content", "[init_sequence]") +{ + REQUIRE(kInitSequence[16].data.size() == kCmdLed.size()); + REQUIRE(kInitSequence[16].data[0] == 0x09); +} + +TEST_CASE("CMD_HAPTICS packet exists and has correct size", "[init_sequence]") +{ + bool found = false; + for (const auto& pkt : kInitSequence) + { + if (pkt.data.size() == kCmdHaptics.size() && pkt.data[0] == 0x03 && pkt.data[3] == 0x0a) + { + found = true; + break; + } + } + REQUIRE(found); +} + +TEST_CASE("Init sequence total packet count constant matches", "[init_sequence]") +{ + REQUIRE(kInitSequenceLength == 17); +} + +TEST_CASE("All packets have non-zero size", "[init_sequence]") +{ + for (const auto& pkt : kInitSequence) + { + REQUIRE(pkt.data.size() > 0); + } +}