commit 4d15897b87b5c05f76c452f4034b9ad80605508b Author: portersky <24420859+portersky@users.noreply.github.com> Date: Sun Jun 14 19:18:06 2026 +0200 Inital commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c9a7e1a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +[*] +end_of_line = LF +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true + +[*.{yml,json,lua,md,html}] +charset = utf-8 +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07103c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# CMake +build +build-* +cmake-build-* +coverage + +# Editors +.vscode +.idea +.ccls +.ccls-cache +.cache +compile_commands.json + +# macOS +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fa40afd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# AGENTS.md + +## Project Overview + +`stk` is a C23 library for reading EdgeTx radio joystick input via +HID. Wired for TDD using Unity and CMock. + +## Build System + +- **Generator:** Ninja +- **CMake minimum:** 3.21 +- **C standard:** C23 + +### Commands + +Configure (default): +```sh +cmake -S . -B build -G Ninja +``` + +Configure with coverage: +```sh +cmake -S . -B build-cov -G Ninja -DENABLE_COVERAGE=ON +``` + +Build: +```sh +ninja -C build +``` + +Run tests (full Unity output, colored): +```sh +ninja -C build check +``` + +Run tests (CTest summary only): +```sh +ninja -C build test +``` + +Run tests and generate coverage HTML: +```sh +ninja -C build-cov coverage +``` + +Run (Linux/macOS): +```sh +./build/main +``` + +Run (Windows): +```sh +./build/main.exe +``` + +### Dependencies + +Dependencies are managed via custom `Find*.cmake` scripts in `deps/`. +These scripts use `FetchContent` under the hood to download and build +libraries automatically. + +To add a new dependency: + +1. Add the corresponding `Find.cmake` to `deps/` +2. Add `find_package( REQUIRED)` to `CMakeLists.txt` +3. Link with `::` in `target_link_libraries()` + +### CMake Module Path + +`deps/` is added to `CMAKE_MODULE_PATH` so `find_package()` resolves +to the custom scripts instead of system-installed packages. + +## Coding Conventions + +- **Language:** C23 +- **4-space indentation** +- **Naming:** `snake_case` for variables, functions, and types +- **Naming:** `SCREAMING_SNAKE_CASE` only for macros and constants +- `<>` includes only for system headers (std, OS, etc.) +- `""` includes for project headers +- Include order: + 1. C standard library headers (``, ``, etc.) + 2. *(blank line)* + 3. OS-specific headers (Windows API, POSIX, etc.) + 4. *(blank line)* + 5. Third-party dependencies + 6. *(blank line)* + 7. Local/project headers + +## Shell Scripts + +- Always use `#!/bin/sh` shebang for shell scripts +- Scripts must be POSIX compliant (no bashisms) +- When providing commands to users: + - Windows/PowerShell: use `` ` `` for line continuation + - Unix/Linux/macOS: use `\` for line continuation + +## Commit Messages + +- Follow the 50/72 rule: + - Subject line: max 50 characters + - Body lines: wrapped at 72 characters +- Use conventional commit prefixes (`feat:`, `fix:`, `docs:`, `chore:`, + etc.) +- Separate subject from body with a blank line +- Do **not** add yourself as a co-author (`Co-Authored-By:` trailers are + forbidden) + +## Documentation (Markdown) + +- Wrap normal text and lists at **max 80 columns** (for readability in + terminals and editors). +- **Exceptions**: Tables and code blocks (```` ``` ````) can exceed 80 + columns when formatting requires it (e.g. trees, alignment). +- Use standard Markdown: `**bold**`, `` `inline code` ``, `##` headings, + `-` or numbered lists, fenced code blocks with language hints. +- Keep examples concise, up-to-date, and self-documenting. +- Do not use em dashes (`—`). Use a colon or rewrite the sentence. +- Each shell command gets its own fenced code block; do **not** combine + multiple commands into one block. Precede each block with a short + plain-text label describing what the command does. +- This file (`AGENTS.md`) follows its own rules. + +## Source Layout + +```text +stk/ + stk.h / stk_win.c EdgeTX HID radio reader (stub for now) +src/ + main.c Entry point (hello world) +tests/ + test_stk.c Unity tests for stk +deps/ + FindUnity.cmake Fetches Unity v2.6.1 via ZIP + FindCMock.cmake Fetches CMock v2.6.0 via ZIP + Platform.cmake Detects OS and compiler + Flags.cmake Warning flags per compiler + Coverage.cmake gcovr coverage support + Sanitizers.cmake AddressSanitizer support + IDE.cmake VS/Xcode folder grouping +``` + +## Platform Support + +The project supports Windows, Linux, macOS, Emscripten, and Android via +`Platform.cmake` and `Flags.cmake` in `deps/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..22fa1e3 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.21) +project(stk VERSION 0.1.0 LANGUAGES C) + +# CMake configuration +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/deps") + +# Platform flags +include(Platform) +include(Flags) + +include(Coverage) + +# STK library +add_subdirectory(stk) + +# Main executable +add_executable(main src/main.c) +target_include_directories(main PRIVATE .) +target_compile_features(main PRIVATE c_std_23) +target_link_libraries(main PRIVATE stkshid) + +# HID discovery tool (debug only) +if(WIN32) + add_executable(hid_discover tools/hid_discover.c) + target_compile_features(hid_discover PRIVATE c_std_23) + target_link_libraries(hid_discover PRIVATE setupapi hid) +endif() + +# Testing +enable_testing() +add_subdirectory(tests) + +# IDE configuration +include(IDE) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfbd7e6 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# STK Library + +A C23 library for reading EdgeTX radio joystick (HID) input on Windows. +Reads raw HID input reports to access 4 analog axes and 24 buttons. + +Wired for test-driven development using +[Unity](https://github.com/ThrowTheSwitch/Unity) and +[CMock](https://github.com/ThrowTheSwitch/CMock). + +All dependencies are fetched automatically via CMake `FetchContent` — no +manual installation required beyond the tools listed below. + +## Requirements + +| Tool | Purpose | +| ------------- | ----------------------------------------------------- | +| CMake >= 3.21 | Build system | +| Ninja | Build backend | +| C23 compiler | GCC 14+, Clang 18+ | +| Ruby >= 3.0 | CMock mock generation | +| gcovr >= 6.0 | Coverage reports — optional (`uv tool install gcovr`) | + +## Build + +Configure: + +```sh +cmake -S . -B build -G Ninja +``` + +Build: + +```sh +ninja -C build +``` + +## Test + +Full Unity output with colors: + +```sh +ninja -C build check +``` + +CTest summary only: + +```sh +ninja -C build test +``` + +`check` builds all suites and runs CTest with `--output-on-failure`, +so assertion-level detail appears on any failure without running +binaries by hand. + +## Coverage + +Configure with coverage instrumentation: + +```sh +cmake -S . -B build-cov -G Ninja -DENABLE_COVERAGE=ON +``` + +Generate the HTML report: + +```sh +ninja -C build-cov coverage +``` + +Open `build-cov/coverage/index.html` in a browser to view results. + +Only `stk/` source files are measured. Unity, CMock, and generated +mock files are excluded. Requires GCC or Clang with gcov support, and +`gcovr` on `PATH`. + +> **Windows note:** requires GCC (e.g. `scoop install gcc`) or a Clang +> build that includes compiler-rt. A custom Clang without compiler-rt +> will fail at link time. + +## Run + +Windows: + +```sh +./build/main.exe +``` + +## API + +```c +#include + +// Open with default VID/PID +stk_config_t config = { + .vendor_id = STK_DEFAULT_VID, + .product_id = STK_DEFAULT_PID, +}; + +if (stk_open(&config) != 0) { + // handle error +} + +// Read state (non-blocking) +stk_state_t state; +stk_read(&state); + +// Named accessors for the 4 active axes +int16_t rx = stk_right_x(&state); +int16_t ry = stk_right_y(&state); +int16_t throttle = stk_throttle(&state); +int16_t lx = stk_left_x(&state); + +// Raw axes array (indices 0-7, only 1/2/4/5 active) +// state.axes[0..7] — values in range STK_AXIS_MIN..MAX (0..2047) + +// Button check +if (stk_button_pressed(&state, 3)) { + // switch 4 is on +} + +// Close the device +stk_close(); +``` + +## Radio Profile + +EdgeTX radio (VID:PID 1209:4F54) HID input report: + +| Field | Size | Details | +| ------- | ---------- | --------------------------------------------------- | +| Axes | 4 x 16-bit | Range 0-2047 (HID usage 0x30-0x37, indices 1/2/4/5) | +| Buttons | 24 bits | Switches, rockers, paddles | +| Total | 20 bytes | No report ID | + +## Current Status + +- [x] Build system (CMake + Ninja) +- [x] Raw HID device enumeration and opening +- [x] Reading 4 axes and 24 buttons +- [ ] Linux support +- [ ] macOS support diff --git a/deps/Coverage.cmake b/deps/Coverage.cmake new file mode 100644 index 0000000..01159ef --- /dev/null +++ b/deps/Coverage.cmake @@ -0,0 +1,56 @@ +# ============================================================================== +# Coverage +# ============================================================================== +# gcov-based coverage via --coverage, reported by gcovr. +# Works with GCC and Clang on Linux and macOS out of the box. +# Windows requires GCC (e.g. MinGW via scoop install gcc) or a Clang build +# that includes compiler-rt (clang_rt.profile). +# +# Requires: gcovr on PATH (install via: uv tool install gcovr) +# Usage: cmake -DENABLE_COVERAGE=ON ... then ninja coverage +# Report: build/coverage/index.html +# ============================================================================== + +option(ENABLE_COVERAGE "Build with coverage instrumentation" OFF) + +if (ENABLE_COVERAGE) + if (NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + message(FATAL_ERROR "ENABLE_COVERAGE requires GCC or Clang") + endif() + + find_program(GCOVR_EXE gcovr REQUIRED) + + if (CMAKE_C_COMPILER_ID MATCHES "Clang") + # gcovr needs a single-token gcov executable. Wrap llvm-cov gcov in a + # script placed in the build dir (guaranteed no spaces in path). + # AppleClang ships llvm-cov inside Xcode, reached only via xcrun. + if (WIN32) + find_program(LLVM_COV_EXE llvm-cov REQUIRED) + set(GCOV_EXECUTABLE "${CMAKE_BINARY_DIR}/llvm-gcov.bat") + file(WRITE "${GCOV_EXECUTABLE}" "@echo off\r\n\"${LLVM_COV_EXE}\" gcov %*\r\n") + elseif (CMAKE_C_COMPILER_ID STREQUAL "AppleClang") + find_program(XCRUN_EXE xcrun REQUIRED) + set(GCOV_EXECUTABLE "${CMAKE_BINARY_DIR}/llvm-gcov.sh") + file(WRITE "${GCOV_EXECUTABLE}" "#!/bin/sh\nexec xcrun llvm-cov gcov \"$@\"\n") + file(CHMOD "${GCOV_EXECUTABLE}" + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + else() + find_program(LLVM_COV_EXE llvm-cov REQUIRED) + set(GCOV_EXECUTABLE "${CMAKE_BINARY_DIR}/llvm-gcov.sh") + file(WRITE "${GCOV_EXECUTABLE}" "#!/bin/sh\nexec \"${LLVM_COV_EXE}\" gcov \"$@\"\n") + file(CHMOD "${GCOV_EXECUTABLE}" + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif() + message(STATUS "Coverage: enabled (gcovr: ${GCOVR_EXE}, gcov: llvm-cov gcov)") + else() + set(GCOV_EXECUTABLE "gcov") + message(STATUS "Coverage: enabled (gcovr: ${GCOVR_EXE})") + endif() + + add_compile_options(--coverage -O0 -g) + add_link_options(--coverage) +endif() diff --git a/deps/FindCMock.cmake b/deps/FindCMock.cmake new file mode 100644 index 0000000..6988bb7 --- /dev/null +++ b/deps/FindCMock.cmake @@ -0,0 +1,92 @@ +# ============================================================================== +# Find CMock +# ============================================================================== +# This module fetches the CMock mocking framework (depends on Unity). +# +# Targets provided: +# CMock::CMock - The CMock library target +# +# Variables set: +# CMock_FOUND - TRUE if CMock is available +# CMock_LIBRARIES - The CMock library target (CMock::CMock) +# CMock_INCLUDE_DIR - Include directories for CMock +# CMock_VERSION - Version of CMock (if available) +# +# Generator variables (set when Ruby is found): +# CMOCK_SCRIPT - Path to lib/cmock.rb +# RUBY_EXECUTABLE - Path to ruby interpreter +# ============================================================================== + +if (DEFINED _FINDCMOCK_INCLUDED) + return() +endif() +set(_FINDCMOCK_INCLUDED TRUE) + +find_package(Unity REQUIRED) + +if (DEFINED CMock_FIND_VERSION AND NOT CMock_FIND_VERSION STREQUAL "") + set(CMOCK_VERSION "${CMock_FIND_VERSION}") +else() + set(CMOCK_VERSION "2.6.0") +endif() + +message(STATUS "Fetching CMock ${CMOCK_VERSION}") + +include(FetchContent) + +FetchContent_Declare( + cmock + URL https://github.com/ThrowTheSwitch/CMock/archive/refs/tags/v${CMOCK_VERSION}.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) + +# CMock uses Meson — bypass its build system and compile src/cmock.c directly. +# FetchContent_MakeAvailable cannot be used here (no CMakeLists.txt in CMock), +# so we call FetchContent_Populate directly and opt into the old policy. +cmake_policy(PUSH) +cmake_policy(SET CMP0169 OLD) +FetchContent_GetProperties(cmock) +if (NOT cmock_POPULATED) + FetchContent_Populate(cmock) +endif() +cmake_policy(POP) + +# The Ruby generator expects vendor/unity/auto/type_sanitizer.rb — populate +# it from the Unity source we already have rather than needing a git submodule +set(_cmock_vendor_auto "${cmock_SOURCE_DIR}/vendor/unity/auto") +if (NOT EXISTS "${_cmock_vendor_auto}/type_sanitizer.rb") + file(MAKE_DIRECTORY "${_cmock_vendor_auto}") + file(COPY "${unity_SOURCE_DIR}/auto/" DESTINATION "${_cmock_vendor_auto}") +endif() + +if (NOT TARGET cmock) + add_library(cmock STATIC "${cmock_SOURCE_DIR}/src/cmock.c") + target_include_directories(cmock PUBLIC + "${cmock_SOURCE_DIR}/src" + "${unity_SOURCE_DIR}/src" + ) + target_link_libraries(cmock PUBLIC unity) +endif() + +if (NOT TARGET CMock::CMock) + add_library(CMock::CMock ALIAS cmock) +endif() + +set(CMock_FOUND TRUE) +set(CMock_LIBRARIES CMock::CMock) +set(CMock_VERSION "${CMOCK_VERSION}") +set(CMock_INCLUDE_DIR "${cmock_SOURCE_DIR}/src") +set(CMOCK_SCRIPT "${cmock_SOURCE_DIR}/lib/cmock.rb" CACHE FILEPATH "Path to CMock Ruby generator") + +if (CMock_INCLUDE_DIR AND TARGET cmock) + set_target_properties(cmock PROPERTIES + INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${CMock_INCLUDE_DIR}" + ) +endif() + +find_program(RUBY_EXECUTABLE ruby) +if (NOT RUBY_EXECUTABLE) + message(WARNING "Ruby not found — CMock code generation unavailable") +endif() + +set(CMOCK_LICENSE_FILE "${cmock_SOURCE_DIR}/LICENSE.txt" CACHE FILEPATH "Path to CMock license file") diff --git a/deps/FindUnity.cmake b/deps/FindUnity.cmake new file mode 100644 index 0000000..6d3edb5 --- /dev/null +++ b/deps/FindUnity.cmake @@ -0,0 +1,67 @@ +# ============================================================================== +# Find Unity +# ============================================================================== +# This module fetches the Unity unit testing framework. +# +# Targets provided: +# Unity::Unity - The Unity library target +# +# Variables set: +# Unity_FOUND - TRUE if Unity is available +# Unity_LIBRARIES - The Unity library target (Unity::Unity) +# Unity_INCLUDE_DIR - Include directories for Unity +# Unity_VERSION - Version of Unity (if available) +# ============================================================================== + +if (DEFINED _FINDUNITY_INCLUDED) + return() +endif() +set(_FINDUNITY_INCLUDED TRUE) + +if (DEFINED Unity_FIND_VERSION AND NOT Unity_FIND_VERSION STREQUAL "") + set(UNITY_VERSION "${Unity_FIND_VERSION}") +else() + set(UNITY_VERSION "2.6.1") +endif() + +message(STATUS "Fetching Unity ${UNITY_VERSION}") + +include(FetchContent) + +FetchContent_Declare( + unity + URL https://github.com/ThrowTheSwitch/Unity/archive/refs/tags/v${UNITY_VERSION}.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) + +FetchContent_MakeAvailable(unity) + +# Unity sets INTERFACE_SYSTEM_INCLUDE_DIRECTORIES to a path inside the build +# tree, which CMake rejects on newer versions. The path stays in +# INTERFACE_INCLUDE_DIRECTORIES so headers are still found. +if (TARGET unity) + set_target_properties(unity PROPERTIES INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "") +endif() + +if (NOT TARGET Unity::Unity) + if (TARGET unity) + add_library(Unity::Unity ALIAS unity) + else() + message(FATAL_ERROR "Could not fetch Unity; no target unity or Unity::Unity available") + endif() +endif() + +set(Unity_FOUND TRUE) +set(Unity_LIBRARIES Unity::Unity) +set(Unity_VERSION "${UNITY_VERSION}") +set(Unity_INCLUDE_DIR "${unity_SOURCE_DIR}/src") + + +if (TARGET unity) + target_compile_definitions(unity PUBLIC + UNITY_OUTPUT_COLOR + UNITY_INCLUDE_PRINT_FORMATTED + ) +endif() + +set(UNITY_LICENSE_FILE "${unity_SOURCE_DIR}/LICENSE.txt" CACHE FILEPATH "Path to Unity license file") diff --git a/deps/Flags.cmake b/deps/Flags.cmake new file mode 100644 index 0000000..28a12cd --- /dev/null +++ b/deps/Flags.cmake @@ -0,0 +1,27 @@ +# ============================================================================== +# Compiler Flags +# ============================================================================== +# Sets BASE_OPTIONS (warning flags) and BASE_DEFINITIONS. +# Apply per-target via target_compile_options / target_compile_definitions +# to avoid polluting fetched dependencies. +# +# Requires: Platform.cmake (for IS_CLANG_OR_GCC / IS_MSVC) +# ============================================================================== + +set(BASE_DEFINITIONS "") +set(BASE_LIBRARIES "") + +set(BASE_OPTIONS "") +if (IS_CLANG_OR_GCC) + set(BASE_OPTIONS + "-Wall" + "-Wextra" + "-Werror" + ) +elseif (IS_MSVC) + set(BASE_OPTIONS + "/W4" + "/WX" + "/utf-8" + ) +endif() diff --git a/deps/IDE.cmake b/deps/IDE.cmake new file mode 100644 index 0000000..1577f6c --- /dev/null +++ b/deps/IDE.cmake @@ -0,0 +1,17 @@ +# ============================================================================== +# IDE Integration +# ============================================================================== +# Groups dependency targets into folders for Visual Studio / Xcode. +# Has no effect on the build. +# ============================================================================== + +function(set_target_folder target folder) + if (TARGET ${target}) + set_target_properties(${target} PROPERTIES FOLDER ${folder}) + endif() +endfunction() + +if (CMAKE_GENERATOR MATCHES "Visual Studio" OR CMAKE_GENERATOR MATCHES "Xcode") + set_target_folder(unity deps) + set_target_folder(cmock deps) +endif() diff --git a/deps/Platform.cmake b/deps/Platform.cmake new file mode 100644 index 0000000..5a5fdcb --- /dev/null +++ b/deps/Platform.cmake @@ -0,0 +1,64 @@ +# ============================================================================== +# Platform Detection +# ============================================================================== +# This module detects the current platform and compiler, setting IS_* variables +# that can be used throughout the build system for conditional logic. +# +# Compiler flags set: +# IS_CLANG_OR_GCC - TRUE if using Clang or GCC compiler +# IS_MSVC - TRUE if using Microsoft Visual C++ compiler +# +# Platform flags set: +# IS_WINDOWS - TRUE if building for Windows +# IS_LINUX - TRUE if building for Linux +# IS_MACOS - TRUE if building for macOS +# IS_IOS - TRUE if building for iOS +# IS_ANDROID - TRUE if building for Android +# IS_EMSCRIPTEN - TRUE if building for WebAssembly via Emscripten +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Compiler Detection +# ------------------------------------------------------------------------------ +set(IS_CLANG_OR_GCC FALSE) +set(IS_MSVC FALSE) + +if(MSVC) + set(IS_MSVC TRUE) +elseif(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU") + set(IS_CLANG_OR_GCC TRUE) +endif() + +# ------------------------------------------------------------------------------ +# Platform Detection +# ------------------------------------------------------------------------------ +set(IS_WINDOWS FALSE) +set(IS_LINUX FALSE) +set(IS_MACOS FALSE) +set(IS_IOS FALSE) +set(IS_ANDROID FALSE) +set(IS_EMSCRIPTEN FALSE) + +if(EMSCRIPTEN) + message(STATUS "Platform: Emscripten") + set(IS_EMSCRIPTEN TRUE) +elseif(ANDROID) + message(STATUS "Platform: Android") + set(IS_ANDROID TRUE) +elseif(APPLE) + if(IOS) + message(STATUS "Platform: iOS") + set(IS_IOS TRUE) + else() + message(STATUS "Platform: macOS") + set(IS_MACOS TRUE) + endif() +elseif(WIN32) + message(STATUS "Platform: Windows") + set(IS_WINDOWS TRUE) +elseif(UNIX) + message(STATUS "Platform: Linux") + set(IS_LINUX TRUE) +else() + message(FATAL_ERROR "Unknown platform!") +endif() diff --git a/deps/Sanitizers.cmake b/deps/Sanitizers.cmake new file mode 100644 index 0000000..d793dcd --- /dev/null +++ b/deps/Sanitizers.cmake @@ -0,0 +1,28 @@ +# ============================================================================== +# Sanitizers +# ============================================================================== +# AddressSanitizer (ASan) support. +# Works with GCC/Clang on Linux and macOS, Clang on Windows (requires +# compiler-rt with sanitizers), and MSVC on Windows (/fsanitize=address). +# +# Usage: cmake -DENABLE_ASAN=ON ... +# ============================================================================== + +option(ENABLE_ASAN "Build with AddressSanitizer" OFF) + +if (ENABLE_ASAN) + if (ENABLE_COVERAGE) + message(FATAL_ERROR "ENABLE_ASAN and ENABLE_COVERAGE cannot be used together") + endif() + + if (MSVC) + add_compile_options(/fsanitize=address) + message(STATUS "ASan: enabled (MSVC)") + elseif (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-fsanitize=address -fno-omit-frame-pointer) + add_link_options(-fsanitize=address) + message(STATUS "ASan: enabled (${CMAKE_C_COMPILER_ID})") + else() + message(FATAL_ERROR "ENABLE_ASAN: unsupported compiler ${CMAKE_C_COMPILER_ID}") + endif() +endif() diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..128e3a7 --- /dev/null +++ b/src/main.c @@ -0,0 +1,36 @@ +#include +#include +#include +#include "stk/stk.h" + +int main(void) { + stk_config_t config = { + .vendor_id = STK_DEFAULT_VID, + .product_id = STK_DEFAULT_PID, + }; + + if (stk_open(&config) != 0) { + fprintf(stderr, "warning: stk_open failed (no device connected)\n"); + return 1; + } + + printf("device opened, reading state... (Ctrl+C to exit)\n"); + printf("move sticks and flip switches on the radio\n\n"); + + for (int i = 0; i < 500; i++) { + stk_state_t state; + if (stk_read(&state) == 0) { + printf("right_x=%4d right_y=%4d throttle=%4d left_x=%4d " + "buttons=0x%08X\n", + stk_right_x(&state), + stk_right_y(&state), + stk_throttle(&state), + stk_left_x(&state), + state.buttons); + } + Sleep(100); + } + + stk_close(); + return 0; +} diff --git a/stk/CMakeLists.txt b/stk/CMakeLists.txt new file mode 100644 index 0000000..8bc8ee8 --- /dev/null +++ b/stk/CMakeLists.txt @@ -0,0 +1,4 @@ +add_library(stkshid STATIC platform/win/stk_win.c) +target_include_directories(stkshid PUBLIC "${CMAKE_SOURCE_DIR}") +target_compile_features(stkshid PRIVATE c_std_23) +target_link_libraries(stkshid PRIVATE setupapi hid) diff --git a/stk/platform/win/stk_win.c b/stk/platform/win/stk_win.c new file mode 100644 index 0000000..fc0bd49 --- /dev/null +++ b/stk/platform/win/stk_win.c @@ -0,0 +1,124 @@ +#include "stk/stk.h" + +#include +#include +#include +#include + +#include + +// GUID_DEVINTERFACE_HID from devguid.h +static const GUID GUID_DEVINTERFACE_HID = { + 0x4d1e55b2, 0xf16f, 0x11cf, + { 0x88, 0xcb, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } +}; + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +static HANDLE g_handle = INVALID_HANDLE_VALUE; + +// --------------------------------------------------------------------------- +// Platform backend +// --------------------------------------------------------------------------- + +int stk_open(stk_config_t const* config) { + if (!config) + return -1; + + HDEVINFO info = SetupDiGetClassDevsW(&GUID_DEVINTERFACE_HID, NULL, + NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + if (info == INVALID_HANDLE_VALUE) + return -1; + + for (DWORD i = 0; ; i++) { + SP_DEVICE_INTERFACE_DATA iface = { .cbSize = sizeof(iface) }; + if (!SetupDiEnumDeviceInterfaces(info, NULL, + &GUID_DEVINTERFACE_HID, i, &iface)) + break; + + DWORD buflen = 0; + SetupDiGetDeviceInterfaceDetailW(info, &iface, NULL, 0, &buflen, NULL); + if (buflen == 0) + continue; + + PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail = + (PSP_DEVICE_INTERFACE_DETAIL_DATA_W)HeapAlloc(GetProcessHeap(), + 0, buflen); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + + if (!SetupDiGetDeviceInterfaceDetailW(info, &iface, detail, buflen, + &buflen, NULL)) { + HeapFree(GetProcessHeap(), 0, detail); + continue; + } + + HANDLE handle = CreateFileW(detail->DevicePath, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, + NULL); + + HeapFree(GetProcessHeap(), 0, detail); + + if (handle == INVALID_HANDLE_VALUE) + continue; + + HIDD_ATTRIBUTES attrs = { .Size = sizeof(attrs) }; + if (!HidD_GetAttributes(handle, &attrs)) { + CloseHandle(handle); + continue; + } + + if (attrs.VendorID == config->vendor_id && + attrs.ProductID == config->product_id) { + g_handle = handle; + SetupDiDestroyDeviceInfoList(info); + return 0; + } + + CloseHandle(handle); + } + + SetupDiDestroyDeviceInfoList(info); + fprintf(stderr, "stk: device 0x%04X:0x%04X not found\n", + config->vendor_id, config->product_id); + return -1; +} + +int stk_read(stk_state_t* state) { + if (g_handle == INVALID_HANDLE_VALUE) + return -1; + + uint8_t buf[64]; + DWORD bytes = 0; + + if (!ReadFile(g_handle, buf, sizeof(buf), &bytes, NULL)) + return -1; + + if (bytes < 20) + return -1; + + // Parse axes (little-endian 16-bit values, offset by 2 bytes) + for (int i = 0; i < STK_NUM_AXES; i++) { + size_t off = 2 + (size_t)i * 2; + uint16_t val = (uint16_t)buf[off] | + ((uint16_t)buf[off + 1] << 8); + state->axes[i] = (int16_t)val; + } + + // Parse buttons (byte 1 + bytes 18-19 = 24 bits) + state->buttons = (uint32_t)buf[1] | + ((uint32_t)buf[18] << 8) | + ((uint32_t)buf[19] << 16); + + return 0; +} + +void stk_close(void) { + if (g_handle != INVALID_HANDLE_VALUE) { + CloseHandle(g_handle); + g_handle = INVALID_HANDLE_VALUE; + } +} diff --git a/stk/stk.h b/stk/stk.h new file mode 100644 index 0000000..3d132d4 --- /dev/null +++ b/stk/stk.h @@ -0,0 +1,124 @@ +/** + * @file stk.h + * @brief EdgeTX RC library interface for HID joystick mode. + * + * Reads raw HID input reports from the EdgeTX radio and exposes + * 4 analog axes (sticks + throttle) and 24 digital buttons + * (switches, rockers, paddles). + * + * @par Report layout (20 bytes): + * @code + * byte 0 : report ID (0x00) + * byte 1 : buttons bits 0..7 + * bytes 2..17 : 8 axes x 16-bit LE (HID usage 0x30..0x37) + * bytes 18..19 : buttons bits 8..23 + * @endcode + * + * @par Active axes: + * | Index | Usage | Physical control | Rest value | + * |-------|-------|-----------------|------------| + * | 0 | — | unused | always 0 | + * | 1 | RX | right stick X | ~1024 | + * | 2 | RY | right stick Y | ~1024 | + * | 3 | — | unused | always 0 | + * | 4 | Z | left stick Y | 0..2047 | + * | 5 | VR | left stick X | ~1024 | + * | 6..7 | — | unused | always 0 | + */ +#pragma once + +#include + +// --------------------------------------------------------------------------- +// Axis range (10-bit ADC on the radio) +// --------------------------------------------------------------------------- + +#define STK_AXIS_MIN 0 +#define STK_AXIS_MAX 2047 +#define STK_AXIS_MID 1024 + +// --------------------------------------------------------------------------- +// Default device IDs (EdgeTX radio in HID joystick mode) +// --------------------------------------------------------------------------- + +#define STK_DEFAULT_VID 0x1209 +#define STK_DEFAULT_PID 0x4F54 + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +typedef struct { + uint16_t vendor_id; + uint16_t product_id; +} stk_config_t; + +// --------------------------------------------------------------------------- +// Radio state +// +// axes[] holds all 8 HID axes (indices 0-7). Only indices 1, 2, 4, 5 +// are wired to physical controls. The rest stay at 0. +// +// buttons is a 24-bit bitmask: bit N set = switch/button N+1 pressed. +// --------------------------------------------------------------------------- + +#define STK_NUM_AXES 8 +#define STK_NUM_BUTTONS 24 + +typedef struct { + int16_t axes[STK_NUM_AXES]; + uint32_t buttons; +} stk_state_t; + +// --------------------------------------------------------------------------- +// Convenience accessors for the 4 active axes +// --------------------------------------------------------------------------- + +static inline int16_t stk_right_x(stk_state_t const* s) { + return s->axes[1]; +} + +static inline int16_t stk_right_y(stk_state_t const* s) { + return s->axes[2]; +} + +static inline int16_t stk_throttle(stk_state_t const* s) { + return s->axes[4]; +} + +static inline int16_t stk_left_x(stk_state_t const* s) { + return s->axes[5]; +} + +// --------------------------------------------------------------------------- +// Button bitmask helpers (bit 0 = first switch, bit 23 = last) +// --------------------------------------------------------------------------- + +static inline bool stk_button_pressed(stk_state_t const* s, + uint8_t index) { + if (index >= STK_NUM_BUTTONS) + return false; + return (s->buttons >> index) & 1; +} + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +/** + * Open the HID radio device. + * config must remain valid for the lifetime of the open session. + * Returns 0 on success, -1 on failure. + */ +int stk_open(stk_config_t const* config); + +/** + * Read the current radio state (non-blocking). + * Returns 0 on success, -1 on failure or no new data. + */ +int stk_read(stk_state_t* state); + +/** + * Close the radio device. Safe to call multiple times. + */ +void stk_close(void); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..6c0d7d8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,48 @@ +find_package(Unity REQUIRED) +find_package(CMock REQUIRED) + +set(TEST_TARGETS "") + +# STK tests +add_executable(test_stk test_stk.c) +target_include_directories(test_stk PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(test_stk PRIVATE stkshid Unity::Unity) +target_compile_features(test_stk PRIVATE c_std_23) +add_test(NAME test_stk COMMAND test_stk) +list(APPEND TEST_TARGETS test_stk) + +# 'check' builds all suites and runs CTest with full Unity output. +# USES_TERMINAL keeps ANSI colors alive through Ninja's output buffering. +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir "${CMAKE_BINARY_DIR}" + --output-on-failure + --progress + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + USES_TERMINAL + DEPENDS ${TEST_TARGETS} +) + +if (ENABLE_COVERAGE) + set(COVERAGE_DIR "${CMAKE_BINARY_DIR}/coverage") + add_custom_target(coverage + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + COMMAND ${CMAKE_COMMAND} -E make_directory "${COVERAGE_DIR}" + COMMAND ${GCOVR_EXE} + --gcov-executable "${GCOV_EXECUTABLE}" + --root "${CMAKE_SOURCE_DIR}" + --filter "${CMAKE_SOURCE_DIR}/stk/" + --exclude "${CMAKE_SOURCE_DIR}/tests/" + --exclude ".*Mock.*" + --exclude ".*unity.*" + --exclude ".*cmock.*" + --html-details "${COVERAGE_DIR}/index.html" + --txt + --print-summary + "${CMAKE_BINARY_DIR}" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + USES_TERMINAL + DEPENDS ${TEST_TARGETS} + COMMENT "Coverage report: ${COVERAGE_DIR}/index.html" + ) +endif() diff --git a/tests/test_stk.c b/tests/test_stk.c new file mode 100644 index 0000000..ebfd147 --- /dev/null +++ b/tests/test_stk.c @@ -0,0 +1,156 @@ +#include "unity.h" +#include "stk/stk.h" + +void setUp(void) {} +void tearDown(void) {} + +// --------------------------------------------------------------------------- +// stk_config_t +// --------------------------------------------------------------------------- + +void test_config_has_correct_fields(void) { + stk_config_t cfg = { + .vendor_id = STK_DEFAULT_VID, + .product_id = STK_DEFAULT_PID, + }; + TEST_ASSERT_EQUAL_UINT16(STK_DEFAULT_VID, cfg.vendor_id); + TEST_ASSERT_EQUAL_UINT16(STK_DEFAULT_PID, cfg.product_id); +} + +void test_config_defaults_match_vid_pid(void) { + TEST_ASSERT_EQUAL_UINT16(0x1209, STK_DEFAULT_VID); + TEST_ASSERT_EQUAL_UINT16(0x4F54, STK_DEFAULT_PID); +} + +// --------------------------------------------------------------------------- +// stk_state_t +// --------------------------------------------------------------------------- + +void test_state_defaults_to_zero(void) { + stk_state_t state = { 0 }; + for (int i = 0; i < STK_NUM_AXES; i++) + TEST_ASSERT_EQUAL_INT16(0, state.axes[i]); + TEST_ASSERT_EQUAL_UINT32(0, state.buttons); +} + +void test_state_has_expected_size(void) { + // 8 axes * 2 bytes + 4 bytes buttons = 20 bytes + TEST_ASSERT_EQUAL_INT(20, (int)sizeof(stk_state_t)); +} + +// --------------------------------------------------------------------------- +// Axis range constants +// --------------------------------------------------------------------------- + +void test_axis_range_is_valid(void) { + TEST_ASSERT_TRUE(STK_AXIS_MIN < STK_AXIS_MID); + TEST_ASSERT_TRUE(STK_AXIS_MID < STK_AXIS_MAX); +} + +void test_axis_mid_is_center(void) { + TEST_ASSERT_EQUAL_INT( + (STK_AXIS_MIN + STK_AXIS_MAX + 1) / 2, + STK_AXIS_MID); +} + +void test_axis_range_values(void) { + TEST_ASSERT_EQUAL_INT(0, STK_AXIS_MIN); + TEST_ASSERT_EQUAL_INT(2047, STK_AXIS_MAX); + TEST_ASSERT_EQUAL_INT(1024, STK_AXIS_MID); +} + +void test_axis_count(void) { + TEST_ASSERT_EQUAL_INT(8, STK_NUM_AXES); +} + +void test_button_count(void) { + TEST_ASSERT_EQUAL_INT(24, STK_NUM_BUTTONS); +} + +// --------------------------------------------------------------------------- +// Named axis accessors +// --------------------------------------------------------------------------- + +void test_named_accessors_map_correct_indices(void) { + stk_state_t state = { 0 }; + state.axes[1] = 111; + state.axes[2] = 222; + state.axes[4] = 333; + state.axes[5] = 444; + + TEST_ASSERT_EQUAL_INT16(111, stk_right_x(&state)); + TEST_ASSERT_EQUAL_INT16(222, stk_right_y(&state)); + TEST_ASSERT_EQUAL_INT16(333, stk_throttle(&state)); + TEST_ASSERT_EQUAL_INT16(444, stk_left_x(&state)); +} + +// --------------------------------------------------------------------------- +// Button helper +// --------------------------------------------------------------------------- + +void test_button_pressed_returns_false_for_unset_bits(void) { + stk_state_t state = { .buttons = 0 }; + for (int i = 0; i < STK_NUM_BUTTONS; i++) + TEST_ASSERT_FALSE(stk_button_pressed(&state, (uint8_t)i)); +} + +void test_button_pressed_returns_true_for_set_bits(void) { + stk_state_t state = { .buttons = (1u << 3) | (1u << 17) }; + TEST_ASSERT_TRUE(stk_button_pressed(&state, 3)); + TEST_ASSERT_TRUE(stk_button_pressed(&state, 17)); + TEST_ASSERT_FALSE(stk_button_pressed(&state, 0)); + TEST_ASSERT_FALSE(stk_button_pressed(&state, 23)); +} + +void test_button_pressed_out_of_range_returns_false(void) { + stk_state_t state = { .buttons = 0xFFFFFFFFu }; + TEST_ASSERT_FALSE(stk_button_pressed(&state, 24)); + TEST_ASSERT_FALSE(stk_button_pressed(&state, 31)); +} + +// --------------------------------------------------------------------------- +// API error handling +// --------------------------------------------------------------------------- + +void test_open_with_null_config_fails(void) { + TEST_ASSERT_NOT_EQUAL_INT(0, stk_open(NULL)); +} + +void test_read_with_null_state_fails(void) { + TEST_ASSERT_NOT_EQUAL_INT(0, stk_read(NULL)); +} + +void test_read_without_open_fails(void) { + stk_state_t state = { 0 }; + TEST_ASSERT_NOT_EQUAL_INT(0, stk_read(&state)); +} + +void test_close_is_safe_without_open(void) { + stk_close(); // should not crash +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_config_has_correct_fields); + RUN_TEST(test_config_defaults_match_vid_pid); + RUN_TEST(test_state_defaults_to_zero); + RUN_TEST(test_state_has_expected_size); + RUN_TEST(test_axis_range_is_valid); + RUN_TEST(test_axis_mid_is_center); + RUN_TEST(test_axis_range_values); + RUN_TEST(test_axis_count); + RUN_TEST(test_button_count); + RUN_TEST(test_named_accessors_map_correct_indices); + RUN_TEST(test_button_pressed_returns_false_for_unset_bits); + RUN_TEST(test_button_pressed_returns_true_for_set_bits); + RUN_TEST(test_button_pressed_out_of_range_returns_false); + RUN_TEST(test_open_with_null_config_fails); + RUN_TEST(test_read_with_null_state_fails); + RUN_TEST(test_read_without_open_fails); + RUN_TEST(test_close_is_safe_without_open); + return UNITY_END(); +} diff --git a/tools/hid_discover.c b/tools/hid_discover.c new file mode 100644 index 0000000..92c06ab --- /dev/null +++ b/tools/hid_discover.c @@ -0,0 +1,234 @@ +// hid_discover.c — enumerate HID devices, find EdgeTx radio, dump caps +// +// Build via CMake (target: hid_discover) +// +// Usage: +// hid_discover.exe (lists all HID devices) +// hid_discover.exe 1209 4F54 (find EdgeTx radio, dump input caps) + +#include +#include +#include +#include +#include +#include + +// ENUM_GUID from devguid.h — used to enumerate HID class devices +static const GUID GUID_DEVINTERFACE_HID = { + 0x4d1e55b2, 0xf16f, 0x11cf, + { 0x88, 0xcb, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } +}; +#define ENUM_GUID GUID_DEVINTERFACE_HID + +// --------------------------------------------------------------------------- +// Capability listing (works even when the raw report descriptor IOCTL fails) +// --------------------------------------------------------------------------- + +static const char* usage_page_name(USAGE page) { + switch (page) { + case 0x01: return "Generic Desktop"; + case 0x02: return "Simulation Controls"; + case 0x09: return "Button"; + default: return "Unknown"; + } +} + +static void print_value_caps(PHIDP_PREPARSED_DATA preparsed, + const HIDP_CAPS* caps) { + USHORT count = caps->NumberInputValueCaps; + if (count == 0) + return; + + PHIDP_VALUE_CAPS vcaps = + HeapAlloc(GetProcessHeap(), 0, count * sizeof(HIDP_VALUE_CAPS)); + if (!vcaps) + return; + + if (HidP_GetValueCaps(HidP_Input, vcaps, &count, preparsed) == HIDP_STATUS_SUCCESS) { + printf("\nInput value caps (%u):\n", count); + for (USHORT i = 0; i < count; i++) { + const HIDP_VALUE_CAPS* v = &vcaps[i]; + if (v->IsRange) { + printf(" [%u] UsagePage=0x%02X (%s) Usage=0x%02X..0x%02X " + "ReportID=%u BitSize=%u ReportCount=%u " + "LogicalMin=%ld LogicalMax=%ld\n", + i, v->UsagePage, usage_page_name(v->UsagePage), + v->Range.UsageMin, v->Range.UsageMax, + v->ReportID, v->BitSize, v->ReportCount, + (long)v->LogicalMin, (long)v->LogicalMax); + } else { + printf(" [%u] UsagePage=0x%02X (%s) Usage=0x%02X " + "ReportID=%u BitSize=%u ReportCount=%u " + "LogicalMin=%ld LogicalMax=%ld\n", + i, v->UsagePage, usage_page_name(v->UsagePage), + v->NotRange.Usage, + v->ReportID, v->BitSize, v->ReportCount, + (long)v->LogicalMin, (long)v->LogicalMax); + } + } + } else { + fprintf(stderr, "HidP_GetValueCaps failed\n"); + } + + HeapFree(GetProcessHeap(), 0, vcaps); +} + +static void print_button_caps(PHIDP_PREPARSED_DATA preparsed, + const HIDP_CAPS* caps) { + USHORT count = caps->NumberInputButtonCaps; + if (count == 0) + return; + + PHIDP_BUTTON_CAPS bcaps = + HeapAlloc(GetProcessHeap(), 0, count * sizeof(HIDP_BUTTON_CAPS)); + if (!bcaps) + return; + + if (HidP_GetButtonCaps(HidP_Input, bcaps, &count, preparsed) == HIDP_STATUS_SUCCESS) { + printf("\nInput button caps (%u):\n", count); + for (USHORT i = 0; i < count; i++) { + const HIDP_BUTTON_CAPS* b = &bcaps[i]; + if (b->IsRange) { + printf(" [%u] UsagePage=0x%02X (%s) Usage=0x%02X..0x%02X " + "ReportID=%u\n", + i, b->UsagePage, usage_page_name(b->UsagePage), + b->Range.UsageMin, b->Range.UsageMax, b->ReportID); + } else { + printf(" [%u] UsagePage=0x%02X (%s) Usage=0x%02X ReportID=%u\n", + i, b->UsagePage, usage_page_name(b->UsagePage), + b->NotRange.Usage, b->ReportID); + } + } + } else { + fprintf(stderr, "HidP_GetButtonCaps failed\n"); + } + + HeapFree(GetProcessHeap(), 0, bcaps); +} + +// --------------------------------------------------------------------------- +// Device probe +// --------------------------------------------------------------------------- + +static int device_num = 0; + +static int probe_device(HANDLE handle, const wchar_t* path, + uint16_t filter_vid, uint16_t filter_pid) { + HIDD_ATTRIBUTES attrs = { .Size = sizeof(attrs) }; + if (!HidD_GetAttributes(handle, &attrs)) + return 0; + + if (filter_vid && filter_pid) { + if (attrs.VendorID != filter_vid || attrs.ProductID != filter_pid) { + fprintf(stderr, "Skipping VID:PID 0x%04X:0x%04X\n", attrs.VendorID, attrs.ProductID); + return 0; // not our target + } + } + + // Get HID caps via preparsed data + HIDP_CAPS caps = { 0 }; + PHIDP_PREPARSED_DATA preparsed = NULL; + BOOL has_caps = FALSE; + if (HidD_GetPreparsedData(handle, &preparsed)) { + if (HidP_GetCaps(preparsed, &caps) == HIDP_STATUS_SUCCESS) + has_caps = TRUE; + } + + device_num++; + printf("\n=== Device %d ===\n", device_num); + printf("Path: %ls\n", path); + printf("VID:PID: 0x%04X:0x%04X Version: 0x%04X\n", + attrs.VendorID, attrs.ProductID, attrs.VersionNumber); + + if (has_caps) { + printf("Input report length: %u\n", caps.InputReportByteLength); + printf("Output report length: %u\n", caps.OutputReportByteLength); + printf("Feature report length: %u\n", caps.FeatureReportByteLength); + printf("Input buttons: %u\n", caps.NumberInputButtonCaps); + printf("Input values: %u\n", caps.NumberInputValueCaps); + + print_value_caps(preparsed, &caps); + print_button_caps(preparsed, &caps); + } + + if (preparsed) + HidD_FreePreparsedData(preparsed); + + return 1; +} + +// --------------------------------------------------------------------------- +// Enumeration +// --------------------------------------------------------------------------- + +static int enumerate_hid_devices(uint16_t filter_vid, uint16_t filter_pid) { + HDEVINFO info = SetupDiGetClassDevsW(&ENUM_GUID, NULL, NULL, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + if (info == INVALID_HANDLE_VALUE) { + fprintf(stderr, "SetupDiGetClassDevs failed: %lu\n", GetLastError()); + return -1; + } + + int found = 0; + for (DWORD i = 0; ; i++) { + SP_DEVICE_INTERFACE_DATA iface = { .cbSize = sizeof(iface) }; + BOOL ok = SetupDiEnumDeviceInterfaces(info, NULL, &ENUM_GUID, i, &iface); + if (!ok) { + fprintf(stderr, "Enumerated %lu interfaces (last error: %lu)\n", + (unsigned long)i, GetLastError()); + break; + } + + DWORD buflen = 0; + SetupDiGetDeviceInterfaceDetailW(info, &iface, NULL, 0, &buflen, NULL); + if (buflen == 0) continue; + + PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail = + (PSP_DEVICE_INTERFACE_DETAIL_DATA_W)HeapAlloc(GetProcessHeap(), 0, buflen); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + + if (!SetupDiGetDeviceInterfaceDetailW(info, &iface, detail, buflen, + &buflen, NULL)) { + HeapFree(GetProcessHeap(), 0, detail); + continue; + } + + HANDLE handle = CreateFileW(detail->DevicePath, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, + NULL); + + if (handle != INVALID_HANDLE_VALUE) { + if (probe_device(handle, detail->DevicePath, filter_vid, filter_pid)) + found++; + CloseHandle(handle); + if (filter_vid && filter_pid && found > 0) break; // found our target + } else { + if (filter_vid && filter_pid) + fprintf(stderr, "CreateFile failed for %ls: %lu\n", + detail->DevicePath, GetLastError()); + } + + HeapFree(GetProcessHeap(), 0, detail); + } + + SetupDiDestroyDeviceInfoList(info); + return found; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +int main(int argc, char* argv[]) { + uint16_t vid = 0, pid = 0; + if (argc >= 3) { + vid = (uint16_t)strtoul(argv[1], NULL, 16); + pid = (uint16_t)strtoul(argv[2], NULL, 16); + printf("Looking for VID:PID 0x%04X:0x%04X\n", vid, pid); + } else { + printf("Listing all HID devices (pass VID PID to filter)\n"); + } + return enumerate_hid_devices(vid, pid) == 0 ? 1 : 0; +}