Compare commits
No commits in common. "master" and "feat/imu-gyro-support" have entirely different histories.
master
...
feat/imu-g
74 changed files with 1026 additions and 9174 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
|||
# Unified diffs require a one-character context marker on blank lines.
|
||||
*.patch -whitespace
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,7 +2,6 @@
|
|||
Switch-Fightstick
|
||||
GP2040-CE
|
||||
build
|
||||
build-aio
|
||||
debug
|
||||
.pycache
|
||||
*.egg-info
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "external/bluepad32"]
|
||||
path = external/bluepad32
|
||||
url = https://github.com/ricardoquesada/bluepad32.git
|
||||
|
|
@ -24,20 +24,7 @@ if (EXISTS ${picoVscode})
|
|||
endif()
|
||||
# ====================================================================================
|
||||
option(SWITCH_PICO_LOG "Enable UART debug logging" OFF)
|
||||
set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend")
|
||||
set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32)
|
||||
if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART"
|
||||
AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
message(FATAL_ERROR
|
||||
"Unknown SWITCH_PICO_INPUT_BACKEND='${SWITCH_PICO_INPUT_BACKEND}'. "
|
||||
"Expected UART or BLUEPAD32.")
|
||||
endif()
|
||||
set(PICO_BOARD pico CACHE STRING "Board type")
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32"
|
||||
AND NOT PICO_BOARD STREQUAL "pico2_w")
|
||||
message(FATAL_ERROR
|
||||
"SWITCH_PICO_INPUT_BACKEND=BLUEPAD32 requires PICO_BOARD=pico2_w")
|
||||
endif()
|
||||
|
||||
# Pull in Raspberry Pi Pico SDK (must be before project)
|
||||
include(pico_sdk_import.cmake)
|
||||
|
|
@ -47,64 +34,12 @@ project(switch-pico C CXX ASM)
|
|||
# Initialise the Raspberry Pi Pico SDK
|
||||
pico_sdk_init()
|
||||
|
||||
# Configure BLUEPAD32 input backend if selected
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
# Ensure Python3 is available and execute patch preparation
|
||||
find_package(Python3 REQUIRED COMPONENTS Interpreter)
|
||||
|
||||
set(BLUEPAD32_PREP_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/tools/prepare_bluepad32.py)
|
||||
execute_process(
|
||||
COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT}
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
|
||||
RESULT_VARIABLE BLUEPAD32_PREP_RESULT
|
||||
OUTPUT_VARIABLE BLUEPAD32_PREP_OUTPUT
|
||||
ERROR_VARIABLE BLUEPAD32_PREP_ERROR
|
||||
)
|
||||
|
||||
if(NOT BLUEPAD32_PREP_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR
|
||||
"Failed to prepare Bluepad32: Patch application or validation failed. "
|
||||
"Details: ${BLUEPAD32_PREP_ERROR}")
|
||||
endif()
|
||||
|
||||
# Configure Bluepad32 include paths and subdirectory
|
||||
set(BLUEPAD32_ROOT ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32)
|
||||
set(BTSTACK_ROOT ${PICO_SDK_PATH}/lib/btstack)
|
||||
include_directories(
|
||||
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config
|
||||
${BTSTACK_ROOT}/3rd-party/bluedroid/encoder/include
|
||||
${BTSTACK_ROOT}/3rd-party/bluedroid/decoder/include
|
||||
${BTSTACK_ROOT}/src
|
||||
)
|
||||
add_subdirectory(
|
||||
${BLUEPAD32_ROOT}/src/components/bluepad32
|
||||
${CMAKE_CURRENT_BINARY_DIR}/libbluepad32
|
||||
)
|
||||
endif()
|
||||
|
||||
# Add executable. Default name is the project name, version 0.1
|
||||
|
||||
add_executable(switch-pico
|
||||
switch-pico.cpp
|
||||
switch_pro_driver.cpp
|
||||
switch_haptics.cpp
|
||||
)
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
target_sources(switch-pico PRIVATE
|
||||
bluepad32_input_backend.cpp
|
||||
bootsel_pairing_button.cpp
|
||||
usb_pairing_management.cpp
|
||||
)
|
||||
target_compile_definitions(switch-pico PRIVATE
|
||||
SWITCH_PICO_BLUEPAD32=1
|
||||
SWITCH_PICO_HID_INSTANCE_COUNT=4
|
||||
PICO_FLASH_ASSUME_CORE1_SAFE=0
|
||||
)
|
||||
else()
|
||||
target_compile_definitions(switch-pico PRIVATE
|
||||
SWITCH_PICO_HID_INSTANCE_COUNT=1
|
||||
)
|
||||
endif()
|
||||
|
||||
pico_set_program_name(switch-pico "switch-pico")
|
||||
pico_set_program_version(switch-pico "0.1")
|
||||
|
|
@ -122,17 +57,6 @@ target_link_libraries(switch-pico
|
|||
hardware_uart
|
||||
pico_rand
|
||||
)
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
target_link_libraries(switch-pico
|
||||
bluepad32
|
||||
pico_cyw43_arch_none
|
||||
pico_btstack_ble
|
||||
pico_btstack_classic
|
||||
pico_btstack_cyw43
|
||||
pico_multicore
|
||||
pico_flash
|
||||
)
|
||||
endif()
|
||||
|
||||
if (SWITCH_PICO_LOG)
|
||||
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_LOG=1)
|
||||
|
|
|
|||
309
README.md
309
README.md
|
|
@ -1,12 +1,11 @@
|
|||
# Switch Pico Controller Bridge
|
||||
|
||||
Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from Bluetooth controllers through Bluepad32.
|
||||
Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB and a host bridge that forwards real gamepad input over UART (with rumble round-trip).
|
||||
|
||||
## What you get
|
||||
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a Switch Pro controller (one on standard Pico, four on Pico 2 W AIO), accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
|
||||
- **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL3 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux).
|
||||
- **Color configuration** (`controller_color_config.h`): compile-time RGB colors for emulated controller grips and supported Bluetooth controller LEDs.
|
||||
- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts four concurrent Bluetooth controllers and sends their controls, calibrated motion, rumble, and slot identity through four separate Switch Pro USB interfaces without a computer.
|
||||
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a wired Switch Pro. Takes controller reports over UART1 and passes rumble from the Switch back over UART.
|
||||
- **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL2 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux).
|
||||
- **Colour override** (`controller_color_config.h`): compile‑time RGB overrides for body/buttons/grips as seen by the Switch.
|
||||
|
||||
## Quick start
|
||||
1. Flash the Pico with `firmware/switch-pico.uf2` (or build your own) using BOOTSEL drag-and-drop (see “Manual UF2 flashing” below).
|
||||
|
|
@ -15,163 +14,12 @@ Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over
|
|||
4. Install the Python bridge (see “Python bridge”) and run `controller-uart-bridge --interactive`.
|
||||
5. Connect the Pico to the Switch (dock USB-A or USB-C OTG); the Switch should see it as a wired Pro Controller.
|
||||
|
||||
## Pico 2 W all-in-one Bluetooth option
|
||||
|
||||
### Architecture
|
||||
|
||||
The AIO build accepts up to four concurrent Bluetooth controllers on a single Pico 2 W. TinyUSB and the four Switch report generators run on Core 0; Bluepad32, BTstack, and the CYW43439 radio run on Core 1. Each Bluetooth device index maps directly to one always-present USB Pro HID interface. Per-slot state snapshots and generation-tagged latest-value rumble mailboxes are the only cross-core data paths.
|
||||
|
||||
All four USB interfaces are always present to the Switch as separate Pro Controllers on one physical USB device. Input, motion, rumble, lifecycle, and displayed grip color remain isolated per slot.
|
||||
|
||||
### Build and flash
|
||||
|
||||
Initialize the pinned Bluepad32 dependency once:
|
||||
|
||||
```sh
|
||||
git submodule update --init external/bluepad32
|
||||
```
|
||||
|
||||
Build and flash a Pico 2 W in BOOTSEL mode:
|
||||
|
||||
```sh
|
||||
python3 build.py --aio
|
||||
```
|
||||
|
||||
This uses an isolated `build-aio/` CMake cache and publishes:
|
||||
|
||||
- `firmware/switch-pico-aio.elf`
|
||||
- `firmware/switch-pico-aio.uf2`
|
||||
|
||||
The default `python3 build.py` command and `firmware/switch-pico.*` artifacts remain the UART/Pico build. The AIO build requires `PICO_BOARD=pico2_w`; it is not interchangeable with the original non-wireless Pico firmware.
|
||||
|
||||
Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0.
|
||||
|
||||
### Pairing up to four controllers
|
||||
|
||||
1. Flash and connect the Pico 2 W to the Switch.
|
||||
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
|
||||
3. Hold BOOTSEL for about two seconds until the onboard LED starts double-blinking. This enables new Bluetooth authentication for 60 seconds.
|
||||
4. Put a controller into Bluetooth pairing mode:
|
||||
- DualSense: hold Create + PS.
|
||||
- DualShock 4: hold Share + PS.
|
||||
- Switch Pro: press its sync button.
|
||||
- Xbox Bluetooth controller: hold its pair button.
|
||||
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
|
||||
5. Wait for the controller's player light to settle. Repeat step 4 for additional controllers while the window remains open. Holding BOOTSEL again extends the deadline by 60 seconds from that point.
|
||||
|
||||
Pairing order determines the initial USB slot assignment. Up to four controllers map 1:1 to the four emulated Switch Pro Controller interfaces.
|
||||
|
||||
While a slot is free, the Pico continuously runs Bluepad32's normal Bluetooth discovery and autoconnect path. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button; BOOTSEL is not required. Outside the BOOTSEL window, BTstack remains non-bondable, rejects new Classic SSP or legacy PIN authentication, and disables every BLE STK generation method. A controller in explicit pairing mode therefore cannot create a new Classic or BLE bond while the window is closed.
|
||||
|
||||
To clear every stored Classic and BLE pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears all bonds, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again.
|
||||
|
||||
|
||||
### LED meanings and device state
|
||||
|
||||
The Pico 2 W onboard LED reports the overall Bluetooth state:
|
||||
- **Double blink**: new controller authentication is enabled for the bounded pairing window.
|
||||
- **Rapid blink for two seconds**: all stored pairings were cleared.
|
||||
- **Fast blink**: a controller connection is still completing its handshake.
|
||||
- **Solid**: at least one controller is active.
|
||||
- **Slow blink**: no controller is active; Bluetooth discovery and autoconnect are running.
|
||||
- **Solid immediately after boot that never transitions**: Bluepad32 initialization did not complete; check firmware flashing and UART logs.
|
||||
|
||||
### Managing controller disconnect and reconnect
|
||||
|
||||
- **Disconnect a controller**: its slot immediately publishes neutral buttons, sticks, and motion. Other connected controllers are unaffected.
|
||||
- **Reconnect a paired controller**: power it on normally with its Home, PS, or Xbox button.
|
||||
- **8BitDo Ultimate Bluetooth reconnect**: leave its selector in Bluetooth mode, press Home once, then shake it. After an abrupt controller power-off, the Pico can remain solid for up to four seconds while Bluetooth link supervision confirms the disconnect; scanning restarts immediately afterward.
|
||||
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
|
||||
- **Pairing window expires**: new authentication is disabled; discovery and remembered-controller autoconnect continue while a slot is free.
|
||||
- **Clear all pairings**: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again.
|
||||
|
||||
### Managing pairings from a PC
|
||||
|
||||
Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. The management command uses private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes.
|
||||
|
||||
```sh
|
||||
uv run switch-pico-pairings list
|
||||
uv run switch-pico-pairings clear --yes
|
||||
```
|
||||
|
||||
`list` refreshes and prints stored Bluetooth Classic and BLE addresses. `clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and leaves autoconnect scanning active. The destructive command requires `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device.
|
||||
|
||||
### Per-controller ABXY layout
|
||||
|
||||
Each connected AIO controller can toggle its own ABXY layout by pressing **L + R + Select + Start** together. On DualSense, use **L1 + R1 + Create + Options**. The controller gives one short rumble when the toggle is accepted; release the chord before toggling again.
|
||||
|
||||
- **Standard**: south→B, east→A, west→Y, north→X.
|
||||
- **Swapped**: south→A, east→B, west→X, north→Y.
|
||||
- The chord is consumed locally and is not forwarded to the Switch.
|
||||
- Other controller slots are unaffected.
|
||||
- Layout returns to the configured default after disconnect or reboot.
|
||||
|
||||
Edit `controller_hotkey_config.h` to change the chord, default layout, or confirmation pulse.
|
||||
|
||||
### Per-controller motion toggle
|
||||
|
||||
Press **D-pad Up + R + Start** together to disable or re-enable motion for one controller. On DualSense, use **D-pad Up + R1 + Options**.
|
||||
|
||||
- A longer rumble confirms motion disabled.
|
||||
- A shorter rumble confirms motion enabled.
|
||||
- The chord is consumed locally and is not forwarded to the Switch.
|
||||
- Other controller slots are unaffected.
|
||||
- Motion returns to enabled after disconnect or reboot.
|
||||
|
||||
Edit `controller_hotkey_config.h` to change the chord, default state, or feedback patterns.
|
||||
|
||||
### Per-slot controller colors
|
||||
|
||||
Each AIO slot has one color shared by its emulated Switch Pro grips and its physical Bluetooth controller:
|
||||
|
||||
1. Blue `#0089EB`
|
||||
2. Red `#E63946`
|
||||
3. Yellow `#F6C945`
|
||||
4. Green `#2ECC71`
|
||||
|
||||
When a controller becomes ready, RGB-capable devices such as DualSense and DualShock 4 receive a darker, more saturated RGB value derived automatically from the slot's Switch grip color. Controllers without an RGB light use player indicator 1, 2, 3, or 4 when Bluepad32 exposes player-LED control. Devices without either capability are left unchanged. Edit only the four grip colors in `controller_color_config.h`; rebuilding automatically recalibrates their lightbar colors.
|
||||
|
||||
### Controller capabilities
|
||||
|
||||
| Controller | Buttons/sticks | Rumble | Motion |
|
||||
|---|---:|---:|---:|
|
||||
| DualSense / DualShock 4 | Yes | Yes | Yes |
|
||||
| Switch Pro / Joy-Con | Yes | Yes | Yes |
|
||||
| PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration |
|
||||
| Wii Remote | Mode-dependent | Yes | Accelerometer |
|
||||
| 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU |
|
||||
| Xbox Bluetooth controller | Yes | Yes | No hardware IMU |
|
||||
|
||||
Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and 8192 units per g in SDL-oriented axes before conversion to Nintendo samples. PS Move motion remains neutral until all model-specific calibration blocks have been received and validated; buttons and rumble remain available while calibration is pending or unavailable. The latest normalized sample is duplicated across the report's three nominal 5 ms slots and remains pending until a regular `0x30` USB report successfully consumes it.
|
||||
|
||||
### Rumble per controller
|
||||
|
||||
Rumble effects are per-slot and independent. The Switch sends rumble commands to a specific USB interface, and the Pico routes each command to the Bluetooth controller in the matching slot. Each slot has a critical-section-protected latest-value mailbox tagged with its connection generation; a newer pending command replaces the older one, and disconnect invalidates commands from the prior controller.
|
||||
|
||||
### Hardware validation
|
||||
|
||||
The four-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned independent controller slots, and buttons, sticks, calibrated motion, rumble, and disconnect isolation worked per controller. Fresh DualSense pairing through the BOOTSEL-open window has also been verified on hardware.
|
||||
|
||||
To reproduce the validation:
|
||||
|
||||
1. **Verify USB enumeration**: Connect the Pico 2 W to a USB host or analyzer. Confirm that four HID interfaces are present, using IN/OUT endpoint pairs `0x81/0x01` through `0x84/0x04`.
|
||||
2. **Verify Bluetooth pairing**: Hold BOOTSEL until the LED double-blinks, put a controller into explicit pairing mode, and confirm its player light settles.
|
||||
3. **Verify input on one controller**: Move sticks and press buttons; confirm only its assigned Switch slot changes.
|
||||
4. **Verify input on two controllers**: Move the second controller independently and confirm the first controller's slot is unaffected.
|
||||
5. **Verify the pairing gate**: Power-cycle the Pico and confirm a paired controller reconnects with its normal Home/PS/Xbox button without BOOTSEL. Put an unpaired controller into explicit pairing mode and confirm it remains blocked until the BOOTSEL window opens.
|
||||
6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates.
|
||||
7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output).
|
||||
|
||||
On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware. The pairing CLI uses vendor control transfers on endpoint 0 and does not depend on those hidraw nodes.
|
||||
|
||||
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.
|
||||
|
||||
## Planned features
|
||||
|
||||
## Limitations
|
||||
- No NFC/amiibo/IR support.
|
||||
- Rumble is best-effort: the UART build depends on SDL3 haptics; the AIO build depends on the connected controller's Bluepad32 rumble implementation.
|
||||
- The UART firmware requires a host computer running the bridge. The Pico 2 W AIO firmware does not; it hosts controllers over Bluetooth, not USB.
|
||||
- Rumble is best-effort: it depends on the Switch sending rumble and SDL2 being able to drive haptics on your specific controller.
|
||||
- Requires a host computer running the bridge; the Pico is not a Bluetooth/USB host for controllers.
|
||||
|
||||
## Uses
|
||||
- **Remote couch co-op**: friends connect via Parsec while the host streams the Switch via a low-latency capture device (e.g., Magewell Pro Capture) and runs the bridge (see setup below).
|
||||
|
|
@ -181,7 +29,7 @@ Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry
|
|||
### Remote couch co-op setup (example)
|
||||
1. Connect the Switch to a low-latency capture device on the host PC; view it in OBS (or your preferred viewer).
|
||||
2. Run `controller-uart-bridge` on the host PC and connect the Pico to the Switch for input.
|
||||
3. Have friends connect to the host PC using Parsec; they use their controllers on their end, which Parsec forwards to the host (SDL3 sees them).
|
||||
3. Have friends connect to the host PC using Parsec; they use their controllers on their end, which Parsec forwards to the host (SDL2 sees them).
|
||||
4. Optional audio routing: Voicemeeter Potato + a virtual audio cable can help manage capture/voice/game audio mixing:
|
||||
- Voicemeeter Potato: https://vb-audio.com/Voicemeeter/potato.htm
|
||||
- VB-CABLE: https://vb-audio.com/Cable/index.htm
|
||||
|
|
@ -189,28 +37,16 @@ Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry
|
|||
## End-to-end data flow (input + rumble)
|
||||
```
|
||||
INPUT (buttons/sticks)
|
||||
[Any controller] -> [Host OS HID] -> [SDL3 Gamepad] -> [controller-uart-bridge]
|
||||
[Any controller] -> [Host OS HID] -> [SDL2 GameController] -> [controller-uart-bridge]
|
||||
-> [USB↔UART adapter + UART serial] -> [Pico firmware] -> [USB (Switch Pro)]
|
||||
-> [Nintendo Switch]
|
||||
|
||||
RUMBLE (force feedback)
|
||||
[Nintendo Switch] -> [USB rumble output report] -> [Pico firmware]
|
||||
-> [UART serial + USB↔UART adapter] -> [controller-uart-bridge]
|
||||
-> [SDL3 haptics] -> [Any controller motors]
|
||||
-> [SDL2 haptics] -> [Any controller motors]
|
||||
```
|
||||
|
||||
### HD rumble translation
|
||||
|
||||
Nintendo sends two stateful four-byte HD-rumble actuator words. Each word can carry full or relative high/low frequency and amplitude commands with up to three subsamples; amplitude uses a logarithmic curve. The Pico decodes both words once in `SwitchHapticsDecoder`, retains actuator state across packets, and reduces the result to conventional low/strong and high/weak motor magnitudes. SDL3 and Bluepad32 cannot reproduce the original linear-actuator frequencies or left/right spatial effects, but they receive the correct nonlinear band amplitudes.
|
||||
|
||||
The UART return frame carries the decoded result rather than raw HD-rumble bytes:
|
||||
|
||||
```text
|
||||
0xBB, 0x02, low-frequency magnitude, high-frequency magnitude, checksum
|
||||
```
|
||||
|
||||
The checksum is the sum of the first four bytes modulo 256. Firmware and Python bridge versions from before this change are not rumble-protocol compatible; controller input framing remains unchanged.
|
||||
|
||||
## Hardware wiring (Pico)
|
||||
- UART1 pins (fixed in firmware):
|
||||
- **TX**: GPIO4 (Pico pin 6) → RX of your USB-serial adapter.
|
||||
|
|
@ -267,66 +103,19 @@ Filters you can use:
|
|||
## Building and flashing firmware
|
||||
Prereqs: Pico SDK + CMake toolchain set up.
|
||||
|
||||
### Using `build.py`
|
||||
|
||||
`build.py` configures CMake, builds the firmware, checks that both output formats
|
||||
were created, copies the release artifacts into `firmware/`, and flashes the ELF
|
||||
with `picotool`.
|
||||
|
||||
Before running it:
|
||||
|
||||
1. Install the Pico SDK, CMake toolchain, and `picotool`.
|
||||
2. Connect the Pico in BOOTSEL mode.
|
||||
3. From the repository root, run:
|
||||
|
||||
### One-shot build + flash (picotool)
|
||||
```sh
|
||||
python3 build.py
|
||||
```
|
||||
|
||||
The generated files are:
|
||||
|
||||
- `build/switch-pico.elf`, which `build.py` passes to `picotool`.
|
||||
- `build/switch-pico.uf2`, which can also be copied to the Pico manually.
|
||||
- `firmware/switch-pico.elf` and `firmware/switch-pico.uf2`, refreshed from the
|
||||
corresponding `build/` artifacts after every successful build.
|
||||
|
||||
To assign one color to every emulated controller slot while building, pass one
|
||||
of these mutually exclusive options:
|
||||
|
||||
```sh
|
||||
# Use one random color for all slots
|
||||
python3 build.py --random-grip-color
|
||||
|
||||
# Use one specific six-digit RGB color for all slots
|
||||
python3 build.py --grip-color FF00AA
|
||||
```
|
||||
|
||||
Both options update all four slot definitions in
|
||||
`controller_color_config.h` before building. With no color option, the
|
||||
per-slot blue/red/yellow/green palette is left unchanged. Run
|
||||
`python3 build.py --help` to see the available command-line options.
|
||||
|
||||
If the tools or artifacts are in non-default locations, use these environment
|
||||
variables:
|
||||
|
||||
```sh
|
||||
PICOTOOL_PATH=/path/to/picotool \
|
||||
ELF_PATH=/path/to/switch-pico.elf \
|
||||
UF2_PATH=/path/to/switch-pico.uf2 \
|
||||
python3 build.py
|
||||
```
|
||||
|
||||
`PICOTOOL_PATH` selects the flashing tool, `ELF_PATH` selects the ELF that is
|
||||
checked and flashed, and `UF2_PATH` selects the UF2 that is checked after the
|
||||
build. Their defaults are `picotool` from `PATH`, `build/switch-pico.elf`, and
|
||||
`build/switch-pico.uf2`, respectively.
|
||||
- Requires `picotool` on your `PATH` (or set `PICOTOOL_PATH=/path/to/picotool`) and a connected Pico in BOOTSEL mode to automatically flash.
|
||||
- Set `ELF_PATH` to override the default `build/switch-pico.elf`.
|
||||
|
||||
### Manual build
|
||||
```sh
|
||||
cmake -S . -B build -DSWITCH_PICO_LOG=OFF
|
||||
cmake --build build -j
|
||||
```
|
||||
This produces both `build/switch-pico.elf` and a flashable `build/switch-pico.uf2`.
|
||||
This produces a `.uf2` you can flash (typically `build/switch-pico.uf2`).
|
||||
|
||||
### Manual UF2 flashing (BOOTSEL, no tools)
|
||||
If you already have a built (or use the pre-built one in `firmware/`) `.uf2`, you can flash it without rebuilding:
|
||||
|
|
@ -342,8 +131,13 @@ Flash alternatives: bootsel + drag-drop or `picotool load`.
|
|||
Flags:
|
||||
- `SWITCH_PICO_LOG`: enable/disable UART logging on the Pico.
|
||||
|
||||
### Changing controller colours
|
||||
`build.py` can optionally update the **grip** colours in `controller_color_config.h` before building/flashing (default leaves the file unchanged):
|
||||
- Random grip colours: `python3 build.py --random-grip-color`
|
||||
- Set grip colours: `python3 build.py --grip-color FF00AA`
|
||||
|
||||
## Python bridge (recommended)
|
||||
Works on macOS, Windows, Linux. Uses SDL3 + pyserial.
|
||||
Works on macOS, Windows, Linux. Uses SDL2 + pyserial.
|
||||
|
||||
### Install dependencies (pyproject-enabled)
|
||||
The repository now includes a `pyproject.toml`, so you can install the bridge and helper scripts as an editable package:
|
||||
|
|
@ -363,7 +157,7 @@ source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
|||
pip install -e .
|
||||
```
|
||||
|
||||
- SDL3 runtime: install via your OS package manager (macOS: `brew install sdl3`; Windows: place `SDL3.dll` on PATH or next to the script; Linux: install `libsdl3-0` or your distribution's equivalent).
|
||||
- SDL2 runtime: install via your OS package manager (macOS: `brew install sdl2`; Windows: place `SDL2.dll` on PATH or next to the script; Linux: `sudo apt install libsdl2-2.0-0` or equivalent).
|
||||
|
||||
### Run
|
||||
```sh
|
||||
|
|
@ -430,21 +224,21 @@ with SwitchUARTClient("/dev/cu.usbserial-0001") as client:
|
|||
|
||||
### Windows tips
|
||||
- Use `COMx` for ports (e.g., `COM5`). Auto‑detect lists COM ports.
|
||||
- Ensure SDL3.dll is on PATH or alongside the script.
|
||||
- Ensure SDL2.dll is on PATH or alongside the script.
|
||||
|
||||
### Linux tips
|
||||
- You may need udev permissions for `/dev/ttyUSB*`/`/dev/ttyACM*` (add user to `dialout`/`uucp` or use `udev` rules).
|
||||
|
||||
## IMU / Motion Controls
|
||||
|
||||
The bridge supports gyroscope and accelerometer passthrough from controllers that have motion sensors (e.g. the Nintendo Switch Pro Controller and DualSense). Motion data is forwarded to the Pico as a rolling three-sample window; the Pico emits standard 0x30 reports at 15 ms intervals and supports both raw IMU mode 1 and packed quaternion mode 2.
|
||||
The bridge supports gyroscope and accelerometer passthrough from controllers that have motion sensors (e.g. the Nintendo Switch Pro Controller, DualSense). Motion data is forwarded to the Pico which injects it into the emulated Switch Pro Controller's HID reports.
|
||||
|
||||
### Requirements
|
||||
- A controller with gyro/accelerometer support that SDL3 can enable.
|
||||
- A controller with gyro/accelerometer support (SDL2 must be able to enable sensors on it).
|
||||
- The Switch will automatically use motion data once the controller is recognised as a Pro Controller.
|
||||
|
||||
### Gyro bias calibration
|
||||
On startup, the bridge collects the first 200 gyro readings while the controller is stationary and averages them to compute a per-axis bias (zero-rate offset). The bias is subtracted from subsequent readings. Keep the controller still during startup for best results.
|
||||
On startup, the bridge collects the first 200 gyro readings while the controller is stationary and averages them to compute a per-axis bias (zero-rate offset). Gyro output is zeroed during this ~1 second calibration window, then bias is subtracted from all subsequent readings. Keep the controller still when starting the bridge for best results. Use `--no-gyro-bias` to skip calibration and use raw values directly.
|
||||
|
||||
### CLI flags
|
||||
- `--debug-imu`: Print raw sensor values (m/s² and rad/s) and converted Switch integer counts every ~200ms. Useful for verifying the sensor is detected and producing sensible data.
|
||||
|
|
@ -452,57 +246,10 @@ On startup, the bridge collects the first 200 gyro readings while the controller
|
|||
- `--gyro-scale FLOAT` (default 1.0): Multiply all gyro values by this factor before sending. Reduce below 1.0 if the camera moves too fast; increase above 1.0 for more sensitivity.
|
||||
|
||||
### Troubleshooting
|
||||
- **Gyro not detected**: Run with `--debug-imu`. If no IMU readings appear, SDL3 cannot see sensors on the controller. On Linux, the `hid-nintendo` kernel driver may expose Nintendo controller motion differently; DualSense motion is supported by SDL3's PlayStation HID driver.
|
||||
- **Wild camera swinging**: Rebuild and flash the current Pico firmware. Older builds acknowledged quaternion IMU mode 2 but emitted raw mode-1 bytes, which Zelda interpreted as random quaternion data. Keep the controller still during startup, then use `--gyro-scale` only for deliberate sensitivity adjustment.
|
||||
- **Verifying Pico output**: Use `uv run python tools/read_pro_imu.py --vid 0x057E --pid 0x2009` to read raw IMU bytes directly from the Pico's USB HID output. A stationary controller should show gyro values near zero and three non-empty, non-duplicated samples per report.
|
||||
|
||||
### Implementation notes for maintainers
|
||||
|
||||
#### The failure
|
||||
|
||||
Nintendo subcommand `0x40` is a mode selector, not a Boolean enable:
|
||||
|
||||
| Value | Meaning | Required bytes 13-48 in report `0x30` |
|
||||
|---|---|---|
|
||||
| `0` | IMU off | Zero-filled |
|
||||
| `1` | Raw IMU | Three 12-byte accelerometer/gyro samples |
|
||||
| `2` | Quaternion | Nintendo's packed 36-byte mode-2 structure |
|
||||
|
||||
The previous firmware stored the argument in `bool is_imu_enabled`. A mode-2 request therefore enabled the raw mode-1 packer. Zelda then decoded raw sensor bytes as mode bits, compressed quaternion components, deltas, and timestamps, producing apparently random camera rotation. The fake also advertised firmware `4.91`, while the genuine wired Pro Controller used during diagnosis reported `3.48`.
|
||||
|
||||
Keep `SwitchImuMode` as a three-state value. Never acknowledge mode 2 and then emit mode-1 bytes.
|
||||
|
||||
#### Mode-1 implementation
|
||||
|
||||
- Emit one `0x30` report every 15 ms.
|
||||
- Advance the report timer by 3: one timer tick for each nominal 5 ms IMU sample.
|
||||
- Pack three chronological samples as signed little-endian `accel X/Y/Z`, then `gyro X/Y/Z`.
|
||||
- The host bridge must retain and republish its latest three-sample window. Do not drain it at the faster UART rate; that previously produced empty and duplicated USB reports.
|
||||
- With the advertised factory calibration, 1g is approximately 4096 counts and 1 rad/s is approximately 818.5 gyro counts.
|
||||
|
||||
#### Mode-2 implementation
|
||||
|
||||
`switch_pro_driver.cpp` implements this in `integrate_motion_sample()` and `fill_quaternion_imu_report_data()`:
|
||||
|
||||
1. Reset quaternion state to `(0, 0, 0, 1)` when transitioning into mode 2.
|
||||
2. Integrate each report's three gyro samples at 5 ms per sample. The Nintendo quaternion axes use sensor `Y, X, Z`, not `X, Y, Z`.
|
||||
3. Build a delta quaternion from the angular rotation vector, multiply it into the current orientation, and normalize after every sample.
|
||||
4. Select the largest absolute quaternion component. Its index and sign represent the omitted component; encode the other three signed components at 21-bit precision.
|
||||
5. Pack accelerometer data in `Y, X, Z` order, set the mode field to `2`, write the 11-bit millisecond timestamp, and set the timestamp/sample count to `3`.
|
||||
6. Integrate and repack only when transmitting the next 15 ms USB report. Calling the integrator from the unrestricted main loop over-integrates the same UART samples.
|
||||
|
||||
The mode-2 wire format is bit-packed and fields cross byte boundaries. Use `write_bits_le()` rather than C/C++ bitfields so layout does not depend on compiler bitfield rules.
|
||||
|
||||
#### Regression and hardware verification
|
||||
|
||||
After changing any IMU conversion, calibration, timing, or report packing:
|
||||
|
||||
1. Run `uv run --with pytest pytest -q`.
|
||||
2. Build with `cmake --build build -j`.
|
||||
3. Capture at least 200 raw `0x30` reports. Stationary gyro should remain near zero; there should be no empty windows, duplicated three-sample windows, or timer-step errors.
|
||||
4. Send subcommand `0x40` with value `2`. Every resulting report must have mode bits `2` and timestamp count `3`.
|
||||
5. Inject a known single-axis gyro rate and decode the packed quaternion. The corresponding component must change smoothly with the expected sign.
|
||||
6. Perform the decisive end-to-end check: genuine Pro Controller → SDL3 bridge → UART → emulated Pico → Zelda. This path was confirmed correct after the mode-2 fix.
|
||||
- **Gyro not detected**: Run with `--debug-imu`. If no IMU readings appear, SDL2 cannot see sensors on your controller (may not be supported or driver issue). On Linux, the `hid-nintendo` kernel driver routes Pro Controller IMU to a separate evdev device that SDL2 cannot read; use Windows or macOS for gyro passthrough.
|
||||
- **Wild camera swinging**: Start with `--gyro-scale 0.3` and increase gradually. Ensure the controller is still during the first second of startup (bias calibration).
|
||||
- **Verifying Pico output**: Use `python tools/read_pro_imu.py --vid 0x057E --pid 0x2009` to read raw IMU bytes directly from the Pico's USB HID output and confirm non-zero values appear.
|
||||
- **SDL2 accuracy**: SDL2 (version < 2.32.7) has a known inaccuracy bug with Switch Pro Controller gyro data. Updating the SDL2 shared library to 2.32.7 or later improves accuracy.
|
||||
|
||||
## References
|
||||
- GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
#ifndef _PICO_BTSTACK_BTSTACK_CONFIG_H
|
||||
#define _PICO_BTSTACK_BTSTACK_CONFIG_H
|
||||
|
||||
// Based on Bluepad32's official Pico W configuration. ENABLE_BLE and
|
||||
// ENABLE_CLASSIC are supplied by the corresponding Pico SDK BTstack targets.
|
||||
#define ENABLE_LOG_INFO
|
||||
#define ENABLE_LOG_ERROR
|
||||
#define ENABLE_PRINTF_HEXDUMP
|
||||
#define ENABLE_SCO_OVER_HCI
|
||||
|
||||
#ifdef ENABLE_BLE
|
||||
#define ENABLE_GATT_CLIENT_PAIRING
|
||||
#define ENABLE_L2CAP_LE_CREDIT_BASED_FLOW_CONTROL_MODE
|
||||
#define ENABLE_LE_CENTRAL
|
||||
#define ENABLE_LE_DATA_LENGTH_EXTENSION
|
||||
#define ENABLE_LE_PERIPHERAL
|
||||
#define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION
|
||||
#define ENABLE_LE_RESOLVING_LIST
|
||||
#define ENABLE_LE_SECURE_CONNECTIONS
|
||||
#else
|
||||
#error "BP32: ENABLE_BLE should be defined"
|
||||
#endif
|
||||
|
||||
#ifdef ENABLE_CLASSIC
|
||||
#define ENABLE_L2CAP_ENHANCED_RETRANSMISSION_MODE
|
||||
#define ENABLE_GOEP_L2CAP
|
||||
#else
|
||||
#error "BP32: ENABLE_CLASSIC should be defined"
|
||||
#endif
|
||||
|
||||
#if defined(ENABLE_CLASSIC) && defined(ENABLE_BLE)
|
||||
#define ENABLE_CROSS_TRANSPORT_KEY_DERIVATION
|
||||
#endif
|
||||
|
||||
#define HCI_OUTGOING_PRE_BUFFER_SIZE 4
|
||||
#define HCI_ACL_PAYLOAD_SIZE (1691 + 4)
|
||||
#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4
|
||||
#define MAX_NR_AVDTP_CONNECTIONS 1
|
||||
#define MAX_NR_AVDTP_STREAM_ENDPOINTS 1
|
||||
#define MAX_NR_AVRCP_CONNECTIONS 2
|
||||
#define MAX_NR_BNEP_CHANNELS 1
|
||||
#define MAX_NR_BNEP_SERVICES 1
|
||||
#define MAX_NR_BTSTACK_LINK_KEY_DB_MEMORY_ENTRIES 2
|
||||
#define MAX_NR_GATT_CLIENTS 4
|
||||
#define MAX_NR_HCI_CONNECTIONS 4
|
||||
#define MAX_NR_HID_HOST_CONNECTIONS 4
|
||||
#define MAX_NR_HIDS_CLIENTS 4
|
||||
#define MAX_NR_HFP_CONNECTIONS 1
|
||||
#define MAX_NR_L2CAP_CHANNELS 10
|
||||
#define MAX_NR_L2CAP_SERVICES 5
|
||||
#define MAX_NR_RFCOMM_CHANNELS 1
|
||||
#define MAX_NR_RFCOMM_MULTIPLEXERS 1
|
||||
#define MAX_NR_RFCOMM_SERVICES 1
|
||||
#define MAX_NR_SERVICE_RECORD_ITEMS 4
|
||||
#define MAX_NR_SM_LOOKUP_ENTRIES 3
|
||||
#define MAX_NR_WHITELIST_ENTRIES 16
|
||||
#define MAX_NR_LE_DEVICE_DB_ENTRIES 16
|
||||
|
||||
// Keep controller buffers and controller-to-host flow control enabled to avoid
|
||||
// overrunning the shared CYW43 bus.
|
||||
#define MAX_NR_CONTROLLER_ACL_BUFFERS 3
|
||||
#define MAX_NR_CONTROLLER_SCO_PACKETS 3
|
||||
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
#define HCI_HOST_ACL_PACKET_LEN 1024
|
||||
#define HCI_HOST_ACL_PACKET_NUM 3
|
||||
#define HCI_HOST_SCO_PACKET_LEN 120
|
||||
#define HCI_HOST_SCO_PACKET_NUM 3
|
||||
|
||||
// Persistent Classic and BLE pairing databases use Pico flash-backed TLV.
|
||||
#define NVM_NUM_DEVICE_DB_ENTRIES 16
|
||||
#define NVM_NUM_LINK_KEYS 16
|
||||
|
||||
// Bluepad32 does not provide malloc to BTstack.
|
||||
#define MAX_ATT_DB_SIZE 512
|
||||
|
||||
#define HAVE_EMBEDDED_TIME_MS
|
||||
#define HAVE_ASSERT
|
||||
#define HCI_RESET_RESEND_TIMEOUT_MS 1000
|
||||
#define ENABLE_SOFTWARE_AES128
|
||||
#define ENABLE_MICRO_ECC_FOR_LE_SECURE_CONNECTIONS
|
||||
#define HAVE_BTSTACK_STDIN
|
||||
|
||||
#endif // _PICO_BTSTACK_BTSTACK_CONFIG_H
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#define UNI_IMU_ACCEL_RES_PER_G 8192
|
||||
#define UNI_IMU_GYRO_RES_PER_DEG_S 1024
|
||||
#define UNI_PSMOVE_CALIBRATION_REPORT_SIZE 49
|
||||
#define UNI_PSMOVE_ZCM1_CALIBRATION_SIZE 143
|
||||
#define UNI_PSMOVE_ZCM2_CALIBRATION_SIZE 96
|
||||
|
||||
typedef enum {
|
||||
UNI_PSMOVE_IMU_MODEL_UNKNOWN = 0,
|
||||
UNI_PSMOVE_IMU_MODEL_ZCM1,
|
||||
UNI_PSMOVE_IMU_MODEL_ZCM2,
|
||||
} uni_psmove_imu_model_t;
|
||||
|
||||
typedef enum {
|
||||
UNI_PSMOVE_CALIBRATION_INVALID = 0,
|
||||
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
|
||||
UNI_PSMOVE_CALIBRATION_COMPLETE,
|
||||
} uni_psmove_calibration_result_t;
|
||||
|
||||
typedef struct {
|
||||
int32_t accel[3];
|
||||
int32_t gyro[3];
|
||||
} uni_imu_fixed_sample_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t data[UNI_PSMOVE_ZCM1_CALIBRATION_SIZE];
|
||||
uni_psmove_imu_model_t model;
|
||||
uint8_t received_blocks;
|
||||
bool complete;
|
||||
} uni_psmove_imu_calibration_t;
|
||||
|
||||
static inline int32_t uni_imu_clamp_i64(int64_t value) {
|
||||
if (value > INT32_MAX) {
|
||||
return INT32_MAX;
|
||||
}
|
||||
if (value < INT32_MIN) {
|
||||
return INT32_MIN;
|
||||
}
|
||||
return (int32_t)value;
|
||||
}
|
||||
|
||||
static inline int32_t uni_imu_scale(int32_t value, int32_t span,
|
||||
int32_t full_scale) {
|
||||
if (span <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return uni_imu_clamp_i64((int64_t)value * full_scale / span);
|
||||
}
|
||||
|
||||
static inline int32_t uni_psmove_scale_gyro(int32_t raw, int32_t bias,
|
||||
int32_t span,
|
||||
int32_t full_scale) {
|
||||
if (span <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return uni_imu_clamp_i64(
|
||||
((int64_t)raw - bias) * full_scale / span);
|
||||
}
|
||||
|
||||
static inline int32_t uni_psmove_decode_value(
|
||||
uni_psmove_imu_model_t model, uint16_t value) {
|
||||
if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
|
||||
return (int32_t)value - 0x8000;
|
||||
}
|
||||
return (int16_t)value;
|
||||
}
|
||||
|
||||
static inline int32_t uni_psmove_read_calibration_value(
|
||||
const uint8_t* data, uni_psmove_imu_model_t model, uint8_t offset) {
|
||||
const uint16_t value =
|
||||
(uint16_t)(data[offset] | ((uint16_t)data[offset + 1] << 8));
|
||||
return uni_psmove_decode_value(model, value);
|
||||
}
|
||||
|
||||
static inline uni_psmove_calibration_result_t
|
||||
uni_psmove_add_calibration_report(uni_psmove_imu_calibration_t* calibration,
|
||||
uni_psmove_imu_model_t model,
|
||||
const uint8_t* report, uint16_t length) {
|
||||
if (calibration == NULL || report == NULL ||
|
||||
length != UNI_PSMOVE_CALIBRATION_REPORT_SIZE || report[0] != 0x10 ||
|
||||
(model != UNI_PSMOVE_IMU_MODEL_ZCM1 &&
|
||||
model != UNI_PSMOVE_IMU_MODEL_ZCM2)) {
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
if (calibration->model != UNI_PSMOVE_IMU_MODEL_UNKNOWN &&
|
||||
calibration->model != model) {
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
calibration->model = model;
|
||||
|
||||
size_t offset;
|
||||
size_t source_offset;
|
||||
uint8_t block_mask;
|
||||
switch (report[1]) {
|
||||
case 0x00:
|
||||
offset = 0;
|
||||
source_offset = 0;
|
||||
block_mask = 0x01;
|
||||
break;
|
||||
case 0x01:
|
||||
if (model != UNI_PSMOVE_IMU_MODEL_ZCM1) {
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
offset = UNI_PSMOVE_CALIBRATION_REPORT_SIZE;
|
||||
source_offset = 2;
|
||||
block_mask = 0x02;
|
||||
break;
|
||||
case 0x81:
|
||||
if (model != UNI_PSMOVE_IMU_MODEL_ZCM2) {
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
offset = UNI_PSMOVE_CALIBRATION_REPORT_SIZE;
|
||||
source_offset = 2;
|
||||
block_mask = 0x02;
|
||||
break;
|
||||
case 0x82:
|
||||
if (model != UNI_PSMOVE_IMU_MODEL_ZCM1) {
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
offset = 2 * UNI_PSMOVE_CALIBRATION_REPORT_SIZE - 2;
|
||||
source_offset = 2;
|
||||
block_mask = 0x04;
|
||||
break;
|
||||
default:
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
|
||||
const size_t copy_size = length - source_offset;
|
||||
const size_t calibration_size =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1
|
||||
? UNI_PSMOVE_ZCM1_CALIBRATION_SIZE
|
||||
: UNI_PSMOVE_ZCM2_CALIBRATION_SIZE;
|
||||
if (offset + copy_size > calibration_size) {
|
||||
return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
}
|
||||
memcpy(&calibration->data[offset], &report[source_offset], copy_size);
|
||||
calibration->received_blocks |= block_mask;
|
||||
|
||||
const uint8_t required_blocks =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? 0x07 : 0x03;
|
||||
calibration->complete =
|
||||
(calibration->received_blocks & required_blocks) == required_blocks;
|
||||
return calibration->complete ? UNI_PSMOVE_CALIBRATION_COMPLETE
|
||||
: UNI_PSMOVE_CALIBRATION_INCOMPLETE;
|
||||
}
|
||||
|
||||
static inline bool uni_psmove_normalize_imu(
|
||||
uni_psmove_imu_model_t model,
|
||||
const uni_psmove_imu_calibration_t* calibration,
|
||||
const uint16_t accel_first[3], const uint16_t accel_second[3],
|
||||
const uint16_t gyro_first[3], const uint16_t gyro_second[3],
|
||||
uni_imu_fixed_sample_t* output) {
|
||||
if (output == NULL) {
|
||||
return false;
|
||||
}
|
||||
memset(output, 0, sizeof(*output));
|
||||
if (calibration == NULL || !calibration->complete ||
|
||||
calibration->model != model || accel_first == NULL ||
|
||||
accel_second == NULL || gyro_first == NULL || gyro_second == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static const uint8_t zcm1_accel_low[] = {0x0a, 0x24, 0x14};
|
||||
static const uint8_t zcm1_accel_high[] = {0x16, 0x1e, 0x08};
|
||||
static const uint8_t zcm2_accel_low[] = {0x08, 0x16, 0x24};
|
||||
static const uint8_t zcm2_accel_high[] = {0x02, 0x10, 0x1e};
|
||||
static const uint8_t zcm1_gyro_bias[] = {0x2a, 0x2c, 0x2e};
|
||||
static const uint8_t zcm1_gyro_high[] = {0x46, 0x50, 0x5a};
|
||||
static const uint8_t zcm2_gyro_bias[] = {0x26, 0x28, 0x2a};
|
||||
static const uint8_t zcm2_gyro_low[] = {0x42, 0x4a, 0x52};
|
||||
static const uint8_t zcm2_gyro_high[] = {0x30, 0x38, 0x40};
|
||||
|
||||
const uint8_t* accel_low =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_accel_low : zcm2_accel_low;
|
||||
const uint8_t* accel_high =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_accel_high : zcm2_accel_high;
|
||||
const uint8_t* gyro_bias =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_gyro_bias : zcm2_gyro_bias;
|
||||
const uint8_t* gyro_high =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_gyro_high : zcm2_gyro_high;
|
||||
const int32_t gyro_full_scale =
|
||||
(model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? 480 : 540) *
|
||||
UNI_IMU_GYRO_RES_PER_DEG_S;
|
||||
|
||||
for (uint8_t axis = 0; axis < 3; ++axis) {
|
||||
const int32_t accel_low_value = uni_psmove_read_calibration_value(
|
||||
calibration->data, model, accel_low[axis]);
|
||||
const int32_t accel_high_value = uni_psmove_read_calibration_value(
|
||||
calibration->data, model, accel_high[axis]);
|
||||
const int32_t accel_center =
|
||||
(accel_low_value + accel_high_value) / 2;
|
||||
const int32_t accel_raw =
|
||||
(uni_psmove_decode_value(model, accel_first[axis]) +
|
||||
uni_psmove_decode_value(model, accel_second[axis])) /
|
||||
2;
|
||||
const int32_t accel_delta = accel_raw - accel_center;
|
||||
const int32_t accel_span =
|
||||
accel_delta < 0 ? accel_center - accel_low_value
|
||||
: accel_high_value - accel_center;
|
||||
output->accel[axis] =
|
||||
uni_imu_scale(accel_delta, accel_span, UNI_IMU_ACCEL_RES_PER_G);
|
||||
|
||||
const int32_t gyro_bias_value = uni_psmove_read_calibration_value(
|
||||
calibration->data, model, gyro_bias[axis]);
|
||||
const int32_t gyro_raw =
|
||||
(uni_psmove_decode_value(model, gyro_first[axis]) +
|
||||
uni_psmove_decode_value(model, gyro_second[axis])) /
|
||||
2;
|
||||
int32_t gyro_span;
|
||||
if (model == UNI_PSMOVE_IMU_MODEL_ZCM1 ||
|
||||
gyro_raw >= gyro_bias_value) {
|
||||
gyro_span = uni_psmove_read_calibration_value(
|
||||
calibration->data, model, gyro_high[axis]) -
|
||||
gyro_bias_value;
|
||||
} else {
|
||||
gyro_span = gyro_bias_value - uni_psmove_read_calibration_value(
|
||||
calibration->data, model,
|
||||
zcm2_gyro_low[axis]);
|
||||
}
|
||||
output->gyro[axis] = uni_psmove_scale_gyro(
|
||||
gyro_raw, gyro_bias_value, gyro_span, gyro_full_scale);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static inline void uni_imu_normalize_wii_accel(int32_t x, int32_t y,
|
||||
int32_t z,
|
||||
int32_t output[3]) {
|
||||
if (output == NULL) {
|
||||
return;
|
||||
}
|
||||
output[0] = uni_imu_scale(-x, 100, UNI_IMU_ACCEL_RES_PER_G);
|
||||
output[1] = uni_imu_scale(z, 100, UNI_IMU_ACCEL_RES_PER_G);
|
||||
output[2] = uni_imu_scale(y, 100, UNI_IMU_ACCEL_RES_PER_G);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
// The AIO firmware exposes one fixed Bluepad32 device slot per USB interface.
|
||||
#define CONFIG_BLUEPAD32_MAX_DEVICES 4
|
||||
#define CONFIG_BLUEPAD32_MAX_ALLOWLIST 4
|
||||
#define CONFIG_BLUEPAD32_GAP_SECURITY 1
|
||||
#define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1
|
||||
|
||||
#define CONFIG_BLUEPAD32_PLATFORM_CUSTOM
|
||||
#define CONFIG_TARGET_PICO_W
|
||||
|
||||
// 2 == Info
|
||||
#define CONFIG_BLUEPAD32_LOG_LEVEL 2
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,46 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "switch_haptics.h"
|
||||
#include "switch_pro_driver.h"
|
||||
|
||||
constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4;
|
||||
constexpr uint8_t BLUEPAD32_PAIRING_RECORD_CAPACITY = 16;
|
||||
|
||||
enum class Bluepad32PairingTransport : uint8_t {
|
||||
kClassic = 1,
|
||||
kBle = 2,
|
||||
};
|
||||
|
||||
enum class Bluepad32PairingSnapshotStatus : uint8_t {
|
||||
kReady = 0,
|
||||
kPending = 1,
|
||||
};
|
||||
|
||||
struct Bluepad32PairingRecord {
|
||||
Bluepad32PairingTransport transport;
|
||||
uint8_t address_type;
|
||||
uint8_t address[6];
|
||||
};
|
||||
|
||||
struct Bluepad32PairingSnapshot {
|
||||
uint32_t generation;
|
||||
Bluepad32PairingSnapshotStatus status;
|
||||
uint8_t record_count;
|
||||
bool overflow;
|
||||
Bluepad32PairingRecord records[BLUEPAD32_PAIRING_RECORD_CAPACITY];
|
||||
};
|
||||
|
||||
|
||||
void bluepad32_input_backend_init();
|
||||
void bluepad32_input_backend_start();
|
||||
void bluepad32_input_backend_open_pairing_window();
|
||||
void bluepad32_input_backend_clear_pairings();
|
||||
bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out);
|
||||
void bluepad32_input_backend_request_pairing_snapshot();
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out);
|
||||
void bluepad32_input_backend_report_sent(uint8_t slot);
|
||||
void bluepad32_input_backend_queue_rumble(uint8_t slot,
|
||||
const SwitchRumbleOutput& rumble);
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
#include "bootsel_pairing_button.h"
|
||||
|
||||
#include "hardware/gpio.h"
|
||||
#include "hardware/structs/ioqspi.h"
|
||||
#include "hardware/structs/sio.h"
|
||||
#include "pico/flash.h"
|
||||
#include "pico/time.h"
|
||||
#if PICO_RP2350
|
||||
#include "hardware/regs/sio.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kPollIntervalMs = 100;
|
||||
constexpr uint32_t kFlashSafeTimeoutMs = 100;
|
||||
constexpr uint32_t kQspiCsPinIndex = 1;
|
||||
|
||||
BootselPairingButtonHoldFsm g_hold_fsm;
|
||||
uint32_t g_last_sample_ms = 0;
|
||||
|
||||
// QSPI CSn sampling adapted from awalol/DS5Dongle's button_functions.cpp:
|
||||
// https://github.com/awalol/DS5Dongle/blob/master/src/button_functions.cpp
|
||||
// Copyright (c) 2026 awalol; used under the MIT License.
|
||||
//
|
||||
// This callback and everything it executes while CSn is floated must remain in
|
||||
// SRAM or be an inlined hardware-register operation. In particular, do not add
|
||||
// logging or ordinary flash-backed data access here.
|
||||
void __no_inline_not_in_flash_func(read_bootsel_callback)(void* parameter) {
|
||||
auto* pressed = static_cast<bool*>(parameter);
|
||||
|
||||
hw_write_masked(
|
||||
&ioqspi_hw->io[kQspiCsPinIndex].ctrl,
|
||||
GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB,
|
||||
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS);
|
||||
|
||||
for (volatile uint32_t delay = 0; delay < 1000; ++delay) {
|
||||
}
|
||||
|
||||
#if PICO_RP2350
|
||||
*pressed =
|
||||
(sio_hw->gpio_hi_in & SIO_GPIO_HI_IN_QSPI_CSN_BITS) == 0;
|
||||
#else
|
||||
*pressed = (sio_hw->gpio_hi_in & (1u << kQspiCsPinIndex)) == 0;
|
||||
#endif
|
||||
|
||||
hw_write_masked(
|
||||
&ioqspi_hw->io[kQspiCsPinIndex].ctrl,
|
||||
GPIO_OVERRIDE_NORMAL << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB,
|
||||
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS);
|
||||
}
|
||||
|
||||
BootselPairingButtonSample sample_bootsel() {
|
||||
bool pressed = false;
|
||||
const int result = flash_safe_execute(read_bootsel_callback, &pressed,
|
||||
kFlashSafeTimeoutMs);
|
||||
if (result != PICO_OK) {
|
||||
return BootselPairingButtonSample::kUnread;
|
||||
}
|
||||
return pressed ? BootselPairingButtonSample::kPressed
|
||||
: BootselPairingButtonSample::kReleased;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BootselPairingButtonEvent BootselPairingButtonHoldFsm::update(
|
||||
BootselPairingButtonSample sample) {
|
||||
if (sample == BootselPairingButtonSample::kUnread) {
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
|
||||
if (sample == BootselPairingButtonSample::kReleased) {
|
||||
pressed_samples_ = 0;
|
||||
pairing_reported_ = false;
|
||||
clear_reported_ = false;
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
|
||||
if (pressed_samples_ < kClearHoldSamples) {
|
||||
++pressed_samples_;
|
||||
}
|
||||
if (pressed_samples_ >= kClearHoldSamples && !clear_reported_) {
|
||||
clear_reported_ = true;
|
||||
return BootselPairingButtonEvent::kClearPairings;
|
||||
}
|
||||
if (pressed_samples_ >= kPairingHoldSamples && !pairing_reported_) {
|
||||
pairing_reported_ = true;
|
||||
return BootselPairingButtonEvent::kOpenPairing;
|
||||
}
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
|
||||
BootselPairingButtonEvent bootsel_pairing_button_task() {
|
||||
const uint32_t now_ms =
|
||||
static_cast<uint32_t>(to_ms_since_boot(get_absolute_time()));
|
||||
if (now_ms - g_last_sample_ms < kPollIntervalMs) {
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
g_last_sample_ms = now_ms;
|
||||
|
||||
return g_hold_fsm.update(sample_bootsel());
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
enum class BootselPairingButtonSample : uint8_t {
|
||||
kUnread,
|
||||
kReleased,
|
||||
kPressed,
|
||||
};
|
||||
enum class BootselPairingButtonEvent : uint8_t {
|
||||
kNone,
|
||||
kOpenPairing,
|
||||
kClearPairings,
|
||||
};
|
||||
|
||||
|
||||
class BootselPairingButtonHoldFsm {
|
||||
public:
|
||||
static constexpr uint8_t kPairingHoldSamples = 20;
|
||||
static constexpr uint8_t kClearHoldSamples = 100;
|
||||
|
||||
BootselPairingButtonEvent update(BootselPairingButtonSample sample);
|
||||
|
||||
private:
|
||||
uint8_t pressed_samples_ = 0;
|
||||
bool pairing_reported_ = false;
|
||||
bool clear_reported_ = false;
|
||||
};
|
||||
|
||||
// Polls BOOTSEL at 10 Hz. Reports pairing at 2 seconds and clearing at
|
||||
// 10 seconds; each event fires once per continuous hold.
|
||||
BootselPairingButtonEvent bootsel_pairing_button_task();
|
||||
114
build.py
114
build.py
|
|
@ -12,20 +12,16 @@ from pathlib import Path
|
|||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_FILE = SCRIPT_DIR / "controller_color_config.h"
|
||||
BUILD_DIR = SCRIPT_DIR / "build"
|
||||
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
|
||||
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
|
||||
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
|
||||
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
|
||||
AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf"
|
||||
AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2"
|
||||
|
||||
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
|
||||
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
|
||||
|
||||
MACROS = tuple(
|
||||
f"SWITCH_COLOR_SLOT_{slot}_{component}"
|
||||
for slot in range(1, 5)
|
||||
for component in ("R", "G", "B")
|
||||
MACROS = (
|
||||
"SWITCH_COLOR_LEFT_GRIP_R",
|
||||
"SWITCH_COLOR_LEFT_GRIP_G",
|
||||
"SWITCH_COLOR_LEFT_GRIP_B",
|
||||
"SWITCH_COLOR_RIGHT_GRIP_R",
|
||||
"SWITCH_COLOR_RIGHT_GRIP_G",
|
||||
"SWITCH_COLOR_RIGHT_GRIP_B",
|
||||
)
|
||||
|
||||
def parse_args():
|
||||
|
|
@ -34,21 +30,16 @@ def parse_args():
|
|||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="Default behavior leaves controller_color_config.h unchanged.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--aio",
|
||||
action="store_true",
|
||||
help="Build and flash the Pico 2 W Bluepad32 all-in-one firmware.",
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"--random-grip-color",
|
||||
action="store_true",
|
||||
help="Assign one random color to every emulated controller slot.",
|
||||
help="Randomize both grip colors before building.",
|
||||
)
|
||||
group.add_argument(
|
||||
"--grip-color",
|
||||
metavar="RRGGBB",
|
||||
help="Set every emulated controller slot to the provided hex color.",
|
||||
help="Set both grip colors to the provided hex value.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
|
@ -81,7 +72,7 @@ def update_grip_colors(rgb_hex):
|
|||
sys.exit(1)
|
||||
return updated
|
||||
|
||||
values = (r, g, b) * 4
|
||||
values = (r, g, b, r, g, b)
|
||||
for macro, val in zip(MACROS, values):
|
||||
text = replace(macro, val, text)
|
||||
|
||||
|
|
@ -116,68 +107,27 @@ def resolve_picotool():
|
|||
sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n")
|
||||
sys.exit(1)
|
||||
|
||||
def build(
|
||||
aio,
|
||||
build_dir,
|
||||
elf_path,
|
||||
uf2_path,
|
||||
firmware_elf_path,
|
||||
firmware_uf2_path,
|
||||
):
|
||||
if aio:
|
||||
run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")])
|
||||
definitions = [
|
||||
"-DSWITCH_PICO_LOG=OFF",
|
||||
"-DPICO_BOARD=pico2_w",
|
||||
"-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32",
|
||||
]
|
||||
else:
|
||||
definitions = [
|
||||
"-DSWITCH_PICO_LOG=OFF",
|
||||
"-DPICO_BOARD=pico",
|
||||
"-DSWITCH_PICO_INPUT_BACKEND=UART",
|
||||
]
|
||||
|
||||
def build():
|
||||
run_cmd(
|
||||
[
|
||||
"cmake",
|
||||
"-S",
|
||||
str(SCRIPT_DIR),
|
||||
"-B",
|
||||
str(build_dir),
|
||||
*definitions,
|
||||
str(BUILD_DIR),
|
||||
"-DSWITCH_PICO_LOG=OFF",
|
||||
]
|
||||
)
|
||||
run_cmd(["cmake", "--build", str(build_dir)])
|
||||
run_cmd(["cmake", "--build", str(BUILD_DIR)])
|
||||
|
||||
missing_artifacts = [
|
||||
path for path in (elf_path, uf2_path) if not path.is_file()
|
||||
]
|
||||
if missing_artifacts:
|
||||
missing = ", ".join(str(path) for path in missing_artifacts)
|
||||
sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n")
|
||||
sys.exit(1)
|
||||
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(elf_path, firmware_elf_path)
|
||||
shutil.copy2(uf2_path, firmware_uf2_path)
|
||||
|
||||
print(f"Built ELF: {elf_path}")
|
||||
print(f"Built UF2: {uf2_path}")
|
||||
print(f"Copied ELF: {firmware_elf_path}")
|
||||
print(f"Copied UF2: {firmware_uf2_path}")
|
||||
|
||||
|
||||
def flash(elf_path, allow_elf_override):
|
||||
def flash():
|
||||
picotool = resolve_picotool()
|
||||
if not elf_path.exists():
|
||||
if allow_elf_override:
|
||||
sys.stderr.write(
|
||||
f"Error: Cannot find ELF at {elf_path}. Set ELF_PATH to override.\n"
|
||||
)
|
||||
else:
|
||||
sys.stderr.write(f"Error: Cannot find ELF at {elf_path}.\n")
|
||||
if not ELF_PATH.exists():
|
||||
sys.stderr.write(
|
||||
f"Error: Cannot find ELF at {ELF_PATH}. Set ELF_PATH to override.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
run_cmd([str(picotool), "load", str(elf_path), "-fx"])
|
||||
run_cmd([str(picotool), "load", str(ELF_PATH), "-fx"])
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
|
@ -196,28 +146,8 @@ def main():
|
|||
update_grip_colors(color)
|
||||
print(f"Grip color set to #{color} in {CONFIG_FILE.name}")
|
||||
|
||||
if args.aio:
|
||||
build_dir = AIO_BUILD_DIR
|
||||
elf_path = AIO_BUILD_DIR / "switch-pico.elf"
|
||||
uf2_path = AIO_BUILD_DIR / "switch-pico.uf2"
|
||||
firmware_elf_path = AIO_FIRMWARE_ELF_PATH
|
||||
firmware_uf2_path = AIO_FIRMWARE_UF2_PATH
|
||||
else:
|
||||
build_dir = BUILD_DIR
|
||||
elf_path = ELF_PATH
|
||||
uf2_path = UF2_PATH
|
||||
firmware_elf_path = FIRMWARE_ELF_PATH
|
||||
firmware_uf2_path = FIRMWARE_UF2_PATH
|
||||
|
||||
build(
|
||||
args.aio,
|
||||
build_dir,
|
||||
elf_path,
|
||||
uf2_path,
|
||||
firmware_elf_path,
|
||||
firmware_uf2_path,
|
||||
)
|
||||
flash(elf_path, allow_elf_override=not args.aio)
|
||||
build()
|
||||
flash()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,31 +1,25 @@
|
|||
// Compile-time Switch grip colors. Physical controller lightbar values are
|
||||
// derived automatically; each value here is an 8-bit RGB component.
|
||||
// Optional override for Switch Pro colour fields.
|
||||
// Copy/modify the values below and rebuild to change how the controller appears on the Switch.
|
||||
// Each value is an 8-bit RGB component.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Body shell color
|
||||
// Body shell colour
|
||||
#define SWITCH_COLOR_BODY_R 0x1B
|
||||
#define SWITCH_COLOR_BODY_G 0x1B
|
||||
#define SWITCH_COLOR_BODY_B 0x1D
|
||||
|
||||
// Face/button cluster color
|
||||
// Face/button cluster colour
|
||||
#define SWITCH_COLOR_BUTTON_R 0xFF
|
||||
#define SWITCH_COLOR_BUTTON_G 0xFF
|
||||
#define SWITCH_COLOR_BUTTON_B 0xFF
|
||||
|
||||
// Per-slot Switch grip colors: blue, red, yellow, green.
|
||||
#define SWITCH_COLOR_SLOT_1_R 0x00
|
||||
#define SWITCH_COLOR_SLOT_1_G 0x89
|
||||
#define SWITCH_COLOR_SLOT_1_B 0xEB
|
||||
// Left grip colour
|
||||
#define SWITCH_COLOR_LEFT_GRIP_R 0x00
|
||||
#define SWITCH_COLOR_LEFT_GRIP_G 0x89
|
||||
#define SWITCH_COLOR_LEFT_GRIP_B 0xEB
|
||||
|
||||
#define SWITCH_COLOR_SLOT_2_R 0xE6
|
||||
#define SWITCH_COLOR_SLOT_2_G 0x39
|
||||
#define SWITCH_COLOR_SLOT_2_B 0x46
|
||||
|
||||
#define SWITCH_COLOR_SLOT_3_R 0xF6
|
||||
#define SWITCH_COLOR_SLOT_3_G 0xC9
|
||||
#define SWITCH_COLOR_SLOT_3_B 0x45
|
||||
|
||||
#define SWITCH_COLOR_SLOT_4_R 0x2E
|
||||
#define SWITCH_COLOR_SLOT_4_G 0xCC
|
||||
#define SWITCH_COLOR_SLOT_4_B 0x71
|
||||
// Right grip colour
|
||||
#define SWITCH_COLOR_RIGHT_GRIP_R 0x00
|
||||
#define SWITCH_COLOR_RIGHT_GRIP_G 0x89
|
||||
#define SWITCH_COLOR_RIGHT_GRIP_B 0xEB
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
// Compile-time AIO controller hotkey configuration.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Bluepad32 button masks. Default chord: L + R + SELECT + START.
|
||||
#define SWITCH_ABXY_HOTKEY_BUTTON_MASK \
|
||||
(BUTTON_SHOULDER_L | BUTTON_SHOULDER_R)
|
||||
#define SWITCH_ABXY_HOTKEY_MISC_MASK \
|
||||
(MISC_BUTTON_SELECT | MISC_BUTTON_START)
|
||||
|
||||
// Motion toggle chord: D-pad Up + R + START / Options.
|
||||
#define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP
|
||||
#define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R
|
||||
#define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START
|
||||
#define SWITCH_MOTION_DEFAULT_ENABLED 1
|
||||
|
||||
// 0 starts each new connection in Nintendo positional layout; 1 starts swapped.
|
||||
#define SWITCH_ABXY_DEFAULT_SWAPPED 0
|
||||
|
||||
// Local confirmation pulse sent only to the controller that toggled.
|
||||
#define SWITCH_ABXY_FEEDBACK_DURATION_MS 120
|
||||
#define SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE 0xFF
|
||||
#define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0xFF
|
||||
|
||||
// A longer pulse confirms disabled; a shorter pulse confirms enabled.
|
||||
#define SWITCH_MOTION_DISABLED_FEEDBACK_DURATION_MS 180
|
||||
#define SWITCH_MOTION_DISABLED_FEEDBACK_WEAK_MAGNITUDE 0xA0
|
||||
#define SWITCH_MOTION_DISABLED_FEEDBACK_STRONG_MAGNITUDE 0xA0
|
||||
#define SWITCH_MOTION_ENABLED_FEEDBACK_DURATION_MS 80
|
||||
#define SWITCH_MOTION_ENABLED_FEEDBACK_WEAK_MAGNITUDE 0x60
|
||||
#define SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE 0x60
|
||||
1
external/bluepad32
vendored
1
external/bluepad32
vendored
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 6efa7123fe8badf5a40ad1205743a80b31c00ea4
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,568 +0,0 @@
|
|||
diff --git a/src/components/bluepad32/bt/uni_bt_bredr.c b/src/components/bluepad32/bt/uni_bt_bredr.c
|
||||
index 955cc6f..4013cc1 100644
|
||||
--- a/src/components/bluepad32/bt/uni_bt_bredr.c
|
||||
+++ b/src/components/bluepad32/bt/uni_bt_bredr.c
|
||||
@@ -423,13 +423,14 @@ void uni_bt_bredr_on_l2cap_channel_opened(uint16_t channel, const uint8_t* packe
|
||||
status = l2cap_event_channel_opened_get_status(packet);
|
||||
if (status) {
|
||||
logi("L2CAP Connection failed: 0x%02x.\n", status);
|
||||
- // Practice showed that if the connection fails, just disconnect/remove
|
||||
- // so that the connection can start again.
|
||||
+ // Channel-open failures also include transient page timeouts when a
|
||||
+ // paired controller powers down or is temporarily unreachable. Keep
|
||||
+ // the persistent key so the controller can reconnect later. Users can
|
||||
+ // remove genuinely stale keys through the explicit pairing reset.
|
||||
if (status == L2CAP_CONNECTION_RESPONSE_RESULT_REFUSED_SECURITY) {
|
||||
logi("Probably GAP-security-related issues. Set GAP security to 2\n");
|
||||
}
|
||||
- logi("Removing key for device: %s.\n", bd_addr_to_str(address));
|
||||
- gap_drop_link_key_for_bd_addr(device->conn.btaddr);
|
||||
+ logi("Removing failed device instance for: %s; preserving link key.\n", bd_addr_to_str(address));
|
||||
uni_hid_device_disconnect(device);
|
||||
uni_hid_device_delete(device);
|
||||
/* 'device' is destroyed, don't use */
|
||||
diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h
|
||||
index 6af4969..0aebb0a 100644
|
||||
--- a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h
|
||||
+++ b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h
|
||||
@@ -14,6 +14,8 @@
|
||||
void uni_hid_parser_psmove_setup(struct uni_hid_device_s* d);
|
||||
void uni_hid_parser_psmove_init_report(struct uni_hid_device_s* d);
|
||||
void uni_hid_parser_psmove_parse_input_report(struct uni_hid_device_s* d, const uint8_t* report, uint16_t len);
|
||||
+void uni_hid_parser_psmove_parse_feature_report(
|
||||
+ struct uni_hid_device_s* d, const uint8_t* report, uint16_t len);
|
||||
void uni_hid_parser_psmove_set_lightbar_color(struct uni_hid_device_s* d, uint8_t r, uint8_t g, uint8_t b);
|
||||
void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d,
|
||||
uint16_t start_delay_ms,
|
||||
diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c
|
||||
index ea063b8..7670caf 100644
|
||||
--- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c
|
||||
+++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c
|
||||
@@ -297,17 +297,17 @@ void uni_hid_parser_ds4_parse_feature_report(uni_hid_device_t* d, const uint8_t*
|
||||
// Set gyroscope calibration and normalization parameters.
|
||||
// Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s.
|
||||
speed_2x = r->gyro_speed_plus + r->gyro_speed_minus;
|
||||
- ins->gyro_calib_data[0].bias = 0;
|
||||
+ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias;
|
||||
ins->gyro_calib_data[0].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[0].sens_denom =
|
||||
abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias);
|
||||
|
||||
- ins->gyro_calib_data[1].bias = 0;
|
||||
+ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias;
|
||||
ins->gyro_calib_data[1].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[1].sens_denom =
|
||||
abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias);
|
||||
|
||||
- ins->gyro_calib_data[2].bias = 0;
|
||||
+ ins->gyro_calib_data[2].bias = r->gyro_roll_bias;
|
||||
ins->gyro_calib_data[2].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[2].sens_denom =
|
||||
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
|
||||
@@ -476,7 +476,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor
|
||||
|
||||
// Gyro
|
||||
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
|
||||
- int32_t raw_data = (int16_t)r->gyro[i];
|
||||
+ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias;
|
||||
int32_t calib_data =
|
||||
mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom);
|
||||
ctl->gamepad.gyro[i] = calib_data;
|
||||
@@ -484,7 +484,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor
|
||||
|
||||
// Accel
|
||||
for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) {
|
||||
- int32_t raw_data = (int16_t)r->accel[i];
|
||||
+ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias;
|
||||
int32_t calib_data =
|
||||
mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom);
|
||||
ctl->gamepad.accel[i] = calib_data;
|
||||
diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c
|
||||
index a22ef26..3d5ecef 100644
|
||||
--- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c
|
||||
+++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c
|
||||
@@ -487,17 +487,17 @@ void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t*
|
||||
// Set gyroscope calibration and normalization parameters.
|
||||
// Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s.
|
||||
speed_2x = r->gyro_speed_plus + r->gyro_speed_minus;
|
||||
- ins->gyro_calib_data[0].bias = 0;
|
||||
+ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias;
|
||||
ins->gyro_calib_data[0].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[0].sens_denom =
|
||||
abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias);
|
||||
|
||||
- ins->gyro_calib_data[1].bias = 0;
|
||||
+ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias;
|
||||
ins->gyro_calib_data[1].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[1].sens_denom =
|
||||
abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias);
|
||||
|
||||
- ins->gyro_calib_data[2].bias = 0;
|
||||
+ ins->gyro_calib_data[2].bias = r->gyro_roll_bias;
|
||||
ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[2].sens_denom =
|
||||
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
|
||||
@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
|
||||
|
||||
// Gyro
|
||||
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
|
||||
- int32_t raw_data = (int16_t)r->gyro[i];
|
||||
+ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias;
|
||||
int32_t calib_data =
|
||||
mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom);
|
||||
ctl->gamepad.gyro[i] = calib_data;
|
||||
@@ -630,7 +630,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
|
||||
|
||||
// Accel
|
||||
for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) {
|
||||
- int32_t raw_data = (int16_t)r->accel[i];
|
||||
+ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias;
|
||||
int32_t calib_data =
|
||||
mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom);
|
||||
ctl->gamepad.accel[i] = calib_data;
|
||||
diff --git a/src/components/bluepad32/parser/uni_hid_parser_psmove.c b/src/components/bluepad32/parser/uni_hid_parser_psmove.c
|
||||
index 0265f93..5c0f2bb 100644
|
||||
--- a/src/components/bluepad32/parser/uni_hid_parser_psmove.c
|
||||
+++ b/src/components/bluepad32/parser/uni_hid_parser_psmove.c
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
#include "parser/uni_hid_parser_psmove.h"
|
||||
+#include "parser/uni_hid_parser_imu.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
@@ -27,11 +28,6 @@ typedef enum psmove_fsm {
|
||||
PSMOVE_FSM_LED_UPDATED, // LED updated
|
||||
} psmove_fsm_t;
|
||||
|
||||
-typedef enum psmove_model {
|
||||
- PSMOVE_MODEL_UNK,
|
||||
- PSMOVE_MODEL_ZCM1,
|
||||
- PSMOVE_MODEL_ZCM2,
|
||||
-} psmove_model_t;
|
||||
|
||||
typedef enum {
|
||||
PSMOVE_STATE_RUMBLE_DISABLED,
|
||||
@@ -41,9 +37,10 @@ typedef enum {
|
||||
|
||||
// psmove_instance_t represents data used by the psmove driver instance.
|
||||
typedef struct psmove_instance_s {
|
||||
- psmove_model_t model;
|
||||
+ uni_psmove_imu_model_t model;
|
||||
psmove_fsm_t state;
|
||||
uint8_t led_rgb[3];
|
||||
+ uni_psmove_imu_calibration_t imu_calibration;
|
||||
|
||||
btstack_timer_source_t rumble_timer_duration;
|
||||
btstack_timer_source_t rumble_timer_delayed_start;
|
||||
@@ -127,6 +124,7 @@ static void psmove_send_output_report(uni_hid_device_t* d, psmove_output_report_
|
||||
static void on_psmove_set_rumble_on(btstack_timer_source_t* ts);
|
||||
static void on_psmove_set_rumble_off(btstack_timer_source_t* ts);
|
||||
static void psmove_play_dual_rumble_now(uni_hid_device_t* d, uint16_t duration_ms, uint8_t magnitude);
|
||||
+static void psmove_request_calibration_report(uni_hid_device_t* d);
|
||||
|
||||
void uni_hid_parser_psmove_init_report(uni_hid_device_t* d) {
|
||||
uni_controller_t* ctl = &d->controller;
|
||||
@@ -154,6 +152,7 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t
|
||||
}
|
||||
|
||||
uni_controller_t* ctl = &d->controller;
|
||||
+ psmove_instance_t* ins = get_psmove_instance(d);
|
||||
|
||||
// Buttons
|
||||
if (r->buttons[0] & 0x01)
|
||||
@@ -187,18 +186,39 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t
|
||||
|
||||
ctl->gamepad.throttle = r->trigger * 4;
|
||||
|
||||
- ctl->gamepad.accel[0] = r->accel_x;
|
||||
- ctl->gamepad.accel[1] = r->accel_y;
|
||||
- ctl->gamepad.accel[2] = r->accel_z;
|
||||
-
|
||||
- ctl->gamepad.gyro[0] = r->gyro_x;
|
||||
- ctl->gamepad.gyro[1] = r->gyro_y;
|
||||
- ctl->gamepad.gyro[2] = r->gyro_z;
|
||||
+ const uint16_t accel_first[3] = {r->accel_x, r->accel_y, r->accel_z};
|
||||
+ const uint16_t accel_second[3] = {
|
||||
+ r->accel_x2, r->accel_y2, r->accel_z2};
|
||||
+ const uint16_t gyro_first[3] = {r->gyro_x, r->gyro_y, r->gyro_z};
|
||||
+ const uint16_t gyro_second[3] = {
|
||||
+ r->gyro_x2, r->gyro_y2, r->gyro_z2};
|
||||
+ uni_imu_fixed_sample_t motion;
|
||||
+ if (uni_psmove_normalize_imu(
|
||||
+ ins->model, &ins->imu_calibration, accel_first, accel_second,
|
||||
+ gyro_first, gyro_second, &motion)) {
|
||||
+ memcpy(ctl->gamepad.accel, motion.accel, sizeof(motion.accel));
|
||||
+ memcpy(ctl->gamepad.gyro, motion.gyro, sizeof(motion.gyro));
|
||||
+ }
|
||||
|
||||
if (r->battery <= 5)
|
||||
ctl->battery = r->battery * 51;
|
||||
}
|
||||
|
||||
+void uni_hid_parser_psmove_parse_feature_report(
|
||||
+ uni_hid_device_t* d, const uint8_t* report, uint16_t len) {
|
||||
+ psmove_instance_t* ins = get_psmove_instance(d);
|
||||
+ const uni_psmove_calibration_result_t result =
|
||||
+ uni_psmove_add_calibration_report(
|
||||
+ &ins->imu_calibration, ins->model, report, len);
|
||||
+ if (result == UNI_PSMOVE_CALIBRATION_INCOMPLETE) {
|
||||
+ psmove_request_calibration_report(d);
|
||||
+ } else if (result == UNI_PSMOVE_CALIBRATION_COMPLETE) {
|
||||
+ logi("psmove: IMU calibration ready\n");
|
||||
+ } else if (result == UNI_PSMOVE_CALIBRATION_INVALID) {
|
||||
+ loge("psmove: invalid IMU calibration; motion disabled\n");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d,
|
||||
uint16_t start_delay_ms,
|
||||
uint16_t duration_ms,
|
||||
@@ -261,25 +281,34 @@ void uni_hid_parser_psmove_setup(struct uni_hid_device_s* d) {
|
||||
|
||||
switch (d->product_id) {
|
||||
case ZCM1_PID:
|
||||
- ins->model = PSMOVE_MODEL_ZCM1;
|
||||
+ ins->model = UNI_PSMOVE_IMU_MODEL_ZCM1;
|
||||
logi("psmove: Detected ZCM1 model\n");
|
||||
break;
|
||||
case ZCM2_PID:
|
||||
- ins->model = PSMOVE_MODEL_ZCM2;
|
||||
+ ins->model = UNI_PSMOVE_IMU_MODEL_ZCM2;
|
||||
logi("psmove: Detected ZCM2 model\n");
|
||||
break;
|
||||
default:
|
||||
- loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", ins->model);
|
||||
- ins->model = PSMOVE_MODEL_ZCM1;
|
||||
+ loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", d->product_id);
|
||||
+ ins->model = UNI_PSMOVE_IMU_MODEL_ZCM1;
|
||||
break;
|
||||
}
|
||||
|
||||
+ psmove_request_calibration_report(d);
|
||||
uni_hid_device_set_ready_complete(d);
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
+static void psmove_request_calibration_report(uni_hid_device_t* d) {
|
||||
+ static const uint8_t report[] = {
|
||||
+ ((HID_MESSAGE_TYPE_GET_REPORT << 4) | HID_REPORT_TYPE_FEATURE),
|
||||
+ 0x10,
|
||||
+ };
|
||||
+ uni_hid_device_send_ctrl_report(d, report, sizeof(report));
|
||||
+}
|
||||
+
|
||||
static psmove_instance_t* get_psmove_instance(uni_hid_device_t* d) {
|
||||
return (psmove_instance_t*)&d->parser_data[0];
|
||||
}
|
||||
diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/components/bluepad32/parser/uni_hid_parser_switch.c
|
||||
index 599fc35..9f073b4 100644
|
||||
--- a/src/components/bluepad32/parser/uni_hid_parser_switch.c
|
||||
+++ b/src/components/bluepad32/parser/uni_hid_parser_switch.c
|
||||
@@ -51,13 +51,15 @@ static const int16_t DEFAULT_ACCEL_OFFSET = 0;
|
||||
static const int16_t DEFAULT_ACCEL_SCALE = 16384;
|
||||
static const int16_t DEFAULT_GYRO_OFFSET = 0;
|
||||
static const int16_t DEFAULT_GYRO_SCALE = 13371;
|
||||
-#define SWITCH_IMU_PREC_RANGE_SCALE 1000
|
||||
+#define SWITCH_IMU_GYRO_RES_PER_DEG_S 1024
|
||||
+#define SWITCH_IMU_ACCEL_RES_PER_G 8192
|
||||
|
||||
#define SWITCH_FACTORY_IMU_CAL_DATA_SIZE 24
|
||||
static const uint16_t SWITCH_FACTORY_IMU_CAL_DATA_ADDR = 0x6020;
|
||||
|
||||
#define SWITCH_DUMP_ROM_DATA_SIZE 24 // Max size is 24
|
||||
#define SWITCH_SETUP_TIMEOUT_MS 800
|
||||
+#define SWITCH_RUMBLE_REFRESH_MS 40
|
||||
#if ENABLE_SPI_FLASH_DUMP
|
||||
static const uint32_t SWITCH_DUMP_ROM_DATA_ADDR_START = 0x20000;
|
||||
static const uint32_t SWITCH_DUMP_ROM_DATA_ADDR_END = 0x30000;
|
||||
@@ -72,6 +74,7 @@ enum switch_state {
|
||||
STATE_READ_FACTORY_IMU_CALIBRATION, // Factory IMU calibration info
|
||||
STATE_SET_FULL_REPORT, // Request report 0x30
|
||||
STATE_ENABLE_IMU, // Enable/Disable gyro/accel
|
||||
+ STATE_ENABLE_RUMBLE, // Enable controller vibration
|
||||
STATE_DUMP_FLASH, // Dump SPI Flash memory
|
||||
STATE_UPDATE_LED, // Update LEDs
|
||||
STATE_READY, // Gamepad setup ready!
|
||||
@@ -111,6 +114,7 @@ enum switch_subcmd {
|
||||
SUBCMD_SPI_FLASH_READ = 0x10,
|
||||
SUBCMD_SET_PLAYER_LEDS = 0x30,
|
||||
SUBCMD_ENABLE_IMU = 0x40,
|
||||
+ SUBCMD_ENABLE_RUMBLE = 0x48,
|
||||
};
|
||||
|
||||
typedef enum {
|
||||
@@ -137,6 +141,7 @@ typedef struct switch_instance_s {
|
||||
// Although technically, we can use one timer for delay and duration, easier to debug/maintain if we have two.
|
||||
btstack_timer_source_t rumble_timer_duration;
|
||||
btstack_timer_source_t rumble_timer_delayed_start;
|
||||
+ btstack_timer_source_t rumble_timer_refresh;
|
||||
switch_state_rumble_t rumble_state;
|
||||
|
||||
btstack_timer_source_t setup_timer;
|
||||
@@ -322,6 +327,7 @@ static void fsm_read_user_stick_calibration(struct uni_hid_device_s* d);
|
||||
static void fsm_read_factory_imu_calibration(struct uni_hid_device_s* d);
|
||||
static void fsm_set_full_report(struct uni_hid_device_s* d);
|
||||
static void fsm_enable_imu(struct uni_hid_device_s* d);
|
||||
+static void fsm_enable_rumble(struct uni_hid_device_s* d);
|
||||
static void fsm_update_led(struct uni_hid_device_s* d);
|
||||
static void fsm_ready(struct uni_hid_device_s* d);
|
||||
static void process_reply_read_spi_dump(struct uni_hid_device_s* d, const uint8_t* data, int len);
|
||||
@@ -333,11 +339,16 @@ static void process_reply_set_report_mode(struct uni_hid_device_s* d, const stru
|
||||
static void process_reply_spi_flash_read(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
|
||||
static void process_reply_set_player_leds(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
|
||||
static void process_reply_enable_imu(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
|
||||
+static void process_reply_enable_rumble(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
|
||||
static int32_t calibrate_axis(int32_t v, switch_cal_stick_t cal);
|
||||
static void set_led(uni_hid_device_t* d, uint8_t leds);
|
||||
static void on_switch_set_rumble_on(btstack_timer_source_t* ts);
|
||||
static void on_switch_set_rumble_off(btstack_timer_source_t* ts);
|
||||
+static void on_switch_refresh_rumble(btstack_timer_source_t* ts);
|
||||
static void switch_stop_rumble_now(uni_hid_device_t* d);
|
||||
+static void switch_send_dual_rumble_now(uni_hid_device_t* d,
|
||||
+ uint8_t weak_magnitude,
|
||||
+ uint8_t strong_magnitude);
|
||||
static void switch_play_dual_rumble_now(uni_hid_device_t* d,
|
||||
uint16_t duration_ms,
|
||||
uint8_t weak_magnitude,
|
||||
@@ -451,6 +462,10 @@ static void process_fsm(struct uni_hid_device_s* d) {
|
||||
break;
|
||||
case STATE_ENABLE_IMU:
|
||||
logd("STATE_ENABLE_IMU\n");
|
||||
+ fsm_enable_rumble(d);
|
||||
+ break;
|
||||
+ case STATE_ENABLE_RUMBLE:
|
||||
+ logd("STATE_ENABLE_RUMBLE\n");
|
||||
fsm_dump_rom(d);
|
||||
break;
|
||||
case STATE_DUMP_FLASH:
|
||||
@@ -725,6 +740,12 @@ static void process_reply_enable_imu(struct uni_hid_device_s* d, const struct sw
|
||||
ARG_UNUSED(r);
|
||||
ARG_UNUSED(len);
|
||||
}
|
||||
+static void process_reply_enable_rumble(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len) {
|
||||
+ ARG_UNUSED(d);
|
||||
+ ARG_UNUSED(r);
|
||||
+ ARG_UNUSED(len);
|
||||
+}
|
||||
+
|
||||
|
||||
// Process 0x21 input report: SWITCH_INPUT_SUBCMD_REPLY
|
||||
static void process_input_subcmd_reply(struct uni_hid_device_s* d, const uint8_t* report, int len) {
|
||||
@@ -752,6 +773,9 @@ static void process_input_subcmd_reply(struct uni_hid_device_s* d, const uint8_t
|
||||
case SUBCMD_ENABLE_IMU:
|
||||
process_reply_enable_imu(d, r, len);
|
||||
break;
|
||||
+ case SUBCMD_ENABLE_RUMBLE:
|
||||
+ process_reply_enable_rumble(d, r, len);
|
||||
+ break;
|
||||
default:
|
||||
loge("Switch: Error, unexpected subcmd_id=0x%02x in report 0x21\n", r->subcmd_id);
|
||||
break;
|
||||
@@ -823,19 +847,26 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
|
||||
switch_instance_t* ins = get_switch_instance(d);
|
||||
uni_controller_t* ctl = &d->controller;
|
||||
|
||||
- int accel[3];
|
||||
- int gyro[3];
|
||||
+ int32_t accel[3];
|
||||
+ int32_t gyro[3];
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
- if (ins->imu_cal_accel_divisor[i] == 0)
|
||||
- accel[i] = r->accel[i];
|
||||
- else
|
||||
- accel[i] = (r->accel[i] * ins->cal_accel.scale[i]) / ins->imu_cal_accel_divisor[i];
|
||||
- gyro[i] = mult_frac((SWITCH_IMU_PREC_RANGE_SCALE * (r->gyro[i] - ins->cal_gyro.offset[i])),
|
||||
- ins->cal_gyro.scale[i], ins->imu_cal_gyro_divisor[i]);
|
||||
+ if (ins->imu_cal_accel_divisor[i] == 0) {
|
||||
+ accel[i] = r->accel[i] * 2;
|
||||
+ } else {
|
||||
+ accel[i] = mult_frac(r->accel[i], 4 * SWITCH_IMU_ACCEL_RES_PER_G, ins->imu_cal_accel_divisor[i]);
|
||||
+ }
|
||||
+
|
||||
+ if (ins->imu_cal_gyro_divisor[i] == 0) {
|
||||
+ gyro[i] = mult_frac(r->gyro[i], 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S, DEFAULT_GYRO_SCALE);
|
||||
+ } else {
|
||||
+ gyro[i] = mult_frac(r->gyro[i] - ins->cal_gyro.offset[i],
|
||||
+ 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S,
|
||||
+ ins->imu_cal_gyro_divisor[i]);
|
||||
+ }
|
||||
}
|
||||
|
||||
- // Right joycon has Y and Z axes negated.
|
||||
+ // Right Joy-Con has native Y and Z axes negated.
|
||||
if (ins->controller_type == SWITCH_CONTROLLER_TYPE_JCR) {
|
||||
accel[1] = -accel[1];
|
||||
accel[2] = -accel[2];
|
||||
@@ -843,10 +874,13 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
|
||||
gyro[2] = -gyro[2];
|
||||
}
|
||||
|
||||
- for (int i = 0; i < 3; i++) {
|
||||
- ctl->gamepad.accel[i] = accel[i];
|
||||
- ctl->gamepad.gyro[i] = gyro[i];
|
||||
- }
|
||||
+ // Match SDL3's PlayStation-oriented sensor coordinate convention.
|
||||
+ ctl->gamepad.accel[0] = -accel[1];
|
||||
+ ctl->gamepad.accel[1] = accel[2];
|
||||
+ ctl->gamepad.accel[2] = -accel[0];
|
||||
+ ctl->gamepad.gyro[0] = -gyro[1];
|
||||
+ ctl->gamepad.gyro[1] = gyro[2];
|
||||
+ ctl->gamepad.gyro[2] = -gyro[0];
|
||||
}
|
||||
|
||||
// Process 0x30 input report: SWITCH_INPUT_IMU_DATA
|
||||
@@ -1172,6 +1206,18 @@ static void fsm_enable_imu(struct uni_hid_device_s* d) {
|
||||
req->data[0] = (ins->mode == SWITCH_MODE_IMU);
|
||||
send_subcmd(d, req, sizeof(out));
|
||||
}
|
||||
+static void fsm_enable_rumble(struct uni_hid_device_s* d) {
|
||||
+ switch_instance_t* ins = get_switch_instance(d);
|
||||
+ ins->state = STATE_ENABLE_RUMBLE;
|
||||
+
|
||||
+ uint8_t out[sizeof(struct switch_subcmd_request) + 1] = {0};
|
||||
+ struct switch_subcmd_request* req = (struct switch_subcmd_request*)&out[0];
|
||||
+ req->report_id = OUTPUT_RUMBLE_AND_SUBCMD;
|
||||
+ req->subcmd_id = SUBCMD_ENABLE_RUMBLE;
|
||||
+ req->data[0] = 0x01;
|
||||
+ send_subcmd(d, req, sizeof(out));
|
||||
+}
|
||||
+
|
||||
|
||||
static void fsm_update_led(struct uni_hid_device_s* d) {
|
||||
switch_instance_t* ins = get_switch_instance(d);
|
||||
@@ -1203,6 +1249,10 @@ static struct switch_rumble_freq_data find_rumble_freq(uint16_t freq) {
|
||||
return rumble_freqs[i];
|
||||
}
|
||||
|
||||
+static uint16_t switch_magnitude_to_amp(uint8_t magnitude) {
|
||||
+ return (uint16_t)(((uint32_t)magnitude * 1003 + 127) / 255);
|
||||
+}
|
||||
+
|
||||
static struct switch_rumble_amp_data find_rumble_amp(uint16_t amp) {
|
||||
unsigned int i = 0;
|
||||
if (amp > rumble_amps[0].amp) {
|
||||
@@ -1259,6 +1309,7 @@ void uni_hid_parser_switch_play_dual_rumble(struct uni_hid_device_s* d,
|
||||
break;
|
||||
case SWITCH_STATE_RUMBLE_IN_PROGRESS:
|
||||
btstack_run_loop_remove_timer(&ins->rumble_timer_duration);
|
||||
+ btstack_run_loop_remove_timer(&ins->rumble_timer_refresh);
|
||||
break;
|
||||
default:
|
||||
// Do nothing
|
||||
@@ -1366,6 +1417,7 @@ static void switch_stop_rumble_now(uni_hid_device_t* d) {
|
||||
|
||||
// No need to protect it with a mutex since it runs in the same main thread
|
||||
assert(ins->rumble_state == SWITCH_STATE_RUMBLE_IN_PROGRESS);
|
||||
+ btstack_run_loop_remove_timer(&ins->rumble_timer_refresh);
|
||||
ins->rumble_state = SWITCH_STATE_RUMBLE_DISABLED;
|
||||
|
||||
struct switch_subcmd_request req = {0};
|
||||
@@ -1379,6 +1431,22 @@ static void switch_stop_rumble_now(uni_hid_device_t* d) {
|
||||
send_subcmd(d, (struct switch_subcmd_request*)&req, sizeof(req) - 1);
|
||||
}
|
||||
|
||||
+static void switch_send_dual_rumble_now(uni_hid_device_t* d,
|
||||
+ uint8_t weak_magnitude,
|
||||
+ uint8_t strong_magnitude) {
|
||||
+ struct switch_subcmd_request req = {
|
||||
+ .report_id = OUTPUT_RUMBLE_ONLY,
|
||||
+ };
|
||||
+ // Fixed frequencies match the standard Switch LRA envelope and the
|
||||
+ // 8BitDo Switch-mode implementation. Magnitudes control amplitude only.
|
||||
+ switch_encode_rumble(req.rumble_left, 453, 135,
|
||||
+ switch_magnitude_to_amp(weak_magnitude));
|
||||
+ switch_encode_rumble(req.rumble_right, 453, 99,
|
||||
+ switch_magnitude_to_amp(strong_magnitude));
|
||||
+ // Rumble request don't include the last byte of "switch_subcmd_request": subcmd_id
|
||||
+ send_subcmd(d, &req, sizeof(req) - 1);
|
||||
+}
|
||||
+
|
||||
static void switch_play_dual_rumble_now(uni_hid_device_t* d,
|
||||
uint16_t duration_ms,
|
||||
uint8_t weak_magnitude,
|
||||
@@ -1391,14 +1459,17 @@ static void switch_play_dual_rumble_now(uni_hid_device_t* d,
|
||||
return;
|
||||
}
|
||||
|
||||
- struct switch_subcmd_request req = {
|
||||
- .report_id = OUTPUT_RUMBLE_ONLY,
|
||||
- };
|
||||
- switch_encode_rumble(req.rumble_left, weak_magnitude << 2, weak_magnitude, 500);
|
||||
- switch_encode_rumble(req.rumble_right, strong_magnitude << 2, strong_magnitude, 500);
|
||||
+ ins->rumble_weak_magnitude = weak_magnitude;
|
||||
+ ins->rumble_strong_magnitude = strong_magnitude;
|
||||
+ switch_send_dual_rumble_now(d, weak_magnitude, strong_magnitude);
|
||||
|
||||
- // Rumble request don't include the last byte of "switch_subcmd_request": subcmd_id
|
||||
- send_subcmd(d, &req, sizeof(req) - 1);
|
||||
+ // Refresh active rumble for Switch-compatible controllers that do not
|
||||
+ // retain a single output packet, including 8BitDo Switch mode.
|
||||
+ ins->rumble_timer_refresh.process = &on_switch_refresh_rumble;
|
||||
+ ins->rumble_timer_refresh.context = d;
|
||||
+ btstack_run_loop_set_timer(&ins->rumble_timer_refresh,
|
||||
+ SWITCH_RUMBLE_REFRESH_MS);
|
||||
+ btstack_run_loop_add_timer(&ins->rumble_timer_refresh);
|
||||
|
||||
// Set timer to turn off rumble
|
||||
ins->rumble_timer_duration.process = &on_switch_set_rumble_off;
|
||||
@@ -1414,6 +1485,20 @@ static void on_switch_set_rumble_on(btstack_timer_source_t* ts) {
|
||||
|
||||
switch_play_dual_rumble_now(d, ins->rumble_duration_ms, ins->rumble_weak_magnitude, ins->rumble_strong_magnitude);
|
||||
}
|
||||
+static void on_switch_refresh_rumble(btstack_timer_source_t* ts) {
|
||||
+ uni_hid_device_t* d = btstack_run_loop_get_timer_context(ts);
|
||||
+ switch_instance_t* ins = get_switch_instance(d);
|
||||
+ if (ins->rumble_state != SWITCH_STATE_RUMBLE_IN_PROGRESS) {
|
||||
+ return;
|
||||
+ }
|
||||
+ switch_send_dual_rumble_now(
|
||||
+ d, (uint8_t)ins->rumble_weak_magnitude,
|
||||
+ (uint8_t)ins->rumble_strong_magnitude);
|
||||
+ btstack_run_loop_set_timer(&ins->rumble_timer_refresh,
|
||||
+ SWITCH_RUMBLE_REFRESH_MS);
|
||||
+ btstack_run_loop_add_timer(&ins->rumble_timer_refresh);
|
||||
+}
|
||||
+
|
||||
|
||||
static void on_switch_set_rumble_off(btstack_timer_source_t* ts) {
|
||||
uni_hid_device_t* d = btstack_run_loop_get_timer_context(ts);
|
||||
diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c
|
||||
index be2103e..4819639 100644
|
||||
--- a/src/components/bluepad32/parser/uni_hid_parser_wii.c
|
||||
+++ b/src/components/bluepad32/parser/uni_hid_parser_wii.c
|
||||
@@ -19,6 +19,7 @@
|
||||
#endif // ENABLE_EEPROM_DUMP
|
||||
|
||||
#include "parser/uni_hid_parser_wii.h"
|
||||
+#include "parser/uni_hid_parser_imu.h"
|
||||
|
||||
#include "controller/uni_controller.h"
|
||||
#include "hid_usage.h"
|
||||
@@ -585,9 +586,7 @@ static void process_drm_ka(uni_hid_device_t* d, const uint8_t* report, uint16_t
|
||||
|
||||
uni_controller_t* ctl = &d->controller;
|
||||
|
||||
- ctl->gamepad.accel[0] = sx;
|
||||
- ctl->gamepad.accel[1] = sy;
|
||||
- ctl->gamepad.accel[2] = sz;
|
||||
+ uni_imu_normalize_wii_accel(sx, sy, sz, ctl->gamepad.accel);
|
||||
|
||||
// Dpad works as dpad, useful to navigate menus.
|
||||
ctl->gamepad.dpad |= (report[1] & 0x01) ? DPAD_DOWN : 0;
|
||||
diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad32/uni_hid_device.c
|
||||
index 67841e8..9fe7134 100644
|
||||
--- a/src/components/bluepad32/uni_hid_device.c
|
||||
+++ b/src/components/bluepad32/uni_hid_device.c
|
||||
@@ -655,6 +655,7 @@ void uni_hid_device_guess_controller_type_from_pid_vid(uni_hid_device_t* d) {
|
||||
d->report_parser.setup = uni_hid_parser_psmove_setup;
|
||||
d->report_parser.init_report = uni_hid_parser_psmove_init_report;
|
||||
d->report_parser.parse_input_report = uni_hid_parser_psmove_parse_input_report;
|
||||
+ d->report_parser.parse_feature_report = uni_hid_parser_psmove_parse_feature_report;
|
||||
d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color;
|
||||
d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble;
|
||||
logi("Device detected as PS Move: 0x%02x\n", type);
|
||||
|
|
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||
[project]
|
||||
name = "switch-pico-bridge"
|
||||
version = "0.1.0"
|
||||
description = "SDL3-to-UART host bridge and helpers for the switch-pico firmware."
|
||||
description = "SDL2-to-UART host bridge and helpers for the switch-pico firmware."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
authors = [{ name = "Switch Pico Maintainers" }]
|
||||
|
|
@ -13,14 +13,11 @@ dependencies = [
|
|||
"pyserial",
|
||||
"PySDL3",
|
||||
"rich",
|
||||
"hidapi",
|
||||
"pyusb",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
controller-uart-bridge = "switch_pico_bridge.controller_uart_bridge:main"
|
||||
host-uart-logger = "switch_pico_bridge.host_uart_logger:main"
|
||||
switch-pico-pairings = "switch_pico_bridge.pairing_manager:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from .switch_pico_uart import ( # noqa: F401
|
|||
SwitchDpad,
|
||||
SwitchUARTClient,
|
||||
axis_to_stick,
|
||||
decode_rumble,
|
||||
discover_serial_ports,
|
||||
first_serial_port,
|
||||
str_to_dpad,
|
||||
|
|
@ -23,6 +24,7 @@ __all__ = [
|
|||
"discover_serial_ports",
|
||||
"first_serial_port",
|
||||
"axis_to_stick",
|
||||
"decode_rumble",
|
||||
"str_to_dpad",
|
||||
"trigger_to_button",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bridge multiple SDL3 controllers to switch-pico over UART and mirror rumble back.
|
||||
Bridge multiple SDL2 controllers to switch-pico over UART and mirror rumble back.
|
||||
|
||||
The framing matches ``switch-pico.cpp``:
|
||||
- Host -> Pico : UART v2 controller report
|
||||
- Pico -> Host : 0xBB, 0x02, low-frequency magnitude,
|
||||
high-frequency magnitude, checksum
|
||||
- Host -> Pico : 0xAA, buttons (LE16), hat, lx, ly, rx, ry
|
||||
- Pico -> Host : 0xBB, 0x01, 8 rumble bytes, checksum (sum of first 10 bytes)
|
||||
|
||||
Features inspired by ``host/controller_bridge.py``:
|
||||
- Multiple controllers paired to multiple UART ports
|
||||
- Rich-powered interactive pairing UI
|
||||
- Adjustable send frequency, deadzone, and trigger thresholds
|
||||
- Rumble feedback delivered to SDL3 controllers
|
||||
- Rumble feedback delivered to SDL2 controllers
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -49,16 +48,24 @@ from .switch_pico_uart import (
|
|||
SwitchReport,
|
||||
axis_to_stick,
|
||||
str_to_dpad,
|
||||
decode_rumble,
|
||||
discover_serial_ports,
|
||||
trigger_to_button,
|
||||
)
|
||||
|
||||
RUMBLE_IDLE_TIMEOUT = 0.25 # seconds without packets before forcing rumble off
|
||||
RUMBLE_DURATION_MS = 50
|
||||
RUMBLE_STUCK_TIMEOUT = 0.60 # continuous same-energy rumble will be stopped after this
|
||||
RUMBLE_MIN_ACTIVE = 0.40 # below this, rumble is treated as off/noise
|
||||
RUMBLE_SCALE = 1.0
|
||||
CONTROLLER_DB_URL_DEFAULT = "https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt"
|
||||
SDL_TRUE = True
|
||||
SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658)
|
||||
GYRO_BIAS_SAMPLES = 200
|
||||
SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(
|
||||
sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658
|
||||
)
|
||||
GYRO_BIAS_SAMPLES = 100 # samples to collect for bias (~0.5 s at 200 Hz)
|
||||
GYRO_BIAS_WARMUP_S = 1.5 # seconds to wait before starting calibration
|
||||
GYRO_BIAS_TIMEOUT_S = 10.0 # force-lock after this many seconds even if still moving
|
||||
IMU_BUFFER_SIZE = 32
|
||||
|
||||
|
||||
def parse_mapping(value: str) -> Tuple[int, str]:
|
||||
|
|
@ -197,16 +204,21 @@ def interactive_pairing(
|
|||
return mappings
|
||||
|
||||
|
||||
def apply_rumble(
|
||||
controller: sdl3.SDL_Gamepad,
|
||||
low_frequency: float,
|
||||
high_frequency: float,
|
||||
) -> bool:
|
||||
"""Apply normalized low/high rumble magnitudes to an SDL controller."""
|
||||
low = int(max(0.0, min(1.0, low_frequency)) * 0xFFFF)
|
||||
high = int(max(0.0, min(1.0, high_frequency)) * 0xFFFF)
|
||||
sdl3.SDL_RumbleGamepad(controller, low, high, RUMBLE_DURATION_MS)
|
||||
return low != 0 or high != 0
|
||||
def apply_rumble(controller: sdl3.SDL_Gamepad, payload: bytes) -> float:
|
||||
"""Apply rumble payload to SDL controller and return max normalized energy."""
|
||||
left_norm, right_norm = decode_rumble(payload)
|
||||
max_norm = max(left_norm, right_norm)
|
||||
# Treat small rumble as "off" to avoid idle buzz.
|
||||
if max_norm < RUMBLE_MIN_ACTIVE:
|
||||
sdl3.SDL_RumbleGamepad(controller, 0, 0, 0)
|
||||
return 0.0
|
||||
# Attenuate to feel closer to a real controller; cap at ~25% strength.
|
||||
scale = RUMBLE_SCALE
|
||||
low = int(min(1.0, left_norm * scale) * 0xFFFF) # SDL: low_frequency_rumble
|
||||
high = int(min(1.0, right_norm * scale) * 0xFFFF) # SDL: high_frequency_rumble
|
||||
duration = 10
|
||||
sdl3.SDL_RumbleGamepad(controller, low, high, duration)
|
||||
return max_norm
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -232,19 +244,25 @@ class ControllerContext:
|
|||
)
|
||||
last_send: float = 0.0
|
||||
last_reopen_attempt: float = 0.0
|
||||
last_rumble_at: float = 0.0
|
||||
last_rumble: float = 0.0
|
||||
last_rumble_change: float = 0.0
|
||||
last_rumble_energy: float = 0.0
|
||||
rumble_active: bool = False
|
||||
axis_offsets: Dict[int, int] = field(default_factory=dict)
|
||||
swap_abxy: bool = False
|
||||
sensors_supported: bool = False
|
||||
sensors_enabled: bool = False
|
||||
imu_samples: List[IMUSample] = field(default_factory=list)
|
||||
last_accel: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
# Default: gravity on SDL Y axis (~+9.8 m/s²) — controller held horizontally.
|
||||
# This prevents the first IMU sample from having zero accel before the first
|
||||
# accel event arrives.
|
||||
last_accel: Tuple[float, float, float] = (0.0, 9.80665, 0.0)
|
||||
gyro_bias_x: float = 0.0
|
||||
gyro_bias_y: float = 0.0
|
||||
gyro_bias_z: float = 0.0
|
||||
gyro_bias_samples: int = 0
|
||||
gyro_bias_locked: bool = False
|
||||
gyro_bias_start_time: float = 0.0 # monotonic time when calibration began
|
||||
last_debug_imu_print: float = 0.0
|
||||
|
||||
|
||||
|
|
@ -312,7 +330,9 @@ def initialize_controller_sensors(ctx: ControllerContext, console: Console) -> N
|
|||
accel_enabled = sdl3.SDL_SetGamepadSensorEnabled(
|
||||
ctx.controller, SENSOR_ACCEL, SDL_TRUE
|
||||
)
|
||||
gyro_enabled = sdl3.SDL_SetGamepadSensorEnabled(ctx.controller, SENSOR_GYRO, SDL_TRUE)
|
||||
gyro_enabled = sdl3.SDL_SetGamepadSensorEnabled(
|
||||
ctx.controller, SENSOR_GYRO, SDL_TRUE
|
||||
)
|
||||
ctx.sensors_enabled = accel_enabled and gyro_enabled
|
||||
if not ctx.sensors_enabled:
|
||||
console.print(
|
||||
|
|
@ -809,7 +829,7 @@ class BridgeConfig:
|
|||
swap_abxy_ids: set[str]
|
||||
swap_abxy_global: bool
|
||||
debug_imu: bool = False
|
||||
no_imu: bool = False
|
||||
no_imu: bool = True
|
||||
gyro_scale: float = 1.0
|
||||
|
||||
|
||||
|
|
@ -843,7 +863,9 @@ class PairingState:
|
|||
ignore_port_desc: List[str] = field(default_factory=list)
|
||||
include_port_desc: List[str] = field(default_factory=list)
|
||||
include_port_mfr: List[str] = field(default_factory=list)
|
||||
display_index_alloc: DisplayIndexAllocator = field(default_factory=DisplayIndexAllocator)
|
||||
display_index_alloc: DisplayIndexAllocator = field(
|
||||
default_factory=DisplayIndexAllocator
|
||||
)
|
||||
|
||||
|
||||
def load_button_maps(
|
||||
|
|
@ -934,7 +956,11 @@ def detect_controllers(
|
|||
if sdl3.SDL_IsGamepad(instance_id):
|
||||
name = sdl3.SDL_GetGamepadNameForID(instance_id)
|
||||
name_str = (
|
||||
name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
|
||||
name.decode()
|
||||
if isinstance(name, bytes)
|
||||
else str(name)
|
||||
if name
|
||||
else "Unknown"
|
||||
)
|
||||
if include_controller_name and all(
|
||||
substr not in name_str.lower() for substr in include_controller_name
|
||||
|
|
@ -943,14 +969,20 @@ def detect_controllers(
|
|||
f"[yellow]Skipping controller ({name_str}) due to name filter[/yellow]"
|
||||
)
|
||||
continue
|
||||
console.print(f"[cyan]Detected controller {display_counter}: ({name_str})[/cyan]")
|
||||
console.print(
|
||||
f"[cyan]Detected controller {display_counter}: ({name_str})[/cyan]"
|
||||
)
|
||||
display_counter += 1
|
||||
controller_ids.append(instance_id)
|
||||
controller_names[instance_id] = name_str
|
||||
else:
|
||||
name = sdl3.SDL_GetJoystickNameForID(instance_id)
|
||||
name_str = (
|
||||
name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
|
||||
name.decode()
|
||||
if isinstance(name, bytes)
|
||||
else str(name)
|
||||
if name
|
||||
else "Unknown"
|
||||
)
|
||||
if include_controller_name and all(
|
||||
substr not in name_str.lower() for substr in include_controller_name
|
||||
|
|
@ -993,10 +1025,19 @@ def list_controllers_with_guids(
|
|||
if is_gc
|
||||
else sdl3.SDL_GetJoystickNameForID(instance_id)
|
||||
)
|
||||
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
|
||||
name_str = (
|
||||
name.decode()
|
||||
if isinstance(name, bytes)
|
||||
else str(name)
|
||||
if name
|
||||
else "Unknown"
|
||||
)
|
||||
guid_str = guid_string_for_instance_id(instance_id)
|
||||
table.add_row(
|
||||
str(instance_id), "GameController" if is_gc else "Joystick", name_str, guid_str
|
||||
str(instance_id),
|
||||
"GameController" if is_gc else "Joystick",
|
||||
name_str,
|
||||
guid_str,
|
||||
)
|
||||
sdl3.SDL_free(joystick_ids)
|
||||
console.print(table)
|
||||
|
|
@ -1088,7 +1129,9 @@ def assign_port_for_index(
|
|||
return port_choice
|
||||
|
||||
|
||||
def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set[str]:
|
||||
def ports_in_use(
|
||||
pairing: PairingState, contexts: Dict[int, ControllerContext]
|
||||
) -> set[str]:
|
||||
"""Return a set of UART paths currently reserved or mapped."""
|
||||
used = set(pairing.mapping_by_index.values())
|
||||
used.update(ctx.port for ctx in contexts.values() if ctx.port)
|
||||
|
|
@ -1125,6 +1168,7 @@ def handle_removed_port(
|
|||
ctx.uart = None
|
||||
ctx.port = None
|
||||
ctx.rumble_active = False
|
||||
ctx.last_rumble_energy = 0.0
|
||||
ctx.last_reopen_attempt = time.monotonic()
|
||||
console.print(
|
||||
f"[yellow]UART {path} removed; controller {ctx.controller_index} waiting for reassignment[/yellow]"
|
||||
|
|
@ -1206,7 +1250,13 @@ def open_initial_contexts(
|
|||
for instance_id in controller_indices:
|
||||
if not sdl3.SDL_IsGamepad(instance_id):
|
||||
name = sdl3.SDL_GetJoystickNameForID(instance_id)
|
||||
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
|
||||
name_str = (
|
||||
name.decode()
|
||||
if isinstance(name, bytes)
|
||||
else str(name)
|
||||
if name
|
||||
else "Unknown"
|
||||
)
|
||||
console.print(
|
||||
f"[yellow]ID {instance_id} is not a GameController ({name_str}). Trying raw open failed.[/yellow]"
|
||||
)
|
||||
|
|
@ -1315,22 +1365,55 @@ def handle_sensor_update(
|
|||
gx, gy, gz = float(data[0]), float(data[1]), float(data[2])
|
||||
|
||||
if not ctx.gyro_bias_locked:
|
||||
now = time.monotonic()
|
||||
|
||||
# Track when the first gyro event arrived so we can enforce the warmup.
|
||||
if ctx.gyro_bias_start_time == 0.0:
|
||||
ctx.gyro_bias_start_time = now
|
||||
print(
|
||||
f"[IMU idx={ctx.controller_index}] Gyro bias calibration started — "
|
||||
f"hold controller still for {GYRO_BIAS_WARMUP_S:.0f}s..."
|
||||
)
|
||||
|
||||
elapsed = now - ctx.gyro_bias_start_time
|
||||
|
||||
# Phase 1: warmup — discard all samples, just wait.
|
||||
if elapsed < GYRO_BIAS_WARMUP_S:
|
||||
return
|
||||
|
||||
# Phase 2: collect samples unconditionally.
|
||||
# Timeout: after GYRO_BIAS_TIMEOUT_S total, force-lock with whatever we have.
|
||||
if ctx.gyro_bias_samples < GYRO_BIAS_SAMPLES:
|
||||
ctx.gyro_bias_x += gx
|
||||
ctx.gyro_bias_y += gy
|
||||
ctx.gyro_bias_z += gz
|
||||
ctx.gyro_bias_samples += 1
|
||||
if ctx.gyro_bias_samples >= GYRO_BIAS_SAMPLES:
|
||||
n = ctx.gyro_bias_samples
|
||||
|
||||
force_lock = elapsed > GYRO_BIAS_TIMEOUT_S and ctx.gyro_bias_samples > 10
|
||||
|
||||
if ctx.gyro_bias_samples >= GYRO_BIAS_SAMPLES or force_lock:
|
||||
n = max(ctx.gyro_bias_samples, 1)
|
||||
ctx.gyro_bias_x /= n
|
||||
ctx.gyro_bias_y /= n
|
||||
ctx.gyro_bias_z /= n
|
||||
ctx.gyro_bias_locked = True
|
||||
import math
|
||||
|
||||
if not ctx.gyro_bias_locked:
|
||||
bx, by, bz = 0.0, 0.0, 0.0
|
||||
else:
|
||||
bx, by, bz = ctx.gyro_bias_x, ctx.gyro_bias_y, ctx.gyro_bias_z
|
||||
mag = math.sqrt(
|
||||
ctx.gyro_bias_x**2 + ctx.gyro_bias_y**2 + ctx.gyro_bias_z**2
|
||||
)
|
||||
quality = (
|
||||
"OK" if mag < 0.05 else "WARN: controller was moving during calibration"
|
||||
)
|
||||
print(
|
||||
f"[IMU idx={ctx.controller_index}] Bias locked{' (timeout)' if force_lock else ''}: "
|
||||
f"({ctx.gyro_bias_x:.5f}, {ctx.gyro_bias_y:.5f}, {ctx.gyro_bias_z:.5f}) rad/s "
|
||||
f"magnitude={mag:.4f} rad/s = {mag * 180 / math.pi:.2f} deg/s [{quality}]"
|
||||
)
|
||||
# Don't send IMU until bias is locked — raw unbiased values cause drift.
|
||||
return
|
||||
|
||||
bx, by, bz = ctx.gyro_bias_x, ctx.gyro_bias_y, ctx.gyro_bias_z
|
||||
|
||||
ux, uy, uz = gx, gy, gz
|
||||
ux -= bx
|
||||
|
|
@ -1357,8 +1440,8 @@ def handle_sensor_update(
|
|||
)
|
||||
|
||||
ctx.imu_samples.append(sample)
|
||||
if len(ctx.imu_samples) > IMU_SAMPLES_PER_REPORT:
|
||||
del ctx.imu_samples[:-IMU_SAMPLES_PER_REPORT]
|
||||
if len(ctx.imu_samples) > IMU_BUFFER_SIZE:
|
||||
ctx.imu_samples = ctx.imu_samples[-IMU_BUFFER_SIZE:]
|
||||
|
||||
if config.debug_imu:
|
||||
now = time.monotonic()
|
||||
|
|
@ -1374,31 +1457,10 @@ def handle_sensor_update(
|
|||
)
|
||||
|
||||
|
||||
ABXY_SWAP_COMBO = frozenset({
|
||||
sdl3.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER,
|
||||
sdl3.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER,
|
||||
sdl3.SDL_GAMEPAD_BUTTON_BACK,
|
||||
sdl3.SDL_GAMEPAD_BUTTON_START,
|
||||
})
|
||||
|
||||
|
||||
def _check_abxy_swap_combo(
|
||||
ctx: ControllerContext,
|
||||
config: BridgeConfig,
|
||||
console: Console,
|
||||
) -> None:
|
||||
"""Toggle ABXY layout when LB+RB+SELECT+START are all held."""
|
||||
if not all(ctx.button_state.get(b) for b in ABXY_SWAP_COMBO):
|
||||
return
|
||||
toggle_abxy_for_context(ctx, config, console)
|
||||
sdl3.SDL_RumbleGamepad(ctx.controller, 0xAAAA, 0xAAAA, 200)
|
||||
|
||||
|
||||
def handle_button_event(
|
||||
event: sdl3.SDL_Event,
|
||||
config: BridgeConfig,
|
||||
contexts: Dict[int, ControllerContext],
|
||||
console: Console,
|
||||
) -> None:
|
||||
"""Process button events into report/dpad state."""
|
||||
ctx = contexts.get(event.gbutton.which)
|
||||
|
|
@ -1421,8 +1483,6 @@ def handle_button_event(
|
|||
elif button in DPAD_BUTTONS:
|
||||
ctx.dpad[DPAD_BUTTONS[button]] = pressed
|
||||
ctx.report.hat = str_to_dpad(ctx.dpad)
|
||||
if pressed and button in ABXY_SWAP_COMBO:
|
||||
_check_abxy_swap_combo(ctx, config, console)
|
||||
|
||||
|
||||
def handle_device_added(
|
||||
|
|
@ -1440,7 +1500,13 @@ def handle_device_added(
|
|||
return
|
||||
if not sdl3.SDL_IsGamepad(sdl_id):
|
||||
name = sdl3.SDL_GetJoystickNameForID(sdl_id)
|
||||
name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown"
|
||||
name_str = (
|
||||
name.decode()
|
||||
if isinstance(name, bytes)
|
||||
else str(name)
|
||||
if name
|
||||
else "Unknown"
|
||||
)
|
||||
console.print(
|
||||
f"[yellow]Device {sdl_id} is not a GameController ({name_str}).[/yellow]"
|
||||
)
|
||||
|
|
@ -1453,11 +1519,15 @@ def handle_device_added(
|
|||
try:
|
||||
controller, instance_id, guid = open_controller(sdl_id)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Hotplug open failed for controller {display_idx}: {exc}[/red]")
|
||||
console.print(
|
||||
f"[red]Hotplug open failed for controller {display_idx}: {exc}[/red]"
|
||||
)
|
||||
pairing.display_index_alloc.release(display_idx)
|
||||
return
|
||||
stable_id = guid
|
||||
should_swap = display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids
|
||||
should_swap = (
|
||||
display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids
|
||||
)
|
||||
uart = open_uart_or_warn(port, args.baud, console) if port else None
|
||||
if uart:
|
||||
uarts.append(uart)
|
||||
|
|
@ -1556,33 +1626,54 @@ def service_contexts(
|
|||
try:
|
||||
if now - ctx.last_send >= config.interval:
|
||||
if ctx.sensors_enabled and not config.no_imu:
|
||||
# Keep publishing the latest complete sensor window. Draining
|
||||
# this at the faster UART rate leaves most USB reports empty.
|
||||
ctx.report.imu_samples = ctx.imu_samples
|
||||
count = min(len(ctx.imu_samples), IMU_SAMPLES_PER_REPORT)
|
||||
if count > 0:
|
||||
# Take the NEWEST samples, discard stale ones.
|
||||
# Previously took from front (oldest) which caused
|
||||
# 145ms latency when FIFO was full at 29-32 samples.
|
||||
ctx.report.imu_samples = ctx.imu_samples[-count:]
|
||||
ctx.imu_samples.clear()
|
||||
else:
|
||||
ctx.report.imu_samples = []
|
||||
else:
|
||||
ctx.report.imu_samples = []
|
||||
# Debug: log actual IMU values being sent via UART
|
||||
if config.debug_imu and ctx.report.imu_samples:
|
||||
s = ctx.report.imu_samples[0]
|
||||
if abs(s.gyro_x) > 50 or abs(s.gyro_y) > 50 or abs(s.gyro_z) > 50:
|
||||
print(
|
||||
f"[UART_SEND] LARGE GYRO a=({s.accel_x},{s.accel_y},{s.accel_z}) "
|
||||
f"g=({s.gyro_x},{s.gyro_y},{s.gyro_z}) fifo_remaining={len(ctx.imu_samples)}"
|
||||
)
|
||||
ctx.uart.send_report(ctx.report)
|
||||
ctx.last_send = now
|
||||
|
||||
latest_rumble = None
|
||||
last_payload = None
|
||||
while True:
|
||||
rumble = ctx.uart.read_rumble()
|
||||
if rumble is None:
|
||||
p = ctx.uart.read_rumble_payload()
|
||||
if not p:
|
||||
break
|
||||
latest_rumble = rumble
|
||||
last_payload = p
|
||||
|
||||
if latest_rumble is not None:
|
||||
# Apply only the freshest rumble command seen during this tick.
|
||||
ctx.rumble_active = apply_rumble(
|
||||
ctx.controller, latest_rumble[0], latest_rumble[1]
|
||||
)
|
||||
ctx.last_rumble_at = now
|
||||
if last_payload is not None:
|
||||
# Apply only the freshest rumble payload seen during this tick.
|
||||
energy = apply_rumble(ctx.controller, last_payload)
|
||||
ctx.rumble_active = energy >= RUMBLE_MIN_ACTIVE
|
||||
if ctx.rumble_active and energy != ctx.last_rumble_energy:
|
||||
ctx.last_rumble_change = now
|
||||
ctx.last_rumble_energy = energy
|
||||
ctx.last_rumble = now
|
||||
elif ctx.rumble_active and (now - ctx.last_rumble) > RUMBLE_IDLE_TIMEOUT:
|
||||
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
||||
ctx.rumble_active = False
|
||||
ctx.last_rumble_energy = 0.0
|
||||
elif (
|
||||
ctx.rumble_active
|
||||
and (now - ctx.last_rumble_at) > RUMBLE_IDLE_TIMEOUT
|
||||
and (now - ctx.last_rumble_change) > RUMBLE_STUCK_TIMEOUT
|
||||
):
|
||||
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
||||
ctx.rumble_active = False
|
||||
ctx.last_rumble_energy = 0.0
|
||||
except SerialException as exc:
|
||||
console.print(f"[yellow]UART {ctx.port} disconnected: {exc}[/yellow]")
|
||||
try:
|
||||
|
|
@ -1592,6 +1683,7 @@ def service_contexts(
|
|||
sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0)
|
||||
ctx.uart = None
|
||||
ctx.rumble_active = False
|
||||
ctx.last_rumble_energy = 0.0
|
||||
ctx.last_reopen_attempt = now
|
||||
except Exception as exc:
|
||||
console.print(f"[red]UART error on {ctx.port}: {exc}[/red]")
|
||||
|
|
@ -1623,7 +1715,7 @@ def run_bridge_loop(
|
|||
sdl3.SDL_EVENT_GAMEPAD_BUTTON_DOWN,
|
||||
sdl3.SDL_EVENT_GAMEPAD_BUTTON_UP,
|
||||
):
|
||||
handle_button_event(event, config, contexts, console)
|
||||
handle_button_event(event, config, contexts)
|
||||
elif event.type == SDL_EVENT_GAMEPAD_SENSOR_UPDATE:
|
||||
handle_sensor_update(event, contexts, config)
|
||||
elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED:
|
||||
|
|
|
|||
|
|
@ -1,292 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Manage Pico 2 W Bluetooth pairings over vendor requests on USB EP0."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Any, Protocol
|
||||
|
||||
import usb.core
|
||||
|
||||
USB_VENDOR_ID = 0x057E
|
||||
USB_PRODUCT_ID = 0x2009
|
||||
REQUEST_CLEAR = 0x50
|
||||
REQUEST_GET = 0x51
|
||||
REQUEST_REFRESH = 0x52
|
||||
REQUEST_VALUE = 0x5350
|
||||
REQUEST_INDEX = 0x4D47
|
||||
PROTOCOL_VERSION = 1
|
||||
RESPONSE_HEADER_SIZE = 12
|
||||
RECORD_SIZE = 8
|
||||
RECORD_CAPACITY = 16
|
||||
MAXIMUM_RESPONSE_SIZE = RESPONSE_HEADER_SIZE + RECORD_CAPACITY * RECORD_SIZE
|
||||
STATUS_READY = 0
|
||||
STATUS_PENDING = 1
|
||||
TRANSPORT_CLASSIC = 1
|
||||
TRANSPORT_BLE = 2
|
||||
USB_TIMEOUT_MS = 1000
|
||||
|
||||
|
||||
class PairingManagerError(RuntimeError):
|
||||
"""Expected discovery, USB transport, or protocol failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairingRecord:
|
||||
transport: int
|
||||
address_type: int
|
||||
address: bytes
|
||||
|
||||
@property
|
||||
def address_text(self) -> str:
|
||||
return ":".join(f"{octet:02X}" for octet in self.address)
|
||||
|
||||
@property
|
||||
def transport_text(self) -> str:
|
||||
if self.transport == TRANSPORT_CLASSIC:
|
||||
return "Classic"
|
||||
if self.transport == TRANSPORT_BLE:
|
||||
address_types = {
|
||||
0: "public",
|
||||
1: "random",
|
||||
2: "public identity",
|
||||
3: "random identity",
|
||||
}
|
||||
suffix = address_types.get(
|
||||
self.address_type, f"type {self.address_type}"
|
||||
)
|
||||
return f"BLE ({suffix})"
|
||||
return f"unknown transport {self.transport}"
|
||||
|
||||
|
||||
class UsbDevice(Protocol):
|
||||
bus: int | None
|
||||
address: int | None
|
||||
|
||||
def ctrl_transfer(
|
||||
self,
|
||||
bm_request_type: int,
|
||||
request: int,
|
||||
value: int = 0,
|
||||
index: int = 0,
|
||||
data_or_w_length: Any = None,
|
||||
timeout: int | None = None,
|
||||
) -> Any:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairingSnapshot:
|
||||
generation: int
|
||||
status: int
|
||||
overflow: bool
|
||||
records: tuple[PairingRecord, ...]
|
||||
|
||||
|
||||
def parse_snapshot(payload: bytes) -> PairingSnapshot:
|
||||
if len(payload) < RESPONSE_HEADER_SIZE:
|
||||
raise PairingManagerError("short pairing-management response")
|
||||
if payload[:4] != b"SPPM":
|
||||
raise PairingManagerError("device does not implement pairing management")
|
||||
if payload[4] != PROTOCOL_VERSION:
|
||||
raise PairingManagerError(
|
||||
f"unsupported pairing protocol version {payload[4]}"
|
||||
)
|
||||
|
||||
status = payload[5]
|
||||
record_count = payload[6]
|
||||
required = RESPONSE_HEADER_SIZE + record_count * RECORD_SIZE
|
||||
if record_count > RECORD_CAPACITY or len(payload) < required:
|
||||
raise PairingManagerError("invalid pairing record count")
|
||||
|
||||
generation = int(struct.unpack_from("<I", payload, 8)[0])
|
||||
records: list[PairingRecord] = []
|
||||
offset = RESPONSE_HEADER_SIZE
|
||||
for _ in range(record_count):
|
||||
records.append(
|
||||
PairingRecord(
|
||||
transport=payload[offset],
|
||||
address_type=payload[offset + 1],
|
||||
address=bytes(payload[offset + 2 : offset + 8]),
|
||||
)
|
||||
)
|
||||
offset += RECORD_SIZE
|
||||
return PairingSnapshot(
|
||||
generation=generation,
|
||||
status=status,
|
||||
overflow=bool(payload[7] & 1),
|
||||
records=tuple(records),
|
||||
)
|
||||
|
||||
|
||||
def _control_in(device: UsbDevice) -> bytes:
|
||||
payload = device.ctrl_transfer(
|
||||
0xC0,
|
||||
REQUEST_GET,
|
||||
REQUEST_VALUE,
|
||||
REQUEST_INDEX,
|
||||
MAXIMUM_RESPONSE_SIZE,
|
||||
timeout=USB_TIMEOUT_MS,
|
||||
)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
def _control_out(device: UsbDevice, request: int) -> None:
|
||||
device.ctrl_transfer(
|
||||
0x40,
|
||||
request,
|
||||
REQUEST_VALUE,
|
||||
REQUEST_INDEX,
|
||||
None,
|
||||
timeout=USB_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
def read_snapshot(device: UsbDevice) -> PairingSnapshot:
|
||||
return parse_snapshot(_control_in(device))
|
||||
|
||||
|
||||
def wait_for_snapshot(
|
||||
device: UsbDevice, previous_generation: int, timeout: float
|
||||
) -> PairingSnapshot:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = read_snapshot(device)
|
||||
if (
|
||||
snapshot.status == STATUS_READY
|
||||
and snapshot.generation != previous_generation
|
||||
):
|
||||
return snapshot
|
||||
time.sleep(0.05)
|
||||
raise PairingManagerError("Pico did not finish the pairing operation")
|
||||
|
||||
|
||||
def refresh_snapshot(device: UsbDevice, timeout: float) -> PairingSnapshot:
|
||||
initial = read_snapshot(device)
|
||||
_control_out(device, REQUEST_REFRESH)
|
||||
return wait_for_snapshot(device, initial.generation, timeout)
|
||||
|
||||
|
||||
def clear_pairings(device: UsbDevice, timeout: float) -> PairingSnapshot:
|
||||
initial = read_snapshot(device)
|
||||
_control_out(device, REQUEST_CLEAR)
|
||||
snapshot = wait_for_snapshot(device, initial.generation, timeout)
|
||||
if snapshot.records:
|
||||
raise PairingManagerError("Pico reported pairings after clear completed")
|
||||
return snapshot
|
||||
|
||||
|
||||
def _candidate_devices() -> Iterable[UsbDevice]:
|
||||
devices = usb.core.find(
|
||||
find_all=True,
|
||||
idVendor=USB_VENDOR_ID,
|
||||
idProduct=USB_PRODUCT_ID,
|
||||
)
|
||||
return () if devices is None else devices
|
||||
|
||||
|
||||
def find_pico(
|
||||
bus: int | None, address: int | None, timeout: float = 3.0
|
||||
) -> UsbDevice:
|
||||
deadline = time.monotonic() + timeout
|
||||
failures: list[Exception] = []
|
||||
while True:
|
||||
matches: list[UsbDevice] = []
|
||||
for device in _candidate_devices():
|
||||
if bus is not None and getattr(device, "bus", None) != bus:
|
||||
continue
|
||||
if address is not None and getattr(device, "address", None) != address:
|
||||
continue
|
||||
try:
|
||||
_ = read_snapshot(device)
|
||||
except (PairingManagerError, usb.core.USBError) as exc:
|
||||
failures.append(exc)
|
||||
continue
|
||||
matches.append(device)
|
||||
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
locations = ", ".join(
|
||||
f"{device.bus}:{device.address}" for device in matches
|
||||
)
|
||||
raise PairingManagerError(
|
||||
f"multiple switch-pico devices found ({locations}); "
|
||||
"select one with --bus and --address"
|
||||
)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if failures:
|
||||
raise PairingManagerError(
|
||||
"matching USB devices were found, but none accepted the "
|
||||
f"management request; last error: {failures[-1]}"
|
||||
) from failures[-1]
|
||||
raise PairingManagerError("no USB-connected switch-pico AIO firmware found")
|
||||
|
||||
|
||||
def _print_snapshot(snapshot: PairingSnapshot) -> None:
|
||||
if not snapshot.records:
|
||||
print("No stored pairings.")
|
||||
return
|
||||
for index, record in enumerate(snapshot.records, start=1):
|
||||
print(f"{index}: {record.transport_text} {record.address_text}")
|
||||
if snapshot.overflow:
|
||||
print("Warning: additional pairings did not fit in the response.")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="switch-pico-pairings",
|
||||
description="List or clear switch-pico AIO Bluetooth pairings.",
|
||||
)
|
||||
parser.add_argument("--bus", type=int, help="USB bus number")
|
||||
parser.add_argument("--address", type=int, help="USB device address")
|
||||
parser.add_argument(
|
||||
"--timeout", type=float, default=3.0,
|
||||
help="operation timeout in seconds (default: 3)",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("list", help="list stored Classic and BLE pairings")
|
||||
clear_parser = subparsers.add_parser("clear", help="clear all pairings")
|
||||
clear_parser.add_argument(
|
||||
"--yes", action="store_true",
|
||||
help="confirm destructive clearing without prompting",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.timeout <= 0:
|
||||
print("error: --timeout must be positive", file=sys.stderr)
|
||||
return 2
|
||||
if args.command == "clear" and not args.yes:
|
||||
print("error: clear requires --yes", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
device = find_pico(args.bus, args.address, args.timeout)
|
||||
if args.command == "list":
|
||||
_print_snapshot(refresh_snapshot(device, args.timeout))
|
||||
else:
|
||||
before = refresh_snapshot(device, args.timeout)
|
||||
clear_pairings(device, args.timeout)
|
||||
print(f"Cleared {len(before.records)} stored pairing(s).")
|
||||
except PairingManagerError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except usb.core.USBError as exc:
|
||||
print(f"error: USB access failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -2,13 +2,12 @@
|
|||
"""
|
||||
Lightweight helpers for talking to the switch-pico firmware over UART.
|
||||
|
||||
This module exposes the report structure plus a small convenience wrapper
|
||||
This module exposes the raw report structure plus a small convenience wrapper
|
||||
so other scripts can do things like "press a button" or "move a stick" without
|
||||
depending on SDL. It mirrors the framing in ``switch-pico.cpp``:
|
||||
|
||||
Host -> Pico : UART v2 controller report
|
||||
Pico -> Host : 0xBB, 0x02, low-frequency magnitude, high-frequency magnitude,
|
||||
checksum (sum of the first 4 bytes)
|
||||
Host -> Pico : 0xAA, buttons (LE16), hat, lx, ly, rx, ry
|
||||
Pico -> Host : 0xBB, 0x01, 8 rumble bytes, checksum (sum of first 10 bytes)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -27,7 +26,7 @@ from serial.tools import list_ports, list_ports_common
|
|||
UART_HEADER = 0xAA
|
||||
UART_PROTOCOL_VERSION = 0x02
|
||||
RUMBLE_HEADER = 0xBB
|
||||
RUMBLE_TYPE_DECODED = 0x02
|
||||
RUMBLE_TYPE_RUMBLE = 0x01
|
||||
UART_BAUD = 921600
|
||||
IMU_SAMPLES_PER_REPORT = 3
|
||||
|
||||
|
|
@ -301,16 +300,15 @@ class PicoUART:
|
|||
"""Send a controller report to the Pico."""
|
||||
self.serial.write(report.to_bytes())
|
||||
|
||||
def read_rumble(self) -> Optional[Tuple[float, float]]:
|
||||
def read_rumble_payload(self) -> Optional[bytes]:
|
||||
"""
|
||||
Extract one decoded rumble frame as normalized low/high magnitudes.
|
||||
Drain available UART bytes into an internal buffer, then extract one rumble frame.
|
||||
|
||||
Frame format:
|
||||
0: 0xBB (RUMBLE_HEADER)
|
||||
1: type (0x02 for decoded rumble)
|
||||
2: low-frequency magnitude (0-255)
|
||||
3: high-frequency magnitude (0-255)
|
||||
4: checksum (sum of first 4 bytes) & 0xFF
|
||||
1: type (0x01 for rumble)
|
||||
2-9: 8-byte rumble payload
|
||||
10: checksum (sum of first 10 bytes) & 0xFF
|
||||
"""
|
||||
waiting = self.serial.in_waiting
|
||||
if waiting:
|
||||
|
|
@ -325,18 +323,18 @@ class PicoUART:
|
|||
self._buffer.clear()
|
||||
return None
|
||||
|
||||
if len(self._buffer) - start < 5:
|
||||
if len(self._buffer) - start < 11:
|
||||
if start > 0:
|
||||
del self._buffer[:start]
|
||||
return None
|
||||
|
||||
frame = self._buffer[start : start + 5]
|
||||
checksum = compute_checksum(bytes(frame[:4]))
|
||||
frame = self._buffer[start : start + 11]
|
||||
checksum = compute_checksum(bytes(frame[:10]))
|
||||
|
||||
if frame[1] == RUMBLE_TYPE_DECODED and checksum == frame[4]:
|
||||
rumble = (frame[2] / 255.0, frame[3] / 255.0)
|
||||
del self._buffer[: start + 5]
|
||||
return rumble
|
||||
if frame[1] == RUMBLE_TYPE_RUMBLE and checksum == frame[10]:
|
||||
payload = bytes(frame[2:10])
|
||||
del self._buffer[: start + 11]
|
||||
return payload
|
||||
|
||||
del self._buffer[: start + 1]
|
||||
|
||||
|
|
@ -345,6 +343,21 @@ class PicoUART:
|
|||
self.serial.close()
|
||||
|
||||
|
||||
def decode_rumble(payload: bytes) -> Tuple[float, float]:
|
||||
"""Return normalized rumble amplitudes (0.0-1.0) for left/right."""
|
||||
if len(payload) < 8:
|
||||
return 0.0, 0.0
|
||||
if payload == b"\x00\x01\x40\x40\x00\x01\x40\x40":
|
||||
return 0.0, 0.0
|
||||
right_raw = ((payload[1] & 0x03) << 8) | payload[0]
|
||||
left_raw = ((payload[5] & 0x03) << 8) | payload[4]
|
||||
if left_raw < 8 and right_raw < 8:
|
||||
return 0.0, 0.0
|
||||
left = min(max(left_raw / 1023.0, 0.0), 1.0)
|
||||
right = min(max(right_raw / 1023.0, 0.0), 1.0)
|
||||
return left, right
|
||||
|
||||
|
||||
@dataclass
|
||||
class SwitchControllerState:
|
||||
"""Mutable controller state with helpers for building reports."""
|
||||
|
|
@ -524,10 +537,13 @@ class SwitchUARTClient:
|
|||
|
||||
def poll_rumble(self) -> Optional[Tuple[float, float]]:
|
||||
"""
|
||||
Poll for decoded low/high rumble magnitudes normalized to 0.0-1.0.
|
||||
Poll for the latest rumble payload and return normalized amplitudes.
|
||||
Returns None if no rumble frame was available.
|
||||
"""
|
||||
return self.uart.read_rumble()
|
||||
payload = self.uart.read_rumble_payload()
|
||||
if payload:
|
||||
return decode_rumble(payload)
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._auto_thread:
|
||||
|
|
|
|||
139
switch-pico.cpp
139
switch-pico.cpp
|
|
@ -1,14 +1,10 @@
|
|||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "bsp/board.h"
|
||||
#include "hardware/uart.h"
|
||||
#include "pico/stdlib.h"
|
||||
#include "tusb.h"
|
||||
#include "switch_pro_driver.h"
|
||||
#ifndef SWITCH_PICO_BLUEPAD32
|
||||
#include "hardware/uart.h"
|
||||
#else
|
||||
#include "bluepad32_input_backend.h"
|
||||
#include "bootsel_pairing_button.h"
|
||||
#endif
|
||||
|
||||
#ifdef SWITCH_PICO_LOG
|
||||
#define LOG_PRINTF(...) printf(__VA_ARGS__)
|
||||
|
|
@ -16,38 +12,26 @@
|
|||
#define LOG_PRINTF(...) ((void)0)
|
||||
#endif
|
||||
|
||||
#ifndef SWITCH_PICO_BLUEPAD32
|
||||
// UART1 is reserved for external input frames from the host PC.
|
||||
#define UART_ID uart1
|
||||
#define BAUD_RATE 921600
|
||||
#define UART_TX_PIN 4
|
||||
#define UART_RX_PIN 5
|
||||
#define UART_RUMBLE_HEADER 0xBB
|
||||
#define UART_RUMBLE_TYPE 0x02
|
||||
#endif
|
||||
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT ==
|
||||
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT);
|
||||
static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
|
||||
static SwitchInputState
|
||||
g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
|
||||
#else
|
||||
static constexpr uint8_t SWITCH_HID_INSTANCE = 0;
|
||||
static bool g_last_ready = false;
|
||||
static SwitchInputState g_user_state;
|
||||
#endif
|
||||
#define UART_RUMBLE_RUMBLE_TYPE 0x01
|
||||
|
||||
static bool g_last_mounted = false;
|
||||
static bool g_last_ready = false;
|
||||
|
||||
// Track the latest state provided by UART or the autopilot.
|
||||
static SwitchInputState g_user_state;
|
||||
|
||||
#ifndef SWITCH_PICO_BLUEPAD32
|
||||
static void init_uart_input() {
|
||||
uart_init(UART_ID, BAUD_RATE);
|
||||
gpio_set_function(UART_TX_PIN, GPIO_FUNC_UART);
|
||||
gpio_set_function(UART_RX_PIN, GPIO_FUNC_UART);
|
||||
uart_set_format(UART_ID, 8, 1, UART_PARITY_NONE);
|
||||
}
|
||||
#endif
|
||||
|
||||
static SwitchInputState neutral_input() {
|
||||
SwitchInputState state{};
|
||||
|
|
@ -58,39 +42,24 @@ static SwitchInputState neutral_input() {
|
|||
return state;
|
||||
}
|
||||
|
||||
#ifndef SWITCH_PICO_BLUEPAD32
|
||||
static void send_rumble_uart_frame(const SwitchRumbleOutput& rumble) {
|
||||
uint8_t frame[5] = {
|
||||
UART_RUMBLE_HEADER,
|
||||
UART_RUMBLE_TYPE,
|
||||
rumble.low_frequency_magnitude,
|
||||
rumble.high_frequency_magnitude,
|
||||
0,
|
||||
};
|
||||
static void send_rumble_uart_frame(const uint8_t rumble[8]) {
|
||||
uint8_t frame[11];
|
||||
frame[0] = UART_RUMBLE_HEADER;
|
||||
frame[1] = UART_RUMBLE_RUMBLE_TYPE;
|
||||
memcpy(&frame[2], rumble, 8);
|
||||
|
||||
for (uint8_t i = 0; i < 4; ++i) {
|
||||
frame[4] = static_cast<uint8_t>(frame[4] + frame[i]);
|
||||
uint8_t checksum = 0;
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
checksum = static_cast<uint8_t>(checksum + frame[i]);
|
||||
}
|
||||
frame[10] = checksum;
|
||||
uart_write_blocking(UART_ID, frame, sizeof(frame));
|
||||
}
|
||||
#endif
|
||||
|
||||
static void on_rumble_from_switch(uint8_t instance,
|
||||
const SwitchRumbleOutput& rumble) {
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) {
|
||||
return;
|
||||
}
|
||||
bluepad32_input_backend_queue_rumble(instance, rumble);
|
||||
#else
|
||||
if (instance != SWITCH_HID_INSTANCE) {
|
||||
return;
|
||||
}
|
||||
static void on_rumble_from_switch(const uint8_t rumble[8]) {
|
||||
send_rumble_uart_frame(rumble);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef SWITCH_PICO_BLUEPAD32
|
||||
// Consume UART bytes and forward complete frames to the Switch Pro driver.
|
||||
static bool poll_uart_frames() {
|
||||
static uint8_t buffer[64];
|
||||
|
|
@ -134,7 +103,7 @@ static bool poll_uart_frames() {
|
|||
|
||||
if (expected_len > 0 && index >= expected_len) {
|
||||
SwitchInputState parsed{};
|
||||
if (switch_pro_apply_uart_packet(buffer, expected_len, parsed)) {
|
||||
if (switch_pro_apply_uart_packet(buffer, expected_len, &parsed)) {
|
||||
g_user_state = parsed;
|
||||
new_data = true;
|
||||
LOG_PRINTF("[UART] packet buttons=0x%04x hat=%u lx=%u ly=%u rx=%u ry=%u\n",
|
||||
|
|
@ -165,100 +134,44 @@ static bool poll_uart_frames() {
|
|||
|
||||
return new_data;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void log_usb_state() {
|
||||
bool mounted = tud_mounted();
|
||||
bool ready = switch_pro_is_ready();
|
||||
|
||||
if (mounted != g_last_mounted) {
|
||||
g_last_mounted = mounted;
|
||||
LOG_PRINTF("[USB] %s\n", mounted ? "mounted" : "unmounted");
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
for (uint8_t instance = 0;
|
||||
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
|
||||
const bool ready = switch_pro_is_ready(instance);
|
||||
if (ready != g_last_ready[instance]) {
|
||||
g_last_ready[instance] = ready;
|
||||
LOG_PRINTF("[SWITCH %u] driver %s\n", instance,
|
||||
ready ? "ready (handshake OK)" : "not ready");
|
||||
}
|
||||
}
|
||||
#else
|
||||
const bool ready = switch_pro_is_ready(SWITCH_HID_INSTANCE);
|
||||
if (ready != g_last_ready) {
|
||||
g_last_ready = ready;
|
||||
LOG_PRINTF("[SWITCH] driver %s\n",
|
||||
ready ? "ready (handshake OK)" : "not ready");
|
||||
LOG_PRINTF("[SWITCH] driver %s\n", ready ? "ready (handshake OK)" : "not ready");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
int main() {
|
||||
board_init();
|
||||
stdio_init_all();
|
||||
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
bluepad32_input_backend_init();
|
||||
#else
|
||||
init_uart_input();
|
||||
#endif
|
||||
|
||||
tusb_init();
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
for (uint8_t instance = 0;
|
||||
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
|
||||
switch_pro_init(instance);
|
||||
switch_pro_set_rumble_callback(instance, on_rumble_from_switch);
|
||||
g_user_states[instance] = neutral_input();
|
||||
switch_pro_set_input(instance, g_user_states[instance]);
|
||||
}
|
||||
#else
|
||||
switch_pro_init(SWITCH_HID_INSTANCE);
|
||||
switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE,
|
||||
on_rumble_from_switch);
|
||||
switch_pro_init();
|
||||
switch_pro_set_rumble_callback(on_rumble_from_switch);
|
||||
g_user_state = neutral_input();
|
||||
switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state);
|
||||
#endif
|
||||
switch_pro_set_input(g_user_state);
|
||||
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
bluepad32_input_backend_start();
|
||||
LOG_PRINTF("[BOOT] switch-pico starting (Bluepad32 wireless @ 115200)\n");
|
||||
#else
|
||||
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
|
||||
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n",
|
||||
UART_TX_PIN, UART_RX_PIN, BAUD_RATE);
|
||||
#endif
|
||||
|
||||
while (true) {
|
||||
tud_task(); // USB device tasks
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
switch (bootsel_pairing_button_task()) {
|
||||
case BootselPairingButtonEvent::kOpenPairing:
|
||||
bluepad32_input_backend_open_pairing_window();
|
||||
break;
|
||||
case BootselPairingButtonEvent::kClearPairings:
|
||||
bluepad32_input_backend_clear_pairings();
|
||||
break;
|
||||
case BootselPairingButtonEvent::kNone:
|
||||
break;
|
||||
}
|
||||
for (uint8_t instance = 0;
|
||||
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
|
||||
bluepad32_input_backend_snapshot(instance,
|
||||
&g_user_states[instance]);
|
||||
switch_pro_set_input(instance, g_user_states[instance]);
|
||||
if (switch_pro_task(instance)) {
|
||||
bluepad32_input_backend_report_sent(instance);
|
||||
}
|
||||
}
|
||||
#else
|
||||
bool new_data = poll_uart_frames(); // Pull controller state from UART1
|
||||
(void)new_data;
|
||||
SwitchInputState state = g_user_state;
|
||||
switch_pro_set_input(SWITCH_HID_INSTANCE, state);
|
||||
(void)switch_pro_task(SWITCH_HID_INSTANCE);
|
||||
#endif
|
||||
switch_pro_set_input(state);
|
||||
switch_pro_task(); // Push state to the Switch host
|
||||
log_usb_state();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
#include "switch_haptics.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
enum class CommandAction : uint8_t {
|
||||
Ignore,
|
||||
Default,
|
||||
Substitute,
|
||||
Sum,
|
||||
};
|
||||
|
||||
struct HapticCommand {
|
||||
CommandAction amplitude_action;
|
||||
CommandAction frequency_action;
|
||||
int16_t amplitude_offset;
|
||||
int16_t frequency_offset;
|
||||
};
|
||||
|
||||
constexpr HapticCommand kCommands[32] = {
|
||||
{CommandAction::Default, CommandAction::Default, 0, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 0, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 240, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 224, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 208, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 192, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 176, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 160, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 144, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 128, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 112, 0},
|
||||
{CommandAction::Substitute, CommandAction::Ignore, 96, 0},
|
||||
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
|
||||
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
|
||||
{CommandAction::Ignore, CommandAction::Substitute, 0, 0},
|
||||
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
|
||||
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
|
||||
{CommandAction::Sum, CommandAction::Sum, 4, 1},
|
||||
{CommandAction::Sum, CommandAction::Ignore, 4, 0},
|
||||
{CommandAction::Sum, CommandAction::Sum, 4, -1},
|
||||
{CommandAction::Sum, CommandAction::Sum, 1, 1},
|
||||
{CommandAction::Sum, CommandAction::Ignore, 1, 0},
|
||||
{CommandAction::Sum, CommandAction::Sum, 1, -1},
|
||||
{CommandAction::Ignore, CommandAction::Sum, 0, 1},
|
||||
{CommandAction::Ignore, CommandAction::Ignore, 0, 0},
|
||||
{CommandAction::Ignore, CommandAction::Sum, 0, -1},
|
||||
{CommandAction::Sum, CommandAction::Sum, -1, 1},
|
||||
{CommandAction::Sum, CommandAction::Ignore, -1, 0},
|
||||
{CommandAction::Sum, CommandAction::Sum, -1, -1},
|
||||
{CommandAction::Sum, CommandAction::Sum, -4, 1},
|
||||
{CommandAction::Sum, CommandAction::Ignore, -4, 0},
|
||||
{CommandAction::Sum, CommandAction::Sum, -4, -1},
|
||||
};
|
||||
|
||||
constexpr uint32_t kNeutralWord = 0x40400100u;
|
||||
constexpr uint8_t kDefaultFrequency = 64;
|
||||
|
||||
template <unsigned Shift, uint32_t Mask>
|
||||
constexpr uint8_t extract(uint32_t word) {
|
||||
static_assert(Shift < 32u, "32-bit word extraction shift must be bounded");
|
||||
static_assert(Mask <= 0xffu && Mask <= (0xffffffffu >> Shift),
|
||||
"word extraction mask must fit the shifted byte");
|
||||
return static_cast<uint8_t>((word >> Shift) & Mask);
|
||||
}
|
||||
|
||||
uint8_t apply_command(CommandAction action, int16_t offset, uint8_t current,
|
||||
uint8_t default_value, uint8_t maximum) {
|
||||
switch (action) {
|
||||
case CommandAction::Ignore:
|
||||
return current;
|
||||
case CommandAction::Default:
|
||||
return default_value;
|
||||
case CommandAction::Substitute:
|
||||
return static_cast<uint8_t>(offset);
|
||||
case CommandAction::Sum: {
|
||||
int result = static_cast<int>(current) + static_cast<int>(offset);
|
||||
if (result < 0) {
|
||||
result = 0;
|
||||
} else if (result > maximum) {
|
||||
result = maximum;
|
||||
}
|
||||
return static_cast<uint8_t>(result);
|
||||
}
|
||||
}
|
||||
return default_value;
|
||||
}
|
||||
|
||||
uint8_t host_amplitude_to_lut_index(uint8_t host_index) {
|
||||
const unsigned index = host_index & 0x7fu;
|
||||
if (index == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (index < 16) {
|
||||
return static_cast<uint8_t>(7u + 8u * index);
|
||||
}
|
||||
if (index < 32) {
|
||||
return static_cast<uint8_t>(97u + 2u * index);
|
||||
}
|
||||
return static_cast<uint8_t>(128u + index);
|
||||
}
|
||||
|
||||
uint32_t load_little_endian_word(const uint8_t* bytes) {
|
||||
return static_cast<uint32_t>(bytes[0]) |
|
||||
(static_cast<uint32_t>(bytes[1]) << 8u) |
|
||||
(static_cast<uint32_t>(bytes[2]) << 16u) |
|
||||
(static_cast<uint32_t>(bytes[3]) << 24u);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
size_t normalize_switch_output_report(uint8_t report_id,
|
||||
const uint8_t* payload,
|
||||
size_t payload_size,
|
||||
uint8_t output[64]) {
|
||||
if (payload == nullptr || output == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
if (report_id == 0) {
|
||||
if (payload_size > 64) {
|
||||
return 0;
|
||||
}
|
||||
std::memcpy(output, payload, payload_size);
|
||||
return payload_size;
|
||||
}
|
||||
if (payload_size >= 64) {
|
||||
return 0;
|
||||
}
|
||||
output[0] = report_id;
|
||||
std::memcpy(output + 1, payload, payload_size);
|
||||
return payload_size + 1;
|
||||
}
|
||||
|
||||
SwitchHapticsDecoder::SwitchHapticsDecoder() {
|
||||
reset();
|
||||
}
|
||||
|
||||
void SwitchHapticsDecoder::reset_actuator(ActuatorState& state) {
|
||||
state.high_amplitude = 0;
|
||||
state.low_amplitude = 0;
|
||||
state.high_frequency = kDefaultFrequency;
|
||||
state.low_frequency = kDefaultFrequency;
|
||||
state.last_word = 0;
|
||||
state.have_last_word = false;
|
||||
}
|
||||
|
||||
void SwitchHapticsDecoder::reset() {
|
||||
reset_actuator(actuators_[0]);
|
||||
reset_actuator(actuators_[1]);
|
||||
}
|
||||
|
||||
SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator(
|
||||
ActuatorState& state, uint32_t word) {
|
||||
if (word == 0 || word == kNeutralWord) {
|
||||
reset_actuator(state);
|
||||
state.last_word = word;
|
||||
state.have_last_word = true;
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
if (state.have_last_word && state.last_word == word) {
|
||||
return {state.low_amplitude, state.high_amplitude};
|
||||
}
|
||||
state.last_word = word;
|
||||
state.have_last_word = true;
|
||||
|
||||
AmplitudePeak peak{0, 0};
|
||||
bool decoded = false;
|
||||
const uint8_t frame_count = extract<30u, 0x03u>(word);
|
||||
const uint32_t data = word & 0x3fffffffu;
|
||||
|
||||
if (frame_count == 0) {
|
||||
state.high_amplitude = 0;
|
||||
return {state.low_amplitude, 0};
|
||||
}
|
||||
|
||||
const auto record_sample = [&]() {
|
||||
if (state.low_amplitude > peak.low) {
|
||||
peak.low = state.low_amplitude;
|
||||
}
|
||||
if (state.high_amplitude > peak.high) {
|
||||
peak.high = state.high_amplitude;
|
||||
}
|
||||
};
|
||||
|
||||
const auto apply_pair = [&](bool high_band, uint8_t command_index) {
|
||||
const HapticCommand& command = kCommands[command_index & 0x1fu];
|
||||
uint8_t& amplitude = high_band ? state.high_amplitude : state.low_amplitude;
|
||||
uint8_t& frequency = high_band ? state.high_frequency : state.low_frequency;
|
||||
amplitude = apply_command(command.amplitude_action, command.amplitude_offset,
|
||||
amplitude, 0, 255);
|
||||
frequency = apply_command(command.frequency_action, command.frequency_offset,
|
||||
frequency, kDefaultFrequency, 127);
|
||||
};
|
||||
|
||||
const auto decode_type_1 = [&]() {
|
||||
const uint8_t high_commands[3] = {
|
||||
extract<20u, 0x1fu>(word),
|
||||
extract<10u, 0x1fu>(word),
|
||||
extract<0u, 0x1fu>(word),
|
||||
};
|
||||
const uint8_t low_commands[3] = {
|
||||
extract<25u, 0x1fu>(word),
|
||||
extract<15u, 0x1fu>(word),
|
||||
extract<5u, 0x1fu>(word),
|
||||
};
|
||||
for (uint8_t sample = 0; sample < frame_count; ++sample) {
|
||||
apply_pair(true, high_commands[sample]);
|
||||
apply_pair(false, low_commands[sample]);
|
||||
record_sample();
|
||||
}
|
||||
decoded = true;
|
||||
};
|
||||
|
||||
if (frame_count == 1) {
|
||||
if ((data & 0x000fffffu) == 0) {
|
||||
decode_type_1();
|
||||
} else if ((data & 0x03u) == 0) {
|
||||
state.high_frequency = extract<2u, 0x7fu>(word);
|
||||
state.high_amplitude = host_amplitude_to_lut_index(extract<9u, 0x7fu>(word));
|
||||
state.low_frequency = extract<16u, 0x7fu>(word);
|
||||
state.low_amplitude = host_amplitude_to_lut_index(extract<23u, 0x7fu>(word));
|
||||
record_sample();
|
||||
decoded = true;
|
||||
} else if ((data & 0x02u) != 0) {
|
||||
const bool high_band = extract<0u, 0x01u>(word) != 0;
|
||||
const bool frequency_selected = extract<2u, 0x01u>(word) != 0;
|
||||
const uint8_t value = extract<23u, 0x7fu>(word);
|
||||
if (frequency_selected) {
|
||||
if (high_band) {
|
||||
state.high_frequency = value;
|
||||
} else {
|
||||
state.low_frequency = value;
|
||||
}
|
||||
} else if (high_band) {
|
||||
state.high_amplitude = host_amplitude_to_lut_index(value);
|
||||
} else {
|
||||
state.low_amplitude = host_amplitude_to_lut_index(value);
|
||||
}
|
||||
record_sample();
|
||||
decoded = true;
|
||||
}
|
||||
} else if (frame_count == 2) {
|
||||
if ((data & 0x03ffu) == 0) {
|
||||
decode_type_1();
|
||||
} else {
|
||||
const bool high_band = extract<0u, 0x01u>(word) != 0;
|
||||
const uint8_t frequency = extract<1u, 0x7fu>(word);
|
||||
const uint8_t command = extract<18u, 0x1fu>(word);
|
||||
const uint8_t amplitude = host_amplitude_to_lut_index(extract<23u, 0x7fu>(word));
|
||||
if (high_band) {
|
||||
state.high_frequency = frequency;
|
||||
state.high_amplitude = amplitude;
|
||||
apply_pair(false, command);
|
||||
} else {
|
||||
state.low_frequency = frequency;
|
||||
state.low_amplitude = amplitude;
|
||||
apply_pair(true, command);
|
||||
}
|
||||
record_sample();
|
||||
|
||||
apply_pair(true, extract<8u, 0x1fu>(word));
|
||||
apply_pair(false, extract<13u, 0x1fu>(word));
|
||||
record_sample();
|
||||
decoded = true;
|
||||
}
|
||||
} else if (frame_count == 3) {
|
||||
decode_type_1();
|
||||
}
|
||||
|
||||
if (!decoded) {
|
||||
return {state.low_amplitude, state.high_amplitude};
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
uint8_t SwitchHapticsDecoder::amplitude_to_magnitude(uint8_t amplitude_index) {
|
||||
if (amplitude_index < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const double exponent = -8.0 + static_cast<double>(amplitude_index) / 32.0;
|
||||
const double scaled = std::exp2(exponent) * 255.0;
|
||||
unsigned magnitude = static_cast<unsigned>(scaled + 0.5);
|
||||
if (magnitude > 255u) {
|
||||
magnitude = 255u;
|
||||
}
|
||||
return static_cast<uint8_t>(magnitude);
|
||||
}
|
||||
|
||||
SwitchRumbleOutput SwitchHapticsDecoder::decode(const uint8_t payload[8]) {
|
||||
AmplitudePeak peaks[2] = {
|
||||
{actuators_[0].low_amplitude, actuators_[0].high_amplitude},
|
||||
{actuators_[1].low_amplitude, actuators_[1].high_amplitude},
|
||||
};
|
||||
|
||||
if (payload != nullptr) {
|
||||
peaks[0] = decode_actuator(actuators_[0], load_little_endian_word(payload));
|
||||
peaks[1] = decode_actuator(actuators_[1], load_little_endian_word(payload + 4));
|
||||
}
|
||||
|
||||
const uint8_t low_peak = peaks[0].low > peaks[1].low ? peaks[0].low : peaks[1].low;
|
||||
const uint8_t high_peak = peaks[0].high > peaks[1].high ? peaks[0].high : peaks[1].high;
|
||||
return {amplitude_to_magnitude(low_peak), amplitude_to_magnitude(high_peak)};
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
#ifndef SWITCH_HAPTICS_H
|
||||
#define SWITCH_HAPTICS_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
struct SwitchRumbleOutput {
|
||||
uint8_t low_frequency_magnitude;
|
||||
uint8_t high_frequency_magnitude;
|
||||
};
|
||||
|
||||
size_t normalize_switch_output_report(uint8_t report_id,
|
||||
const uint8_t* payload,
|
||||
size_t payload_size,
|
||||
uint8_t output[64]);
|
||||
|
||||
class SwitchHapticsDecoder {
|
||||
public:
|
||||
SwitchHapticsDecoder();
|
||||
|
||||
void reset();
|
||||
SwitchRumbleOutput decode(const uint8_t payload[8]);
|
||||
|
||||
private:
|
||||
struct ActuatorState {
|
||||
uint8_t high_amplitude;
|
||||
uint8_t low_amplitude;
|
||||
uint8_t high_frequency;
|
||||
uint8_t low_frequency;
|
||||
uint32_t last_word;
|
||||
bool have_last_word;
|
||||
};
|
||||
|
||||
struct AmplitudePeak {
|
||||
uint8_t low;
|
||||
uint8_t high;
|
||||
};
|
||||
|
||||
static void reset_actuator(ActuatorState& state);
|
||||
static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word);
|
||||
static uint8_t amplitude_to_magnitude(uint8_t amplitude_index);
|
||||
|
||||
ActuatorState actuators_[2];
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -8,14 +8,6 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#ifndef SWITCH_PICO_HID_INSTANCE_COUNT
|
||||
#define SWITCH_PICO_HID_INSTANCE_COUNT 1
|
||||
#endif
|
||||
|
||||
#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4
|
||||
#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4"
|
||||
#endif
|
||||
|
||||
|
||||
#define SWITCH_PRO_ENDPOINT_SIZE 64
|
||||
|
||||
|
|
@ -89,7 +81,7 @@ typedef enum {
|
|||
GET_VOLTAGE = 0x50,
|
||||
} SwitchCommands;
|
||||
|
||||
struct SwitchAnalog {
|
||||
typedef struct {
|
||||
uint8_t data[3];
|
||||
|
||||
void setX(uint16_t x) {
|
||||
|
|
@ -109,10 +101,10 @@ struct SwitchAnalog {
|
|||
uint16_t getY() {
|
||||
return static_cast<uint16_t>((data[1] >> 4)) | (data[2] << 4);
|
||||
}
|
||||
};
|
||||
} SwitchAnalog;
|
||||
|
||||
// left and right calibration are stored differently for some reason, so two structs
|
||||
struct SwitchLeftCalibration {
|
||||
typedef struct {
|
||||
uint8_t data[9];
|
||||
|
||||
void getMin(uint16_t& x, uint16_t& y) const { packCalib(6, x, y); }
|
||||
|
|
@ -145,9 +137,9 @@ struct SwitchLeftCalibration {
|
|||
x = static_cast<uint16_t>(data[offset]) | ((data[offset + 1] & 0x0F) << 8);
|
||||
y = static_cast<uint16_t>(data[offset + 2] << 4) | (data[offset + 1] >> 4);
|
||||
}
|
||||
};
|
||||
} SwitchLeftCalibration;
|
||||
|
||||
struct SwitchRightCalibration {
|
||||
typedef struct {
|
||||
uint8_t data[9];
|
||||
|
||||
void getMin(uint16_t& x, uint16_t& y) const { packCalib(3, x, y); }
|
||||
|
|
@ -180,7 +172,7 @@ struct SwitchRightCalibration {
|
|||
x = static_cast<uint16_t>(data[offset]) | ((data[offset + 1] & 0x0F) << 8);
|
||||
y = static_cast<uint16_t>(data[offset + 2] << 4) | (data[offset + 1] >> 4);
|
||||
}
|
||||
};
|
||||
} SwitchRightCalibration;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
|
|
@ -377,19 +369,8 @@ static const uint8_t switch_pro_configuration_descriptor[] =
|
|||
{
|
||||
0x09, // bLength
|
||||
0x02, // bDescriptorType (Configuration)
|
||||
#if SWITCH_PICO_HID_INSTANCE_COUNT == 1
|
||||
0x29, 0x00, // wTotalLength 41
|
||||
0x01, // bNumInterfaces 1
|
||||
#elif SWITCH_PICO_HID_INSTANCE_COUNT == 2
|
||||
0x49, 0x00, // wTotalLength 73
|
||||
0x02, // bNumInterfaces 2
|
||||
#elif SWITCH_PICO_HID_INSTANCE_COUNT == 3
|
||||
0x69, 0x00, // wTotalLength 105
|
||||
0x03, // bNumInterfaces 3
|
||||
#else
|
||||
0x89, 0x00, // wTotalLength 137
|
||||
0x04, // bNumInterfaces 4
|
||||
#endif
|
||||
0x01, // bConfigurationValue
|
||||
0x00, // iConfiguration (String Index)
|
||||
0xA0, // bmAttributes Remote Wakeup
|
||||
|
|
@ -426,108 +407,6 @@ static const uint8_t switch_pro_configuration_descriptor[] =
|
|||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
|
||||
#if SWITCH_PICO_HID_INSTANCE_COUNT >= 2
|
||||
0x09, // bLength
|
||||
0x04, // bDescriptorType (Interface)
|
||||
0x01, // bInterfaceNumber 1
|
||||
0x00, // bAlternateSetting
|
||||
0x02, // bNumEndpoints 2
|
||||
0x03, // bInterfaceClass
|
||||
0x00, // bInterfaceSubClass
|
||||
0x00, // bInterfaceProtocol
|
||||
0x00, // iInterface (String Index)
|
||||
|
||||
0x09, // bLength
|
||||
0x21, // bDescriptorType (HID)
|
||||
0x11, 0x01, // bcdHID 1.11
|
||||
0x00, // bCountryCode
|
||||
0x01, // bNumDescriptors
|
||||
0x22, // bDescriptorType[0] (HID)
|
||||
0xCB, 0x00, // wDescriptorLength[0] 203
|
||||
|
||||
0x07, // bLength
|
||||
0x05, // bDescriptorType (Endpoint)
|
||||
0x82, // bEndpointAddress (IN/D2H)
|
||||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
|
||||
0x07, // bLength
|
||||
0x05, // bDescriptorType (Endpoint)
|
||||
0x02, // bEndpointAddress (OUT/H2D)
|
||||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
#endif
|
||||
|
||||
#if SWITCH_PICO_HID_INSTANCE_COUNT >= 3
|
||||
0x09, // bLength
|
||||
0x04, // bDescriptorType (Interface)
|
||||
0x02, // bInterfaceNumber 2
|
||||
0x00, // bAlternateSetting
|
||||
0x02, // bNumEndpoints 2
|
||||
0x03, // bInterfaceClass
|
||||
0x00, // bInterfaceSubClass
|
||||
0x00, // bInterfaceProtocol
|
||||
0x00, // iInterface (String Index)
|
||||
|
||||
0x09, // bLength
|
||||
0x21, // bDescriptorType (HID)
|
||||
0x11, 0x01, // bcdHID 1.11
|
||||
0x00, // bCountryCode
|
||||
0x01, // bNumDescriptors
|
||||
0x22, // bDescriptorType[0] (HID)
|
||||
0xCB, 0x00, // wDescriptorLength[0] 203
|
||||
|
||||
0x07, // bLength
|
||||
0x05, // bDescriptorType (Endpoint)
|
||||
0x83, // bEndpointAddress (IN/D2H)
|
||||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
|
||||
0x07, // bLength
|
||||
0x05, // bDescriptorType (Endpoint)
|
||||
0x03, // bEndpointAddress (OUT/H2D)
|
||||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
#endif
|
||||
|
||||
#if SWITCH_PICO_HID_INSTANCE_COUNT >= 4
|
||||
0x09, // bLength
|
||||
0x04, // bDescriptorType (Interface)
|
||||
0x03, // bInterfaceNumber 3
|
||||
0x00, // bAlternateSetting
|
||||
0x02, // bNumEndpoints 2
|
||||
0x03, // bInterfaceClass
|
||||
0x00, // bInterfaceSubClass
|
||||
0x00, // bInterfaceProtocol
|
||||
0x00, // iInterface (String Index)
|
||||
|
||||
0x09, // bLength
|
||||
0x21, // bDescriptorType (HID)
|
||||
0x11, 0x01, // bcdHID 1.11
|
||||
0x00, // bCountryCode
|
||||
0x01, // bNumDescriptors
|
||||
0x22, // bDescriptorType[0] (HID)
|
||||
0xCB, 0x00, // wDescriptorLength[0] 203
|
||||
|
||||
0x07, // bLength
|
||||
0x05, // bDescriptorType (Endpoint)
|
||||
0x84, // bEndpointAddress (IN/D2H)
|
||||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
|
||||
0x07, // bLength
|
||||
0x05, // bDescriptorType (Endpoint)
|
||||
0x04, // bEndpointAddress (OUT/H2D)
|
||||
0x03, // bmAttributes (Interrupt)
|
||||
0x40, 0x00, // wMaxPacketSize 64
|
||||
0x08, // bInterval 8 (unit depends on device speed)
|
||||
#endif
|
||||
};
|
||||
|
||||
static const uint8_t switch_pro_report_descriptor[] =
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,10 +8,8 @@
|
|||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "switch_haptics.h"
|
||||
#include "switch_pro_descriptors.h"
|
||||
|
||||
|
||||
typedef struct {
|
||||
int16_t accel_x;
|
||||
int16_t accel_y;
|
||||
|
|
@ -50,68 +48,23 @@ typedef struct {
|
|||
uint8_t imu_sample_count; // 0-3
|
||||
SwitchImuSample imu_samples[3];
|
||||
} SwitchInputState;
|
||||
typedef struct {
|
||||
uint8_t red;
|
||||
uint8_t green;
|
||||
uint8_t blue;
|
||||
} SwitchRgbColor;
|
||||
constexpr SwitchRgbColor switch_pro_calibrate_light_color(
|
||||
SwitchRgbColor grip) {
|
||||
const uint8_t minimum =
|
||||
grip.red < grip.green
|
||||
? (grip.red < grip.blue ? grip.red : grip.blue)
|
||||
: (grip.green < grip.blue ? grip.green : grip.blue);
|
||||
const uint8_t maximum =
|
||||
grip.red > grip.green
|
||||
? (grip.red > grip.blue ? grip.red : grip.blue)
|
||||
: (grip.green > grip.blue ? grip.green : grip.blue);
|
||||
const uint16_t chroma = static_cast<uint16_t>(maximum - minimum);
|
||||
const uint16_t peak =
|
||||
static_cast<uint16_t>((static_cast<uint16_t>(maximum) * 2u + 1u) /
|
||||
3u);
|
||||
if (chroma == 0) {
|
||||
const uint8_t gray = static_cast<uint8_t>(peak);
|
||||
return {gray, gray, gray};
|
||||
}
|
||||
|
||||
const auto calibrate = [minimum, chroma, peak](uint8_t component) {
|
||||
const uint32_t delta =
|
||||
static_cast<uint32_t>(component - minimum);
|
||||
return static_cast<uint8_t>(
|
||||
(static_cast<uint32_t>(peak) * delta * delta) /
|
||||
(static_cast<uint32_t>(chroma) * chroma));
|
||||
};
|
||||
return {calibrate(grip.red), calibrate(grip.green),
|
||||
calibrate(grip.blue)};
|
||||
}
|
||||
// Initialize USB state and calibration before entering the main loop.
|
||||
void switch_pro_init();
|
||||
|
||||
// Update the desired controller state for the next USB report.
|
||||
void switch_pro_set_input(const SwitchInputState& state);
|
||||
|
||||
// Return the configured Switch grip color and its automatically calibrated
|
||||
// physical LED color for one HID/controller slot.
|
||||
SwitchRgbColor switch_pro_get_slot_color(uint8_t instance);
|
||||
SwitchRgbColor switch_pro_get_slot_light_color(uint8_t instance);
|
||||
|
||||
|
||||
// Initialize one HID instance before entering the main loop.
|
||||
void switch_pro_init(uint8_t instance);
|
||||
|
||||
// Update the desired controller state for one HID instance.
|
||||
void switch_pro_set_input(uint8_t instance, const SwitchInputState& state);
|
||||
|
||||
// Drive one Switch Pro USB state machine; returns true only when a regular
|
||||
// 0x30 input report was successfully queued.
|
||||
bool switch_pro_task(uint8_t instance);
|
||||
// Drive the Switch Pro USB state machine; call this frequently in the main loop.
|
||||
void switch_pro_task();
|
||||
|
||||
// Convert a packed UART message into controller state (returns true if parsed).
|
||||
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
|
||||
SwitchInputState& out_state);
|
||||
// If out_state is null the parsed state is written directly to the driver.
|
||||
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, SwitchInputState* out_state = nullptr);
|
||||
|
||||
// Driver state helpers
|
||||
bool switch_pro_is_ready(uint8_t instance);
|
||||
bool switch_pro_is_ready();
|
||||
|
||||
// Optional callback fired with decoded rumble intensities from one host
|
||||
// interface.
|
||||
typedef void (*SwitchRumbleCallback)(uint8_t instance,
|
||||
const SwitchRumbleOutput& rumble);
|
||||
void switch_pro_set_rumble_callback(uint8_t instance,
|
||||
SwitchRumbleCallback callback);
|
||||
// Optional callback fired when the host sends a rumble payload (the raw 8 rumble bytes).
|
||||
typedef void (*SwitchRumbleCallback)(const uint8_t rumble_data[8]);
|
||||
void switch_pro_set_rumble_callback(SwitchRumbleCallback cb);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,212 +0,0 @@
|
|||
#include "parser/uni_hid_parser_imu.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void expect(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
void write_calibration_value(
|
||||
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob,
|
||||
uni_psmove_imu_model_t model, uint8_t offset, int32_t value) {
|
||||
const uint16_t encoded =
|
||||
model == UNI_PSMOVE_IMU_MODEL_ZCM1
|
||||
? static_cast<uint16_t>(value + 0x8000)
|
||||
: static_cast<uint16_t>(static_cast<int16_t>(value));
|
||||
blob[offset] = static_cast<uint8_t>(encoded);
|
||||
blob[offset + 1] = static_cast<uint8_t>(encoded >> 8u);
|
||||
}
|
||||
|
||||
uint16_t encode_input(uni_psmove_imu_model_t model, int32_t value) {
|
||||
return model == UNI_PSMOVE_IMU_MODEL_ZCM1
|
||||
? static_cast<uint16_t>(value + 0x8000)
|
||||
: static_cast<uint16_t>(static_cast<int16_t>(value));
|
||||
}
|
||||
|
||||
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> first_report(
|
||||
const std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob) {
|
||||
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> report{};
|
||||
std::memcpy(report.data(), blob.data(), report.size());
|
||||
report[0] = 0x10;
|
||||
report[1] = 0x00;
|
||||
return report;
|
||||
}
|
||||
|
||||
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> continuation_report(
|
||||
const std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob,
|
||||
uint8_t block, size_t blob_offset) {
|
||||
std::array<uint8_t, UNI_PSMOVE_CALIBRATION_REPORT_SIZE> report{};
|
||||
report[0] = 0x10;
|
||||
report[1] = block;
|
||||
std::memcpy(report.data() + 2, blob.data() + blob_offset,
|
||||
report.size() - 2);
|
||||
return report;
|
||||
}
|
||||
|
||||
void set_accel_calibration(
|
||||
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE>& blob,
|
||||
uni_psmove_imu_model_t model, int32_t low, int32_t high) {
|
||||
const uint8_t* low_offsets;
|
||||
const uint8_t* high_offsets;
|
||||
static const uint8_t zcm1_low[] = {0x0a, 0x24, 0x14};
|
||||
static const uint8_t zcm1_high[] = {0x16, 0x1e, 0x08};
|
||||
static const uint8_t zcm2_low[] = {0x08, 0x16, 0x24};
|
||||
static const uint8_t zcm2_high[] = {0x02, 0x10, 0x1e};
|
||||
if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
|
||||
low_offsets = zcm1_low;
|
||||
high_offsets = zcm1_high;
|
||||
} else {
|
||||
low_offsets = zcm2_low;
|
||||
high_offsets = zcm2_high;
|
||||
}
|
||||
for (uint8_t axis = 0; axis < 3; ++axis) {
|
||||
write_calibration_value(blob, model, low_offsets[axis], low);
|
||||
write_calibration_value(blob, model, high_offsets[axis], high);
|
||||
}
|
||||
}
|
||||
|
||||
void test_wii_accelerometer() {
|
||||
int32_t output[3]{};
|
||||
uni_imu_normalize_wii_accel(100, -50, 25, output);
|
||||
expect(output[0] == -8192 && output[1] == 2048 &&
|
||||
output[2] == -4096,
|
||||
"Wii accelerometer scale or SDL axis mapping is wrong");
|
||||
}
|
||||
|
||||
void test_zcm1_calibration_and_normalization() {
|
||||
constexpr auto model = UNI_PSMOVE_IMU_MODEL_ZCM1;
|
||||
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE> blob{};
|
||||
set_accel_calibration(blob, model, -1000, 1000);
|
||||
expect(uni_psmove_scale_gyro(32767, -32768, 1,
|
||||
1080 * UNI_IMU_GYRO_RES_PER_DEG_S) ==
|
||||
INT32_MAX,
|
||||
"corrupt PS Move calibration overflow was not clamped");
|
||||
const uint8_t bias_offsets[] = {0x2a, 0x2c, 0x2e};
|
||||
const uint8_t high_offsets[] = {0x46, 0x50, 0x5a};
|
||||
for (uint8_t axis = 0; axis < 3; ++axis) {
|
||||
write_calibration_value(blob, model, bias_offsets[axis], 0);
|
||||
write_calibration_value(blob, model, high_offsets[axis], 1000);
|
||||
}
|
||||
|
||||
auto first = first_report(blob);
|
||||
auto second = continuation_report(blob, 0x01, 49);
|
||||
auto third = continuation_report(blob, 0x82, 96);
|
||||
uni_psmove_imu_calibration_t calibration{};
|
||||
expect(uni_psmove_add_calibration_report(
|
||||
&calibration, model, second.data(), second.size()) ==
|
||||
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
|
||||
"ZCM1 second calibration block was not accepted out of order");
|
||||
expect(uni_psmove_add_calibration_report(
|
||||
&calibration, model, first.data(), first.size()) ==
|
||||
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
|
||||
"ZCM1 first calibration block completed too early");
|
||||
expect(uni_psmove_add_calibration_report(
|
||||
&calibration, model, third.data(), third.size()) ==
|
||||
UNI_PSMOVE_CALIBRATION_COMPLETE,
|
||||
"ZCM1 calibration did not complete");
|
||||
|
||||
const uint16_t accel_first[] = {
|
||||
encode_input(model, 1000), encode_input(model, 0),
|
||||
encode_input(model, -1000)};
|
||||
const uint16_t accel_second[] = {
|
||||
encode_input(model, 0), encode_input(model, 0),
|
||||
encode_input(model, -1000)};
|
||||
const uint16_t gyro_first[] = {
|
||||
encode_input(model, 500), encode_input(model, 0),
|
||||
encode_input(model, -500)};
|
||||
const uint16_t gyro_second[] = {
|
||||
encode_input(model, 500), encode_input(model, 0),
|
||||
encode_input(model, -500)};
|
||||
uni_imu_fixed_sample_t output{};
|
||||
expect(uni_psmove_normalize_imu(
|
||||
model, &calibration, accel_first, accel_second, gyro_first,
|
||||
gyro_second, &output),
|
||||
"ZCM1 calibrated sample was rejected");
|
||||
expect(output.accel[0] == 4096 && output.accel[1] == 0 &&
|
||||
output.accel[2] == -8192,
|
||||
"ZCM1 accelerometer normalization is wrong");
|
||||
expect(output.gyro[0] == 245760 && output.gyro[1] == 0 &&
|
||||
output.gyro[2] == -245760,
|
||||
"ZCM1 gyroscope normalization is wrong");
|
||||
}
|
||||
|
||||
void test_zcm2_calibration_and_normalization() {
|
||||
constexpr auto model = UNI_PSMOVE_IMU_MODEL_ZCM2;
|
||||
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE> blob{};
|
||||
set_accel_calibration(blob, model, -1000, 1000);
|
||||
const uint8_t bias_offsets[] = {0x26, 0x28, 0x2a};
|
||||
const uint8_t low_offsets[] = {0x42, 0x4a, 0x52};
|
||||
const uint8_t high_offsets[] = {0x30, 0x38, 0x40};
|
||||
for (uint8_t axis = 0; axis < 3; ++axis) {
|
||||
write_calibration_value(blob, model, bias_offsets[axis], 100);
|
||||
write_calibration_value(blob, model, low_offsets[axis], -900);
|
||||
write_calibration_value(blob, model, high_offsets[axis], 1100);
|
||||
}
|
||||
|
||||
auto first = first_report(blob);
|
||||
auto second = continuation_report(blob, 0x81, 49);
|
||||
uni_psmove_imu_calibration_t calibration{};
|
||||
expect(uni_psmove_add_calibration_report(
|
||||
&calibration, model, first.data(), first.size()) ==
|
||||
UNI_PSMOVE_CALIBRATION_INCOMPLETE,
|
||||
"ZCM2 first calibration block completed too early");
|
||||
expect(uni_psmove_add_calibration_report(
|
||||
&calibration, model, second.data(), second.size()) ==
|
||||
UNI_PSMOVE_CALIBRATION_COMPLETE,
|
||||
"ZCM2 calibration did not complete");
|
||||
|
||||
const uint16_t accel[] = {
|
||||
encode_input(model, -1000), encode_input(model, 0),
|
||||
encode_input(model, 1000)};
|
||||
const uint16_t gyro[] = {
|
||||
encode_input(model, -900), encode_input(model, 100),
|
||||
encode_input(model, 1100)};
|
||||
uni_imu_fixed_sample_t output{};
|
||||
expect(uni_psmove_normalize_imu(model, &calibration, accel, accel,
|
||||
gyro, gyro, &output),
|
||||
"ZCM2 calibrated sample was rejected");
|
||||
expect(output.accel[0] == -8192 && output.accel[1] == 0 &&
|
||||
output.accel[2] == 8192,
|
||||
"ZCM2 signed accelerometer normalization is wrong");
|
||||
expect(output.gyro[0] == -552960 && output.gyro[1] == 0 &&
|
||||
output.gyro[2] == 552960,
|
||||
"ZCM2 signed gyroscope normalization is wrong");
|
||||
}
|
||||
|
||||
void test_uncalibrated_psmove_is_suppressed() {
|
||||
uni_psmove_imu_calibration_t calibration{};
|
||||
const uint16_t values[] = {0xffff, 0xffff, 0xffff};
|
||||
uni_imu_fixed_sample_t output{{1, 2, 3}, {4, 5, 6}};
|
||||
expect(!uni_psmove_normalize_imu(
|
||||
UNI_PSMOVE_IMU_MODEL_ZCM1, &calibration, values, values,
|
||||
values, values, &output),
|
||||
"uncalibrated PS Move sample was accepted");
|
||||
expect(output.accel[0] == 0 && output.accel[1] == 0 &&
|
||||
output.accel[2] == 0 && output.gyro[0] == 0 &&
|
||||
output.gyro[1] == 0 && output.gyro[2] == 0,
|
||||
"uncalibrated PS Move motion was not neutralized");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_wii_accelerometer();
|
||||
test_zcm1_calibration_and_normalization();
|
||||
test_zcm2_calibration_and_normalization();
|
||||
test_uncalibrated_psmove_is_suppressed();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " IMU normalization test(s) failed\n";
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct btstack_timer_source_t {
|
||||
void (*handler)(btstack_timer_source_t*);
|
||||
uint32_t timeout_ms;
|
||||
};
|
||||
|
||||
inline void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer,
|
||||
void (*handler)(btstack_timer_source_t*)) {
|
||||
timer->handler = handler;
|
||||
}
|
||||
|
||||
inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
|
||||
uint32_t timeout_ms) {
|
||||
timer->timeout_ms = timeout_ms;
|
||||
}
|
||||
|
||||
inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {}
|
||||
uint32_t btstack_run_loop_get_time_ms();
|
||||
inline void btstack_run_loop_execute() {}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
struct critical_section_t {};
|
||||
|
||||
inline void critical_section_init(critical_section_t*) {}
|
||||
inline void critical_section_enter_blocking(critical_section_t*) {}
|
||||
inline void critical_section_exit(critical_section_t*) {}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#define CYW43_WL_GPIO_LED_PIN 0
|
||||
|
||||
int cyw43_arch_init();
|
||||
void cyw43_arch_gpio_put(int pin, bool value);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
bool flash_safe_execute_core_init();
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
void multicore_launch_core1(void (*entry)());
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
void tight_loop_contents();
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef uint8_t bd_addr_t[6];
|
||||
typedef uint8_t link_key_t[16];
|
||||
typedef uint8_t sm_key_t[16];
|
||||
typedef int link_key_type_t;
|
||||
|
||||
enum bd_addr_type_t {
|
||||
BD_ADDR_TYPE_LE_PUBLIC = 0,
|
||||
BD_ADDR_TYPE_LE_RANDOM = 1,
|
||||
BD_ADDR_TYPE_LE_PUBLIC_IDENTITY = 2,
|
||||
BD_ADDR_TYPE_LE_RANDOM_IDENTITY = 3,
|
||||
BD_ADDR_TYPE_UNKNOWN = 0xfe,
|
||||
};
|
||||
|
||||
enum hci_link_type_t {
|
||||
HCI_LINK_TYPE_SCO = 0,
|
||||
HCI_LINK_TYPE_ACL = 1,
|
||||
};
|
||||
|
||||
struct btstack_link_key_iterator_t {
|
||||
int index;
|
||||
};
|
||||
|
||||
enum {
|
||||
ERROR_CODE_SUCCESS = 0,
|
||||
HCI_EVENT_PACKET = 4,
|
||||
HCI_EVENT_USER_CONFIRMATION_REQUEST = 0x33,
|
||||
HCI_EVENT_USER_PASSKEY_REQUEST = 0x34,
|
||||
SM_STK_GENERATION_METHOD_JUST_WORKS = 0x01,
|
||||
SM_STK_GENERATION_METHOD_OOB = 0x02,
|
||||
SM_STK_GENERATION_METHOD_PASSKEY = 0x04,
|
||||
SM_STK_GENERATION_METHOD_NUMERIC_COMPARISON = 0x08,
|
||||
};
|
||||
typedef int uni_property_idx_t;
|
||||
typedef int uni_platform_oob_event_t;
|
||||
struct uni_property_t {};
|
||||
|
||||
enum uni_error_t {
|
||||
UNI_ERROR_SUCCESS = 0,
|
||||
UNI_ERROR_IGNORE_DEVICE = 1,
|
||||
UNI_ERROR_INVALID_CONTROLLER = 2,
|
||||
UNI_ERROR_NO_SLOTS = 3,
|
||||
};
|
||||
|
||||
enum {
|
||||
UNI_CONTROLLER_CLASS_GAMEPAD = 1,
|
||||
DPAD_UP = 1 << 0,
|
||||
DPAD_DOWN = 1 << 1,
|
||||
DPAD_LEFT = 1 << 2,
|
||||
DPAD_RIGHT = 1 << 3,
|
||||
BUTTON_A = 1 << 0,
|
||||
BUTTON_B = 1 << 1,
|
||||
BUTTON_X = 1 << 2,
|
||||
BUTTON_Y = 1 << 3,
|
||||
BUTTON_SHOULDER_L = 1 << 4,
|
||||
BUTTON_SHOULDER_R = 1 << 5,
|
||||
BUTTON_TRIGGER_L = 1 << 6,
|
||||
BUTTON_TRIGGER_R = 1 << 7,
|
||||
BUTTON_THUMB_L = 1 << 8,
|
||||
BUTTON_THUMB_R = 1 << 9,
|
||||
MISC_BUTTON_SYSTEM = 1 << 0,
|
||||
MISC_BUTTON_SELECT = 1 << 1,
|
||||
MISC_BUTTON_START = 1 << 2,
|
||||
MISC_BUTTON_CAPTURE = 1 << 3,
|
||||
};
|
||||
|
||||
struct uni_gamepad_t {
|
||||
uint32_t dpad;
|
||||
uint32_t buttons;
|
||||
uint32_t misc_buttons;
|
||||
int32_t axis_x;
|
||||
int32_t axis_y;
|
||||
int32_t axis_rx;
|
||||
int32_t axis_ry;
|
||||
int32_t brake;
|
||||
int32_t throttle;
|
||||
int32_t accel[3];
|
||||
int32_t gyro[3];
|
||||
};
|
||||
|
||||
struct uni_controller_t {
|
||||
int klass;
|
||||
uni_gamepad_t gamepad;
|
||||
};
|
||||
|
||||
struct uni_hid_device_t;
|
||||
typedef void (*btstack_packet_handler_t)(uint8_t, uint16_t, uint8_t*,
|
||||
uint16_t);
|
||||
struct btstack_packet_callback_registration_t {
|
||||
void* item;
|
||||
btstack_packet_handler_t callback;
|
||||
};
|
||||
typedef void (*uni_play_dual_rumble_t)(uni_hid_device_t*, uint16_t,
|
||||
uint16_t, uint8_t, uint8_t);
|
||||
typedef void (*uni_set_player_leds_t)(uni_hid_device_t*, uint8_t);
|
||||
typedef void (*uni_set_lightbar_color_t)(uni_hid_device_t*, uint8_t, uint8_t,
|
||||
uint8_t);
|
||||
|
||||
struct uni_report_parser_t {
|
||||
uni_set_player_leds_t set_player_leds;
|
||||
uni_set_lightbar_color_t set_lightbar_color;
|
||||
uni_play_dual_rumble_t play_dual_rumble;
|
||||
};
|
||||
|
||||
enum uni_bt_conn_protocol_t {
|
||||
UNI_BT_CONN_PROTOCOL_NONE,
|
||||
UNI_BT_CONN_PROTOCOL_BR_EDR,
|
||||
UNI_BT_CONN_PROTOCOL_BLE,
|
||||
};
|
||||
|
||||
|
||||
struct uni_bt_conn_t {
|
||||
bd_addr_t btaddr;
|
||||
uni_bt_conn_protocol_t protocol;
|
||||
};
|
||||
|
||||
struct uni_hid_device_t {
|
||||
uni_bt_conn_t conn;
|
||||
int idx;
|
||||
bool gamepad;
|
||||
uni_report_parser_t report_parser;
|
||||
int rumble_calls;
|
||||
uint8_t last_high;
|
||||
uint8_t last_low;
|
||||
uint16_t last_rumble_duration_ms;
|
||||
int lightbar_calls;
|
||||
uint8_t lightbar_red;
|
||||
uint8_t lightbar_green;
|
||||
uint8_t lightbar_blue;
|
||||
int player_led_calls;
|
||||
uint8_t player_leds;
|
||||
};
|
||||
|
||||
struct uni_platform {
|
||||
const char* name;
|
||||
void (*init)(int, const char**);
|
||||
void (*on_init_complete)();
|
||||
uni_error_t (*on_device_discovered)(bd_addr_t, const char*, uint16_t,
|
||||
uint8_t);
|
||||
void (*on_device_connected)(uni_hid_device_t*);
|
||||
void (*on_device_disconnected)(uni_hid_device_t*);
|
||||
uni_error_t (*on_device_ready)(uni_hid_device_t*);
|
||||
void* on_device_oob_event;
|
||||
void (*on_controller_data)(uni_hid_device_t*, uni_controller_t*);
|
||||
const uni_property_t* (*get_property)(uni_property_idx_t);
|
||||
void (*on_oob_event)(uni_platform_oob_event_t, void*);
|
||||
void* on_device_dump;
|
||||
void* on_gamepad_seat;
|
||||
};
|
||||
|
||||
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device);
|
||||
int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device);
|
||||
void uni_hid_device_disconnect(uni_hid_device_t* device);
|
||||
void uni_bt_allow_incoming_connections(bool enabled);
|
||||
void uni_bt_start_scanning_and_autoconnect_unsafe();
|
||||
void uni_bt_stop_scanning_unsafe();
|
||||
void uni_bt_bredr_scan_start();
|
||||
void uni_bt_bredr_scan_stop();
|
||||
void uni_bt_le_scan_start();
|
||||
void uni_bt_le_scan_stop();
|
||||
void uni_bt_del_keys_unsafe();
|
||||
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator);
|
||||
int gap_link_key_iterator_get_next(
|
||||
btstack_link_key_iterator_t* iterator, bd_addr_t address,
|
||||
link_key_t link_key, link_key_type_t* type);
|
||||
void gap_link_key_iterator_done(btstack_link_key_iterator_t* iterator);
|
||||
int le_device_db_max_count();
|
||||
void le_device_db_info(
|
||||
int index, int* address_type, bd_addr_t address, sm_key_t irk);
|
||||
void gap_set_bondable_mode(int enabled);
|
||||
void gap_set_link_supervision_timeout(uint16_t link_supervision_timeout);
|
||||
void gap_ssp_set_auto_accept(int auto_accept);
|
||||
void sm_set_accepted_stk_generation_methods(
|
||||
uint8_t accepted_stk_generation_methods);
|
||||
int gap_ssp_confirmation_response(const bd_addr_t address);
|
||||
int gap_ssp_confirmation_negative(const bd_addr_t address);
|
||||
int gap_ssp_passkey_response(const bd_addr_t address, uint32_t passkey);
|
||||
int gap_ssp_passkey_negative(const bd_addr_t address);
|
||||
void hci_add_event_handler(
|
||||
btstack_packet_callback_registration_t* callback_handler);
|
||||
uint8_t hci_event_packet_get_type(const uint8_t* packet);
|
||||
void hci_event_user_confirmation_request_get_bd_addr(
|
||||
const uint8_t* packet, bd_addr_t address);
|
||||
void hci_event_user_passkey_request_get_bd_addr(
|
||||
const uint8_t* packet, bd_addr_t address);
|
||||
void uni_platform_set_custom(uni_platform* platform);
|
||||
int uni_init(int argc, const char** argv);
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
using io_rw_32 = volatile uint32_t;
|
||||
|
||||
enum gpio_override {
|
||||
GPIO_OVERRIDE_NORMAL = 0,
|
||||
GPIO_OVERRIDE_LOW = 2,
|
||||
};
|
||||
|
||||
void bootsel_test_masked_write(io_rw_32* address, uint32_t values,
|
||||
uint32_t mask);
|
||||
|
||||
inline void hw_write_masked(io_rw_32* address, uint32_t values,
|
||||
uint32_t mask) {
|
||||
*address = (*address & ~mask) | (values & mask);
|
||||
bootsel_test_masked_write(address, values, mask);
|
||||
}
|
||||
|
||||
#if PICO_RP2350
|
||||
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB 14u
|
||||
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS 0x0000c000u
|
||||
#else
|
||||
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB 12u
|
||||
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS 0x00003000u
|
||||
#endif
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#define SIO_GPIO_HI_IN_QSPI_CSN_BITS 0x08000000u
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "hardware/gpio.h"
|
||||
|
||||
struct ioqspi_status_ctrl_hw_t {
|
||||
io_rw_32 status;
|
||||
io_rw_32 ctrl;
|
||||
};
|
||||
|
||||
struct ioqspi_hw_t {
|
||||
ioqspi_status_ctrl_hw_t io[6];
|
||||
};
|
||||
|
||||
extern ioqspi_hw_t* ioqspi_hw;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct sio_hw_t {
|
||||
volatile uint32_t gpio_in;
|
||||
volatile uint32_t gpio_hi_in;
|
||||
};
|
||||
|
||||
extern sio_hw_t* sio_hw;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#define __no_inline_not_in_flash_func(function_name) function_name
|
||||
|
||||
constexpr int PICO_OK = 0;
|
||||
|
||||
int flash_safe_execute(void (*function)(void*), void* parameter,
|
||||
uint32_t enter_exit_timeout_ms);
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
using absolute_time_t = uint64_t;
|
||||
|
||||
absolute_time_t get_absolute_time();
|
||||
uint64_t to_ms_since_boot(absolute_time_t time);
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
#include "bootsel_pairing_button.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include "hardware/gpio.h"
|
||||
#include "hardware/regs/sio.h"
|
||||
#include "hardware/structs/ioqspi.h"
|
||||
#include "hardware/structs/sio.h"
|
||||
#include "pico/flash.h"
|
||||
#include "pico/time.h"
|
||||
|
||||
namespace {
|
||||
|
||||
#if PICO_RP2350
|
||||
constexpr uint32_t kBootselInputMask = SIO_GPIO_HI_IN_QSPI_CSN_BITS;
|
||||
#else
|
||||
constexpr uint32_t kBootselInputMask = 1u << 1u;
|
||||
#endif
|
||||
|
||||
struct FlashResponse {
|
||||
int result;
|
||||
bool pressed;
|
||||
};
|
||||
|
||||
ioqspi_hw_t qspi_registers{};
|
||||
sio_hw_t sio_registers{};
|
||||
uint64_t now_ms = 0;
|
||||
std::vector<FlashResponse> flash_responses;
|
||||
std::size_t next_flash_response = 0;
|
||||
std::vector<uint32_t> qspi_override_writes;
|
||||
int flash_safe_calls = 0;
|
||||
bool inside_flash_safe_callback = false;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<BootselPairingButtonEvent> apply_pressed(
|
||||
BootselPairingButtonHoldFsm& fsm, int count) {
|
||||
std::vector<BootselPairingButtonEvent> events;
|
||||
for (int sample = 0; sample < count; ++sample) {
|
||||
const BootselPairingButtonEvent event =
|
||||
fsm.update(BootselPairingButtonSample::kPressed);
|
||||
if (event != BootselPairingButtonEvent::kNone) {
|
||||
events.push_back(event);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
void test_short_press() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"a 19-sample press must not complete the hold");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a short-press release must not report a hold");
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"a release must discard the previous short press");
|
||||
}
|
||||
|
||||
void test_pairing_and_clear_events_once() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"the pairing hold must not fire before sample 20");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"pairing must fire on exactly sample 20");
|
||||
require(apply_pressed(fsm, 79).empty(),
|
||||
"a long hold must not fire between pairing and clearing");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"clearing must fire on exactly sample 100");
|
||||
require(apply_pressed(fsm, 100).empty(),
|
||||
"a continuously held button must not repeat either event");
|
||||
}
|
||||
|
||||
void test_release_and_rearm() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
const auto first_events = apply_pressed(fsm, 100);
|
||||
require(first_events.size() == 2 &&
|
||||
first_events[0] ==
|
||||
BootselPairingButtonEvent::kOpenPairing &&
|
||||
first_events[1] ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"the initial long hold must report pairing then clearing");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"release must rearm without reporting an event");
|
||||
const auto second_events = apply_pressed(fsm, 20);
|
||||
require(second_events.size() == 1 &&
|
||||
second_events[0] ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a valid release must permit a later pairing hold");
|
||||
}
|
||||
|
||||
void test_unread_samples_do_not_transition() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 10).empty(),
|
||||
"the first half of a pairing hold must not fire");
|
||||
for (int sample = 0; sample < 8; ++sample) {
|
||||
require(fsm.update(BootselPairingButtonSample::kUnread) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"unread press samples must not report or reset a hold");
|
||||
}
|
||||
require(apply_pressed(fsm, 9).empty(),
|
||||
"valid pressed samples must resume after unread samples");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"20 valid pressed samples must fire despite unread samples");
|
||||
|
||||
require(fsm.update(BootselPairingButtonSample::kUnread) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"an unread release must not rearm a completed hold");
|
||||
require(apply_pressed(fsm, 79).empty(),
|
||||
"the long hold must continue across an unread sample");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"100 valid pressed samples must clear despite unread samples");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a valid release must only rearm");
|
||||
const auto events = apply_pressed(fsm, 20);
|
||||
require(events.size() == 1 &&
|
||||
events[0] == BootselPairingButtonEvent::kOpenPairing,
|
||||
"the FSM must fire after the eventual valid release");
|
||||
}
|
||||
|
||||
BootselPairingButtonEvent run_sample(
|
||||
uint64_t sample_time_ms, int result, bool pressed) {
|
||||
flash_responses.push_back({result, pressed});
|
||||
now_ms = sample_time_ms;
|
||||
const std::size_t expected_consumed = flash_responses.size();
|
||||
const BootselPairingButtonEvent event = bootsel_pairing_button_task();
|
||||
require(next_flash_response == expected_consumed,
|
||||
"a due poll must invoke flash_safe_execute exactly once");
|
||||
return event;
|
||||
}
|
||||
|
||||
void test_sampler_cadence_and_callback_failure() {
|
||||
now_ms = 0;
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must wait for its first 100 ms cadence");
|
||||
now_ms = 99;
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must not poll before 100 ms");
|
||||
require(flash_safe_calls == 0,
|
||||
"sub-cadence task calls must not enter flash-safe execution");
|
||||
|
||||
require(run_sample(100, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the first valid pressed sample must only start the hold");
|
||||
require(flash_safe_calls == 1 && qspi_override_writes.size() == 2,
|
||||
"a successful sample must float and restore QSPI CSn once");
|
||||
const uint32_t disabled =
|
||||
GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB;
|
||||
require(qspi_override_writes[0] == disabled,
|
||||
"the callback must float QSPI CSn before reading BOOTSEL");
|
||||
require(qspi_override_writes[1] == 0,
|
||||
"the callback must restore normal QSPI CSn control");
|
||||
|
||||
now_ms = 199;
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must remain gated between 10 Hz polls");
|
||||
require(flash_safe_calls == 1,
|
||||
"an early task call must not sample BOOTSEL");
|
||||
|
||||
const std::size_t writes_before_failure = qspi_override_writes.size();
|
||||
require(run_sample(200, -1, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"flash-safe failure must be treated as unread");
|
||||
require(qspi_override_writes.size() == writes_before_failure,
|
||||
"a failed flash-safe entry must not invoke the callback");
|
||||
|
||||
for (uint64_t time = 300; time < 2100; time += 100) {
|
||||
require(run_sample(time, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must wait for 20 valid pressed samples");
|
||||
}
|
||||
require(run_sample(2100, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a failed sample must not reset the valid pressed count");
|
||||
require(run_sample(2200, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a held button must not repeat pairing");
|
||||
|
||||
require(run_sample(2300, -1, false) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a failed release sample must remain unread");
|
||||
require(run_sample(2400, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"an unread release must not rearm the sampler FSM");
|
||||
require(run_sample(2500, PICO_OK, false) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a valid release must rearm without firing");
|
||||
|
||||
for (uint64_t time = 2600; time < 4500; time += 100) {
|
||||
require(run_sample(time, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the rearmed sampler must count a fresh hold");
|
||||
}
|
||||
require(run_sample(4500, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a valid release must permit a second pairing hold");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ioqspi_hw_t* ioqspi_hw = &qspi_registers;
|
||||
sio_hw_t* sio_hw = &sio_registers;
|
||||
|
||||
absolute_time_t get_absolute_time() {
|
||||
return now_ms;
|
||||
}
|
||||
|
||||
uint64_t to_ms_since_boot(absolute_time_t time) {
|
||||
return time;
|
||||
}
|
||||
|
||||
void bootsel_test_masked_write(io_rw_32* address, uint32_t, uint32_t mask) {
|
||||
require(inside_flash_safe_callback,
|
||||
"QSPI override writes must occur inside flash_safe_execute");
|
||||
require(address == &ioqspi_hw->io[1].ctrl,
|
||||
"the callback must only override QSPI CSn");
|
||||
qspi_override_writes.push_back(*address & mask);
|
||||
}
|
||||
|
||||
int flash_safe_execute(void (*function)(void*), void* parameter,
|
||||
uint32_t enter_exit_timeout_ms) {
|
||||
require(enter_exit_timeout_ms == 100,
|
||||
"BOOTSEL sampling must use the 100 ms flash-safe timeout");
|
||||
require(next_flash_response < flash_responses.size(),
|
||||
"flash-safe execution requires a queued test response");
|
||||
++flash_safe_calls;
|
||||
const FlashResponse response = flash_responses[next_flash_response++];
|
||||
if (response.result != PICO_OK) {
|
||||
return response.result;
|
||||
}
|
||||
|
||||
sio_hw->gpio_hi_in = response.pressed ? 0 : kBootselInputMask;
|
||||
inside_flash_safe_callback = true;
|
||||
function(parameter);
|
||||
inside_flash_safe_callback = false;
|
||||
require((ioqspi_hw->io[1].ctrl &
|
||||
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS) == 0,
|
||||
"the callback must restore QSPI CSn before returning");
|
||||
return PICO_OK;
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_short_press();
|
||||
test_pairing_and_clear_events_once();
|
||||
test_release_and_rearm();
|
||||
test_unread_samples_do_not_transition();
|
||||
test_sampler_cadence_and_callback_failure();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
uint32_t get_rand_32(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
uint64_t milliseconds;
|
||||
} absolute_time_t;
|
||||
|
||||
absolute_time_t get_absolute_time(void);
|
||||
uint32_t to_ms_since_boot(absolute_time_t time);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
HID_REPORT_TYPE_INVALID = 0,
|
||||
HID_REPORT_TYPE_INPUT = 1,
|
||||
HID_REPORT_TYPE_OUTPUT = 2,
|
||||
HID_REPORT_TYPE_FEATURE = 3,
|
||||
} hid_report_type_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t bmRequestType;
|
||||
uint8_t bRequest;
|
||||
uint16_t wValue;
|
||||
uint16_t wIndex;
|
||||
uint16_t wLength;
|
||||
} tusb_control_request_t;
|
||||
|
||||
bool tud_hid_n_ready(uint8_t instance);
|
||||
bool tud_hid_n_report(uint8_t instance, uint8_t report_id,
|
||||
const void* report, uint16_t length);
|
||||
bool tud_suspended(void);
|
||||
bool tud_remote_wakeup(void);
|
||||
|
||||
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
|
||||
hid_report_type_t report_type, uint8_t* buffer,
|
||||
uint16_t requested_length);
|
||||
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
|
||||
hid_report_type_t report_type,
|
||||
const uint8_t* buffer, uint16_t buffer_size);
|
||||
void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id,
|
||||
const uint8_t* buffer, uint16_t buffer_size);
|
||||
uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance);
|
||||
void tud_mount_cb(void);
|
||||
void tud_umount_cb(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
#include "switch_haptics.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void expect_output(const char* scenario, SwitchRumbleOutput actual,
|
||||
uint8_t expected_low, uint8_t expected_high) {
|
||||
if (actual.low_frequency_magnitude == expected_low &&
|
||||
actual.high_frequency_magnitude == expected_high) {
|
||||
return;
|
||||
}
|
||||
std::cerr << scenario << ": expected low/high "
|
||||
<< static_cast<unsigned>(expected_low) << "/"
|
||||
<< static_cast<unsigned>(expected_high) << ", got "
|
||||
<< static_cast<unsigned>(actual.low_frequency_magnitude) << "/"
|
||||
<< static_cast<unsigned>(actual.high_frequency_magnitude) << '\n';
|
||||
++failures;
|
||||
}
|
||||
|
||||
uint32_t type_2(uint8_t high_frequency, uint8_t high_amplitude,
|
||||
uint8_t low_frequency, uint8_t low_amplitude) {
|
||||
return (1u << 30u) |
|
||||
((static_cast<uint32_t>(low_amplitude) & 0x7fu) << 23u) |
|
||||
((static_cast<uint32_t>(low_frequency) & 0x7fu) << 16u) |
|
||||
((static_cast<uint32_t>(high_amplitude) & 0x7fu) << 9u) |
|
||||
((static_cast<uint32_t>(high_frequency) & 0x7fu) << 2u);
|
||||
}
|
||||
|
||||
uint32_t type_1_one_sample(uint8_t high_command, uint8_t low_command) {
|
||||
return (1u << 30u) |
|
||||
((static_cast<uint32_t>(low_command) & 0x1fu) << 25u) |
|
||||
((static_cast<uint32_t>(high_command) & 0x1fu) << 20u);
|
||||
}
|
||||
|
||||
uint32_t type_1_three_samples(uint8_t high_0, uint8_t low_0,
|
||||
uint8_t high_1, uint8_t low_1,
|
||||
uint8_t high_2, uint8_t low_2) {
|
||||
return (3u << 30u) |
|
||||
((static_cast<uint32_t>(low_0) & 0x1fu) << 25u) |
|
||||
((static_cast<uint32_t>(high_0) & 0x1fu) << 20u) |
|
||||
((static_cast<uint32_t>(low_1) & 0x1fu) << 15u) |
|
||||
((static_cast<uint32_t>(high_1) & 0x1fu) << 10u) |
|
||||
((static_cast<uint32_t>(low_2) & 0x1fu) << 5u) |
|
||||
(static_cast<uint32_t>(high_2) & 0x1fu);
|
||||
}
|
||||
|
||||
std::array<uint8_t, 8> payload(uint32_t left, uint32_t right) {
|
||||
std::array<uint8_t, 8> bytes{};
|
||||
const uint32_t words[2] = {left, right};
|
||||
for (unsigned actuator = 0; actuator < 2; ++actuator) {
|
||||
const unsigned offset = actuator * 4u;
|
||||
bytes[offset] = static_cast<uint8_t>(words[actuator]);
|
||||
bytes[offset + 1u] = static_cast<uint8_t>(words[actuator] >> 8u);
|
||||
bytes[offset + 2u] = static_cast<uint8_t>(words[actuator] >> 16u);
|
||||
bytes[offset + 3u] = static_cast<uint8_t>(words[actuator] >> 24u);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void test_neutral_and_per_actuator_reset() {
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
SwitchHapticsDecoder decoder;
|
||||
|
||||
auto frame = payload(neutral, neutral);
|
||||
expect_output("explicit neutral", decoder.decode(frame.data()), 0, 0);
|
||||
|
||||
frame = payload(type_2(90, 16, 50, 127), type_2(100, 32, 40, 16));
|
||||
expect_output("active actuators", decoder.decode(frame.data()), 250, 32);
|
||||
|
||||
decoder.reset();
|
||||
frame = payload(1u << 5u, 1u << 5u);
|
||||
expect_output("explicit decoder reset", decoder.decode(frame.data()), 0, 0);
|
||||
|
||||
frame = payload(type_2(90, 16, 50, 127), type_2(100, 32, 40, 16));
|
||||
decoder.decode(frame.data());
|
||||
|
||||
frame = payload(0, type_2(100, 32, 40, 16));
|
||||
expect_output("zero resets only left actuator", decoder.decode(frame.data()), 16, 32);
|
||||
|
||||
frame = payload(0, neutral);
|
||||
expect_output("neutral resets right actuator", decoder.decode(frame.data()), 0, 0);
|
||||
}
|
||||
|
||||
void test_type_2_full_state_and_band_mapping() {
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
SwitchHapticsDecoder decoder;
|
||||
const auto frame = payload(type_2(100, 32, 20, 16), neutral);
|
||||
expect_output("type-2 low/high mapping", decoder.decode(frame.data()), 16, 32);
|
||||
}
|
||||
|
||||
void test_type_1_relative_update_and_idempotence() {
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
SwitchHapticsDecoder decoder;
|
||||
|
||||
auto frame = payload(type_2(64, 16, 64, 16), neutral);
|
||||
expect_output("relative update initial state", decoder.decode(frame.data()), 16, 16);
|
||||
|
||||
frame = payload(type_1_one_sample(17, 20), neutral);
|
||||
expect_output("type-1 relative update", decoder.decode(frame.data()), 17, 18);
|
||||
expect_output("identical delta is idempotent", decoder.decode(frame.data()), 17, 18);
|
||||
}
|
||||
|
||||
void test_subsample_peak_and_repeated_current_state() {
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
SwitchHapticsDecoder decoder;
|
||||
|
||||
auto frame = payload(type_2(64, 16, 64, 16), neutral);
|
||||
decoder.decode(frame.data());
|
||||
|
||||
frame = payload(type_1_three_samples(17, 17, 29, 29, 24, 24), neutral);
|
||||
expect_output("peak across three subsamples", decoder.decode(frame.data()), 18, 18);
|
||||
expect_output("repeat returns final cumulative state", decoder.decode(frame.data()), 16, 16);
|
||||
}
|
||||
|
||||
void test_left_right_peak_combination() {
|
||||
SwitchHapticsDecoder decoder;
|
||||
const auto frame = payload(type_2(90, 1, 50, 127), type_2(100, 32, 40, 1));
|
||||
expect_output("independent actuator band peaks", decoder.decode(frame.data()), 250, 32);
|
||||
}
|
||||
|
||||
void test_type_3_and_type_4_frames() {
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
SwitchHapticsDecoder decoder;
|
||||
|
||||
auto frame = payload(type_2(64, 16, 64, 16), neutral);
|
||||
decoder.decode(frame.data());
|
||||
|
||||
const uint32_t type3 = (2u << 30u) | 1u | (70u << 1u) |
|
||||
(24u << 8u) | (17u << 13u) |
|
||||
(20u << 18u) | (32u << 23u);
|
||||
frame = payload(type3, neutral);
|
||||
expect_output("type-3 full plus relative samples", decoder.decode(frame.data()), 18, 32);
|
||||
|
||||
const uint32_t type4_low_amplitude = (1u << 30u) | 2u | (32u << 23u);
|
||||
frame = payload(type4_low_amplitude, neutral);
|
||||
expect_output("type-4 low amplitude selection", decoder.decode(frame.data()), 32, 32);
|
||||
|
||||
const uint32_t type4_high_amplitude = (1u << 30u) | 3u | (127u << 23u);
|
||||
frame = payload(type4_high_amplitude, neutral);
|
||||
expect_output("type-4 high amplitude selection", decoder.decode(frame.data()), 32, 250);
|
||||
}
|
||||
|
||||
void test_malformed_and_reserved_words_preserve_state() {
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
SwitchHapticsDecoder decoder;
|
||||
|
||||
auto frame = payload(type_2(100, 32, 20, 16), neutral);
|
||||
decoder.decode(frame.data());
|
||||
|
||||
frame = payload((1u << 30u) | 1u, neutral);
|
||||
expect_output("reserved type discriminator", decoder.decode(frame.data()), 16, 32);
|
||||
|
||||
frame = payload(1u << 5u, neutral);
|
||||
expect_output("zero-frame word clears high band", decoder.decode(frame.data()), 16, 0);
|
||||
}
|
||||
|
||||
void test_output_report_normalization() {
|
||||
const uint8_t stripped[] = {
|
||||
0x0a,
|
||||
0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40,
|
||||
};
|
||||
uint8_t output[64]{};
|
||||
|
||||
size_t size = normalize_switch_output_report(0x01, stripped, sizeof(stripped), output);
|
||||
if (size != sizeof(stripped) + 1 || output[0] != 0x01 ||
|
||||
output[1] != 0x0a || output[2] != 0x00 || output[9] != 0x40) {
|
||||
std::cerr << "stripped 0x01 report normalization failed\n";
|
||||
++failures;
|
||||
}
|
||||
|
||||
size = normalize_switch_output_report(0x10, stripped, sizeof(stripped), output);
|
||||
if (size != sizeof(stripped) + 1 || output[0] != 0x10 ||
|
||||
output[1] != 0x0a || output[2] != 0x00 || output[9] != 0x40) {
|
||||
std::cerr << "stripped 0x10 report normalization failed\n";
|
||||
++failures;
|
||||
}
|
||||
|
||||
const uint8_t complete[] = {
|
||||
0x10, 0x0a,
|
||||
0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40,
|
||||
};
|
||||
size = normalize_switch_output_report(0, complete, sizeof(complete), output);
|
||||
if (size != sizeof(complete) || output[0] != 0x10 ||
|
||||
output[1] != 0x0a || output[9] != 0x40) {
|
||||
std::cerr << "complete interrupt report normalization failed\n";
|
||||
++failures;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 64> oversized{};
|
||||
if (normalize_switch_output_report(0x01, oversized.data(), oversized.size(), output) != 0) {
|
||||
std::cerr << "oversized stripped report was accepted\n";
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_neutral_and_per_actuator_reset();
|
||||
test_type_2_full_state_and_band_mapping();
|
||||
test_type_1_relative_update_and_idempotence();
|
||||
test_subsample_peak_and_repeated_current_state();
|
||||
test_left_right_peak_combination();
|
||||
test_type_3_and_type_4_frames();
|
||||
test_malformed_and_reserved_words_preserve_state();
|
||||
test_output_report_normalization();
|
||||
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " haptics test(s) failed\n";
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
#include "switch_pro_descriptors.h"
|
||||
#include "tusb_config.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#ifndef EXPECTED_HID_INSTANCE_COUNT
|
||||
#error "EXPECTED_HID_INSTANCE_COUNT must be defined by the test build"
|
||||
#endif
|
||||
|
||||
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == EXPECTED_HID_INSTANCE_COUNT,
|
||||
"the requested HID instance count did not reach the descriptors");
|
||||
static_assert(CFG_TUD_HID == EXPECTED_HID_INSTANCE_COUNT,
|
||||
"TinyUSB HID count differs from the descriptor count");
|
||||
static_assert(sizeof(switch_pro_configuration_descriptor) ==
|
||||
9u + 32u * EXPECTED_HID_INSTANCE_COUNT,
|
||||
"configuration descriptor has the wrong total size");
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint8_t kConfigurationDescriptor = 0x02;
|
||||
constexpr uint8_t kInterfaceDescriptor = 0x04;
|
||||
constexpr uint8_t kEndpointDescriptor = 0x05;
|
||||
constexpr uint8_t kHidDescriptor = 0x21;
|
||||
|
||||
#if EXPECTED_HID_INSTANCE_COUNT == 1
|
||||
constexpr std::array<uint8_t, 41> kUartConfigurationDescriptor = {
|
||||
0x09, 0x02, 0x29, 0x00, 0x01, 0x01, 0x00, 0xA0, 0xFA,
|
||||
0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00,
|
||||
0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0xCB, 0x00,
|
||||
0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x08,
|
||||
0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x08,
|
||||
};
|
||||
#endif
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void expect(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t read_u16(const uint8_t* bytes) {
|
||||
return static_cast<uint16_t>(bytes[0]) |
|
||||
(static_cast<uint16_t>(bytes[1]) << 8u);
|
||||
}
|
||||
|
||||
struct InterfaceContract {
|
||||
bool present = false;
|
||||
bool in_endpoint = false;
|
||||
bool out_endpoint = false;
|
||||
uint8_t endpoint_count = 0;
|
||||
uint8_t hid_count = 0;
|
||||
};
|
||||
|
||||
void inspect_configuration_descriptor() {
|
||||
const auto* descriptor = switch_pro_configuration_descriptor;
|
||||
constexpr size_t descriptor_size =
|
||||
sizeof(switch_pro_configuration_descriptor);
|
||||
#if EXPECTED_HID_INSTANCE_COUNT == 1
|
||||
expect(std::memcmp(descriptor, kUartConfigurationDescriptor.data(),
|
||||
descriptor_size) == 0,
|
||||
"UART configuration descriptor bytes changed");
|
||||
#endif
|
||||
|
||||
expect(descriptor[0] == 9 && descriptor[1] == kConfigurationDescriptor,
|
||||
"configuration header is malformed");
|
||||
expect(read_u16(descriptor + 2) == descriptor_size,
|
||||
"wTotalLength does not match the emitted descriptor");
|
||||
expect(descriptor[4] == EXPECTED_HID_INSTANCE_COUNT,
|
||||
"bNumInterfaces does not match the HID instance count");
|
||||
|
||||
std::array<InterfaceContract, EXPECTED_HID_INSTANCE_COUNT> interfaces{};
|
||||
std::array<bool, 256> endpoint_addresses{};
|
||||
int current_interface = -1;
|
||||
size_t offset = descriptor[0];
|
||||
|
||||
while (offset < descriptor_size) {
|
||||
const uint8_t length = descriptor[offset];
|
||||
expect(length >= 2, "descriptor block has an invalid length");
|
||||
if (length < 2) {
|
||||
break;
|
||||
}
|
||||
expect(offset + length <= descriptor_size,
|
||||
"descriptor block extends beyond wTotalLength");
|
||||
if (offset + length > descriptor_size) {
|
||||
break;
|
||||
}
|
||||
|
||||
const uint8_t type = descriptor[offset + 1];
|
||||
if (type == kInterfaceDescriptor) {
|
||||
expect(length == 9, "interface descriptor has the wrong length");
|
||||
const uint8_t number = descriptor[offset + 2];
|
||||
expect(number < interfaces.size(),
|
||||
"interface number is outside the configured range");
|
||||
if (number < interfaces.size()) {
|
||||
expect(!interfaces[number].present,
|
||||
"interface number is duplicated");
|
||||
interfaces[number].present = true;
|
||||
current_interface = number;
|
||||
} else {
|
||||
current_interface = -1;
|
||||
}
|
||||
expect(descriptor[offset + 3] == 0,
|
||||
"interface uses an unexpected alternate setting");
|
||||
expect(descriptor[offset + 4] == 2,
|
||||
"interface does not declare two endpoints");
|
||||
expect(descriptor[offset + 5] == 0x03,
|
||||
"interface is not HID class");
|
||||
} else if (type == kHidDescriptor) {
|
||||
expect(current_interface >= 0,
|
||||
"HID descriptor appears before an interface");
|
||||
expect(length == sizeof(switch_pro_hid_descriptor),
|
||||
"HID descriptor has the wrong length");
|
||||
expect(std::memcmp(descriptor + offset, switch_pro_hid_descriptor,
|
||||
sizeof(switch_pro_hid_descriptor)) == 0,
|
||||
"interfaces do not reuse the shared HID/report contract");
|
||||
expect(read_u16(descriptor + offset + 7) ==
|
||||
sizeof(switch_pro_report_descriptor),
|
||||
"HID descriptor advertises the wrong report descriptor size");
|
||||
if (current_interface >= 0) {
|
||||
++interfaces[static_cast<size_t>(current_interface)].hid_count;
|
||||
}
|
||||
} else if (type == kEndpointDescriptor) {
|
||||
expect(current_interface >= 0,
|
||||
"endpoint descriptor appears before an interface");
|
||||
expect(length == 7, "endpoint descriptor has the wrong length");
|
||||
const uint8_t address = descriptor[offset + 2];
|
||||
expect(!endpoint_addresses[address],
|
||||
"endpoint address is duplicated across interfaces");
|
||||
endpoint_addresses[address] = true;
|
||||
expect(descriptor[offset + 3] == 0x03,
|
||||
"endpoint is not interrupt type");
|
||||
expect(read_u16(descriptor + offset + 4) ==
|
||||
SWITCH_PRO_ENDPOINT_SIZE,
|
||||
"endpoint has the wrong maximum packet size");
|
||||
expect(descriptor[offset + 6] == 8,
|
||||
"endpoint has the wrong polling interval");
|
||||
|
||||
if (current_interface >= 0) {
|
||||
auto& interface =
|
||||
interfaces[static_cast<size_t>(current_interface)];
|
||||
++interface.endpoint_count;
|
||||
const uint8_t endpoint_number =
|
||||
static_cast<uint8_t>(current_interface + 1);
|
||||
if ((address & 0x80u) != 0) {
|
||||
expect(address == static_cast<uint8_t>(0x80u | endpoint_number),
|
||||
"IN endpoint does not belong to its interface");
|
||||
interface.in_endpoint = true;
|
||||
} else {
|
||||
expect(address == endpoint_number,
|
||||
"OUT endpoint does not belong to its interface");
|
||||
interface.out_endpoint = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += length;
|
||||
}
|
||||
|
||||
expect(offset == descriptor_size,
|
||||
"descriptor parser did not finish at wTotalLength");
|
||||
for (const auto& interface : interfaces) {
|
||||
expect(interface.present, "configured HID interface is missing");
|
||||
expect(interface.hid_count == 1,
|
||||
"interface does not contain exactly one HID descriptor");
|
||||
expect(interface.endpoint_count == 2,
|
||||
"interface does not contain exactly two endpoints");
|
||||
expect(interface.in_endpoint && interface.out_endpoint,
|
||||
"interface is missing an IN or OUT endpoint");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
inspect_configuration_descriptor();
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
|
@ -1,696 +0,0 @@
|
|||
#include "switch_pro_driver.h"
|
||||
#include "controller_color_config.h"
|
||||
#include "tusb.h"
|
||||
#include "pico/time.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t kInstanceCount = SWITCH_PICO_HID_INSTANCE_COUNT;
|
||||
constexpr uint8_t kInvalidInstance = kInstanceCount;
|
||||
static_assert(kInstanceCount == 4,
|
||||
"the native driver harness must exercise four HID instances");
|
||||
|
||||
|
||||
struct SentReport {
|
||||
uint8_t instance = 0;
|
||||
uint8_t report_id = 0;
|
||||
uint16_t length = 0;
|
||||
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> data{};
|
||||
};
|
||||
|
||||
struct RumbleEvent {
|
||||
unsigned count = 0;
|
||||
uint8_t instance = 0xff;
|
||||
SwitchRumbleOutput output{};
|
||||
};
|
||||
|
||||
uint64_t now_ms = 0;
|
||||
uint32_t random_value = 1;
|
||||
std::array<bool, kInstanceCount> hid_ready{};
|
||||
std::array<bool, kInstanceCount> hid_report_succeeds{};
|
||||
std::array<unsigned, kInstanceCount> hid_report_attempts{};
|
||||
std::array<SentReport, 32> sent_reports{};
|
||||
unsigned sent_report_count = 0;
|
||||
std::array<RumbleEvent, kInstanceCount> rumble_events{};
|
||||
int failures = 0;
|
||||
|
||||
void expect(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
void clear_sent_reports() {
|
||||
sent_reports = {};
|
||||
sent_report_count = 0;
|
||||
}
|
||||
|
||||
void initialize_contexts() {
|
||||
now_ms = 0;
|
||||
hid_report_attempts = {};
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
hid_ready[instance] = true;
|
||||
hid_report_succeeds[instance] = true;
|
||||
switch_pro_init(instance);
|
||||
}
|
||||
clear_sent_reports();
|
||||
}
|
||||
|
||||
const SentReport* latest_regular_report(uint8_t instance) {
|
||||
for (unsigned i = sent_report_count; i > 0; --i) {
|
||||
const SentReport& report = sent_reports[i - 1];
|
||||
if (report.instance == instance &&
|
||||
report.length == sizeof(SwitchProReport) &&
|
||||
report.data[0] == 0x30) {
|
||||
return &report;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
SwitchProReport copy_switch_report(const SentReport* sent) {
|
||||
SwitchProReport report{};
|
||||
if (sent != nullptr) {
|
||||
std::memcpy(&report, sent->data.data(), sizeof(report));
|
||||
}
|
||||
return report;
|
||||
}
|
||||
SwitchProReport get_current_report(uint8_t instance,
|
||||
const char* length_failure) {
|
||||
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> data{};
|
||||
expect(tud_hid_get_report_cb(instance, 0, HID_REPORT_TYPE_INPUT,
|
||||
data.data(), data.size()) ==
|
||||
sizeof(SwitchProReport),
|
||||
length_failure);
|
||||
SwitchProReport report{};
|
||||
std::memcpy(&report, data.data(), sizeof(report));
|
||||
return report;
|
||||
}
|
||||
|
||||
void expect_neutral_sticks(SwitchProReport& report,
|
||||
const char* state_failure) {
|
||||
constexpr uint16_t packed_mid = SWITCH_PRO_JOYSTICK_MID >> 4u;
|
||||
constexpr uint16_t packed_inverted_mid =
|
||||
static_cast<uint16_t>(-static_cast<int32_t>(packed_mid)) & 0x0fffu;
|
||||
expect(report.inputs.leftStick.getX() == packed_mid &&
|
||||
report.inputs.leftStick.getY() == packed_inverted_mid &&
|
||||
report.inputs.rightStick.getX() == packed_mid &&
|
||||
report.inputs.rightStick.getY() == packed_inverted_mid,
|
||||
state_failure);
|
||||
}
|
||||
|
||||
|
||||
unsigned reports_for_instance(uint8_t instance) {
|
||||
unsigned count = 0;
|
||||
for (unsigned i = 0; i < sent_report_count; ++i) {
|
||||
if (sent_reports[i].instance == instance) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
uint32_t read_bits_le(const uint8_t* bytes, uint16_t bit_offset,
|
||||
uint8_t width) {
|
||||
uint32_t value = 0;
|
||||
for (uint8_t bit = 0; bit < width; ++bit) {
|
||||
uint16_t source_bit = static_cast<uint16_t>(bit_offset + bit);
|
||||
if ((bytes[source_bit >> 3] & (1u << (source_bit & 7u))) != 0) {
|
||||
value |= 1u << bit;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int16_t read_int16_le(const uint8_t* bytes) {
|
||||
return static_cast<int16_t>(
|
||||
static_cast<uint16_t>(bytes[0]) |
|
||||
(static_cast<uint16_t>(bytes[1]) << 8u));
|
||||
}
|
||||
|
||||
void send_feature(uint8_t instance, uint8_t command, uint8_t value) {
|
||||
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> report{};
|
||||
report[0] = REPORT_FEATURE;
|
||||
report[10] = command;
|
||||
report[11] = value;
|
||||
tud_hid_report_received_cb(instance, 0, report.data(), report.size());
|
||||
}
|
||||
void send_spi_read(uint8_t instance, uint32_t address, uint8_t size) {
|
||||
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> report{};
|
||||
report[0] = REPORT_FEATURE;
|
||||
report[10] = SPI_READ;
|
||||
report[11] = static_cast<uint8_t>(address);
|
||||
report[12] = static_cast<uint8_t>(address >> 8u);
|
||||
report[13] = static_cast<uint8_t>(address >> 16u);
|
||||
report[14] = static_cast<uint8_t>(address >> 24u);
|
||||
report[15] = size;
|
||||
tud_hid_report_received_cb(instance, 0, report.data(), report.size());
|
||||
}
|
||||
|
||||
|
||||
void send_config(uint8_t instance, uint8_t subtype) {
|
||||
const uint8_t report[] = {REPORT_CONFIGURATION, subtype};
|
||||
tud_hid_report_received_cb(instance, 0, report, sizeof(report));
|
||||
}
|
||||
|
||||
uint32_t type_2(uint8_t high_frequency, uint8_t high_amplitude,
|
||||
uint8_t low_frequency, uint8_t low_amplitude) {
|
||||
return (1u << 30u) |
|
||||
((static_cast<uint32_t>(low_amplitude) & 0x7fu) << 23u) |
|
||||
((static_cast<uint32_t>(low_frequency) & 0x7fu) << 16u) |
|
||||
((static_cast<uint32_t>(high_amplitude) & 0x7fu) << 9u) |
|
||||
((static_cast<uint32_t>(high_frequency) & 0x7fu) << 2u);
|
||||
}
|
||||
|
||||
uint32_t type_1_one_sample(uint8_t high_command, uint8_t low_command) {
|
||||
return (1u << 30u) |
|
||||
((static_cast<uint32_t>(low_command) & 0x1fu) << 25u) |
|
||||
((static_cast<uint32_t>(high_command) & 0x1fu) << 20u);
|
||||
}
|
||||
|
||||
std::array<uint8_t, 8> rumble_payload(uint32_t left, uint32_t right) {
|
||||
std::array<uint8_t, 8> payload{};
|
||||
const uint32_t words[] = {left, right};
|
||||
for (unsigned actuator = 0; actuator < 2; ++actuator) {
|
||||
unsigned offset = actuator * 4u;
|
||||
payload[offset] = static_cast<uint8_t>(words[actuator]);
|
||||
payload[offset + 1] = static_cast<uint8_t>(words[actuator] >> 8u);
|
||||
payload[offset + 2] = static_cast<uint8_t>(words[actuator] >> 16u);
|
||||
payload[offset + 3] = static_cast<uint8_t>(words[actuator] >> 24u);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 10> complete_rumble_report(
|
||||
const std::array<uint8_t, 8>& payload) {
|
||||
std::array<uint8_t, 10> report{};
|
||||
report[0] = REPORT_OUTPUT_10;
|
||||
std::memcpy(report.data() + 2, payload.data(), payload.size());
|
||||
return report;
|
||||
}
|
||||
|
||||
void rumble_callback(uint8_t instance, const SwitchRumbleOutput& output) {
|
||||
expect(instance < rumble_events.size(),
|
||||
"rumble callback received an invalid instance");
|
||||
if (instance >= rumble_events.size()) {
|
||||
return;
|
||||
}
|
||||
RumbleEvent& event = rumble_events[instance];
|
||||
++event.count;
|
||||
event.instance = instance;
|
||||
event.output = output;
|
||||
}
|
||||
|
||||
void test_reset_materializes_neutral_sticks() {
|
||||
initialize_contexts();
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
SwitchProReport initialized = get_current_report(
|
||||
instance, "GET_REPORT failed immediately after init");
|
||||
expect_neutral_sticks(
|
||||
initialized, "instance sticks were not neutral after init");
|
||||
}
|
||||
|
||||
tud_mount_cb();
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
SwitchProReport mounted = get_current_report(
|
||||
instance, "GET_REPORT failed immediately after mount");
|
||||
expect_neutral_sticks(
|
||||
mounted, "instance sticks were not neutral after mount");
|
||||
}
|
||||
}
|
||||
|
||||
void test_startup_identify_preserves_first_reply_counter() {
|
||||
initialize_contexts();
|
||||
tud_mount_cb();
|
||||
|
||||
expect(!switch_pro_task(0), "startup identify counted as regular input");
|
||||
expect(sent_report_count == 1 && sent_reports[0].instance == 0 &&
|
||||
sent_reports[0].data[0] == REPORT_USB_INPUT_81 &&
|
||||
sent_reports[0].data[1] == IDENTIFY,
|
||||
"startup identify did not use the addressed raw HID route");
|
||||
|
||||
send_feature(0, GET_CONTROLLER_STATE, 0);
|
||||
now_ms = 6;
|
||||
expect(!switch_pro_task(0), "first subcommand reply counted as regular input");
|
||||
expect(sent_report_count == 2 &&
|
||||
sent_reports[1].data[0] == REPORT_OUTPUT_21 &&
|
||||
sent_reports[1].data[1] == 0,
|
||||
"startup identify consumed the first subcommand reply counter");
|
||||
}
|
||||
|
||||
void test_failed_startup_identify_retries_preserve_counter() {
|
||||
initialize_contexts();
|
||||
tud_mount_cb();
|
||||
hid_report_succeeds[0] = false;
|
||||
|
||||
switch_pro_task(0);
|
||||
switch_pro_task(0);
|
||||
expect(hid_report_attempts[0] == 2 && reports_for_instance(0) == 0,
|
||||
"failed startup identify was not retried");
|
||||
|
||||
hid_report_succeeds[0] = true;
|
||||
expect(!switch_pro_task(0), "retried startup identify counted as regular input");
|
||||
expect(hid_report_attempts[0] == 3 && reports_for_instance(0) == 1,
|
||||
"startup identify did not recover after failed sends");
|
||||
|
||||
send_feature(0, GET_CONTROLLER_STATE, 0);
|
||||
now_ms = 6;
|
||||
switch_pro_task(0);
|
||||
expect(sent_report_count == 2 &&
|
||||
sent_reports[1].data[0] == REPORT_OUTPUT_21 &&
|
||||
sent_reports[1].data[1] == 0,
|
||||
"failed startup identify retries consumed the reply counter");
|
||||
}
|
||||
|
||||
void test_input_reports_and_timers_are_isolated() {
|
||||
initialize_contexts();
|
||||
std::array<SwitchInputState, kInstanceCount> states{};
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
SwitchInputState& state = states[instance];
|
||||
state.lx = static_cast<uint16_t>(0x1111u * (instance + 1u));
|
||||
state.ly = static_cast<uint16_t>(0x2222u + 0x1111u * instance);
|
||||
state.rx = static_cast<uint16_t>(0x5555u + 0x1111u * instance);
|
||||
state.ry = static_cast<uint16_t>(0x8888u + 0x1111u * instance);
|
||||
}
|
||||
states[0].button_a = true;
|
||||
states[1].button_b = true;
|
||||
states[2].button_x = true;
|
||||
states[3].button_y = true;
|
||||
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
switch_pro_set_input(instance, states[instance]);
|
||||
}
|
||||
|
||||
now_ms = 15;
|
||||
std::array<SwitchProReport, kInstanceCount> sent{};
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
expect(switch_pro_task(instance),
|
||||
"configured instance did not send its timed report");
|
||||
const SentReport* routed = latest_regular_report(instance);
|
||||
expect(routed != nullptr, "input report used the wrong HID route");
|
||||
sent[instance] = copy_switch_report(routed);
|
||||
expect(sent[instance].inputs.buttonA == (instance == 0) &&
|
||||
sent[instance].inputs.buttonB == (instance == 1) &&
|
||||
sent[instance].inputs.buttonX == (instance == 2) &&
|
||||
sent[instance].inputs.buttonY == (instance == 3),
|
||||
"button state crossed HID instances");
|
||||
}
|
||||
|
||||
std::array<std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE>, kInstanceCount>
|
||||
current{};
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
expect(tud_hid_get_report_cb(instance, 0, HID_REPORT_TYPE_INPUT,
|
||||
current[instance].data(),
|
||||
current[instance].size()) ==
|
||||
sizeof(SwitchProReport),
|
||||
"GET_REPORT rejected a configured instance");
|
||||
}
|
||||
for (uint8_t left = 0; left < kInstanceCount; ++left) {
|
||||
for (uint8_t right = static_cast<uint8_t>(left + 1u);
|
||||
right < kInstanceCount; ++right) {
|
||||
expect(std::memcmp(current[left].data(), current[right].data(),
|
||||
current[left].size()) != 0,
|
||||
"GET_REPORT returned shared state across HID instances");
|
||||
}
|
||||
}
|
||||
|
||||
SwitchInputState changed_zero = states[0];
|
||||
changed_zero.button_a = false;
|
||||
changed_zero.button_home = true;
|
||||
switch_pro_set_input(0, changed_zero);
|
||||
now_ms = 30;
|
||||
expect(switch_pro_task(0),
|
||||
"instance 0 did not apply its changed input state");
|
||||
SwitchProReport unchanged_three = get_current_report(
|
||||
3, "GET_REPORT failed for instance 3 after instance 0 changed");
|
||||
expect(unchanged_three.inputs.buttonY &&
|
||||
!unchanged_three.inputs.buttonHome,
|
||||
"instance 0 input change leaked into instance 3");
|
||||
|
||||
SwitchInputState changed_three = states[3];
|
||||
changed_three.button_y = false;
|
||||
changed_three.button_capture = true;
|
||||
switch_pro_set_input(3, changed_three);
|
||||
now_ms = 45;
|
||||
expect(switch_pro_task(3),
|
||||
"instance 3 did not apply its changed input state");
|
||||
SwitchProReport unchanged_zero = get_current_report(
|
||||
0, "GET_REPORT failed for instance 0 after instance 3 changed");
|
||||
expect(unchanged_zero.inputs.buttonHome &&
|
||||
!unchanged_zero.inputs.buttonCapture,
|
||||
"instance 3 input change leaked into instance 0");
|
||||
}
|
||||
|
||||
void test_callback_send_and_imu_modes_are_isolated() {
|
||||
initialize_contexts();
|
||||
send_feature(0, TOGGLE_IMU, 1);
|
||||
now_ms = 6;
|
||||
expect(!switch_pro_task(0), "feature reply was reported as regular input");
|
||||
expect(reports_for_instance(0) == 1,
|
||||
"feature callback reply did not use instance 0");
|
||||
expect(reports_for_instance(1) == 0,
|
||||
"feature callback queued a reply on instance 1");
|
||||
|
||||
SwitchInputState zero{};
|
||||
zero.lx = zero.ly = zero.rx = zero.ry = SWITCH_PRO_JOYSTICK_MID;
|
||||
zero.imu_sample_count = 1;
|
||||
zero.imu_samples[0] = {101, 202, 303, 404, 505, 606};
|
||||
SwitchInputState one = zero;
|
||||
one.button_x = true;
|
||||
one.imu_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006};
|
||||
switch_pro_set_input(0, zero);
|
||||
switch_pro_set_input(1, one);
|
||||
now_ms = 21;
|
||||
expect(switch_pro_task(0), "raw-IMU instance did not send input");
|
||||
expect(switch_pro_task(1), "off-IMU instance timer did not send input");
|
||||
SwitchProReport raw = copy_switch_report(latest_regular_report(0));
|
||||
SwitchProReport off = copy_switch_report(latest_regular_report(1));
|
||||
expect(read_int16_le(raw.imuData) == 101 &&
|
||||
read_int16_le(raw.imuData + 6) == 404,
|
||||
"instance 0 raw IMU sample was not preserved");
|
||||
std::array<uint8_t, 36> zero_imu{};
|
||||
expect(std::memcmp(off.imuData, zero_imu.data(), zero_imu.size()) == 0,
|
||||
"instance 0 IMU mode leaked into instance 1");
|
||||
|
||||
initialize_contexts();
|
||||
send_feature(0, TOGGLE_IMU, 2);
|
||||
send_feature(1, TOGGLE_IMU, 2);
|
||||
now_ms = 6;
|
||||
switch_pro_task(0);
|
||||
switch_pro_task(1);
|
||||
SwitchInputState moving{};
|
||||
moving.lx = moving.ly = moving.rx = moving.ry = SWITCH_PRO_JOYSTICK_MID;
|
||||
moving.imu_sample_count = 1;
|
||||
moving.imu_samples[0] = {100, 200, 300, 20000, 0, 0};
|
||||
SwitchInputState stationary{};
|
||||
stationary.lx = stationary.ly = stationary.rx = stationary.ry =
|
||||
SWITCH_PRO_JOYSTICK_MID;
|
||||
stationary.imu_sample_count = 1;
|
||||
stationary.imu_samples[0] = {1000, 2000, 3000, 0, 0, 0};
|
||||
switch_pro_set_input(0, moving);
|
||||
switch_pro_set_input(1, stationary);
|
||||
now_ms = 21;
|
||||
expect(switch_pro_task(0), "moving quaternion instance did not report");
|
||||
expect(switch_pro_task(1), "stationary quaternion timer crossed instances");
|
||||
SwitchProReport moving_report =
|
||||
copy_switch_report(latest_regular_report(0));
|
||||
SwitchProReport stationary_report =
|
||||
copy_switch_report(latest_regular_report(1));
|
||||
bool moving_component =
|
||||
read_bits_le(moving_report.imuData, 52, 21) != 0 ||
|
||||
read_bits_le(moving_report.imuData, 73, 21) != 0 ||
|
||||
read_bits_le(moving_report.imuData, 94, 2) != 0 ||
|
||||
read_bits_le(moving_report.imuData, 144, 19) != 0;
|
||||
bool stationary_component =
|
||||
read_bits_le(stationary_report.imuData, 52, 21) != 0 ||
|
||||
read_bits_le(stationary_report.imuData, 73, 21) != 0 ||
|
||||
read_bits_le(stationary_report.imuData, 94, 2) != 0 ||
|
||||
read_bits_le(stationary_report.imuData, 144, 19) != 0;
|
||||
expect(moving_component, "moving quaternion did not integrate");
|
||||
expect(!stationary_component,
|
||||
"instance 0 quaternion state leaked into instance 1");
|
||||
expect(read_int16_le(stationary_report.imuData) == 2000 &&
|
||||
read_int16_le(stationary_report.imuData + 2) == 1000,
|
||||
"instance 1 quaternion accelerometer state was overwritten");
|
||||
}
|
||||
|
||||
void test_grip_colors_are_isolated() {
|
||||
initialize_contexts();
|
||||
constexpr uint32_t grip_address =
|
||||
0x6000u + offsetof(SwitchFactoryConfig, leftGripColor);
|
||||
constexpr uint8_t grip_bytes =
|
||||
sizeof(SwitchColorDefinition) * 2u;
|
||||
constexpr SwitchRgbColor calibrated_blue =
|
||||
switch_pro_calibrate_light_color({0x00, 0x89, 0xEB});
|
||||
constexpr SwitchRgbColor calibrated_gray =
|
||||
switch_pro_calibrate_light_color({0x96, 0x96, 0x96});
|
||||
static_assert(calibrated_blue.red == 0x00 &&
|
||||
calibrated_blue.green == 0x35 &&
|
||||
calibrated_blue.blue == 0x9D);
|
||||
static_assert(calibrated_gray.red == 0x64 &&
|
||||
calibrated_gray.green == 0x64 &&
|
||||
calibrated_gray.blue == 0x64);
|
||||
|
||||
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
send_spi_read(instance, grip_address, grip_bytes);
|
||||
now_ms += 6;
|
||||
expect(!switch_pro_task(instance),
|
||||
"grip color SPI reply counted as regular input");
|
||||
expect(sent_report_count == static_cast<unsigned>(instance + 1u),
|
||||
"grip color SPI reply was not sent");
|
||||
const SentReport& response = sent_reports[sent_report_count - 1u];
|
||||
const SwitchRgbColor expected =
|
||||
switch_pro_get_slot_color(instance);
|
||||
const uint8_t expected_bytes[] = {
|
||||
expected.red, expected.green, expected.blue,
|
||||
expected.red, expected.green, expected.blue,
|
||||
};
|
||||
expect(response.instance == instance &&
|
||||
response.data[13] == 0x90 &&
|
||||
response.data[14] == SPI_READ &&
|
||||
std::memcmp(response.data.data() + 20, expected_bytes,
|
||||
sizeof(expected_bytes)) == 0,
|
||||
"Switch grip color did not match its HID slot");
|
||||
const SwitchRgbColor light =
|
||||
switch_pro_get_slot_light_color(instance);
|
||||
const SwitchRgbColor calibrated =
|
||||
switch_pro_calibrate_light_color(expected);
|
||||
expect(light.red == calibrated.red &&
|
||||
light.green == calibrated.green &&
|
||||
light.blue == calibrated.blue,
|
||||
"physical controller light was not derived from its grip");
|
||||
}
|
||||
|
||||
const SwitchRgbColor invalid_grip =
|
||||
switch_pro_get_slot_color(kInvalidInstance);
|
||||
const SwitchRgbColor invalid_light =
|
||||
switch_pro_get_slot_light_color(kInvalidInstance);
|
||||
expect(invalid_grip.red == 0 && invalid_grip.green == 0 &&
|
||||
invalid_grip.blue == 0 && invalid_light.red == 0 &&
|
||||
invalid_light.green == 0 && invalid_light.blue == 0,
|
||||
"invalid HID slot returned a configured color");
|
||||
}
|
||||
|
||||
void test_rumble_callbacks_and_decoders_are_isolated() {
|
||||
initialize_contexts();
|
||||
rumble_events = {};
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
switch_pro_set_rumble_callback(instance, rumble_callback);
|
||||
}
|
||||
constexpr uint32_t neutral = 0x40400100u;
|
||||
auto full_payload = rumble_payload(type_2(64, 16, 64, 16), neutral);
|
||||
auto full_report = complete_rumble_report(full_payload);
|
||||
tud_hid_report_received_cb(kInvalidInstance, 0, full_report.data(),
|
||||
full_report.size());
|
||||
for (const auto& event : rumble_events) {
|
||||
expect(event.count == 0,
|
||||
"invalid output instance reached a rumble callback");
|
||||
}
|
||||
|
||||
std::array<uint8_t, 9> stripped{};
|
||||
std::memcpy(stripped.data() + 1, full_payload.data(), full_payload.size());
|
||||
tud_hid_set_report_cb(0, REPORT_OUTPUT_10, HID_REPORT_TYPE_OUTPUT,
|
||||
stripped.data(), stripped.size());
|
||||
expect(rumble_events[0].count == 1 && rumble_events[0].instance == 0,
|
||||
"control output did not route to instance 0 callback");
|
||||
expect(rumble_events[0].output.low_frequency_magnitude == 16 &&
|
||||
rumble_events[0].output.high_frequency_magnitude == 16,
|
||||
"instance 0 full rumble state decoded incorrectly");
|
||||
for (uint8_t instance = 1; instance < kInstanceCount; ++instance) {
|
||||
expect(rumble_events[instance].count == 0,
|
||||
"instance 0 rumble invoked another instance callback");
|
||||
}
|
||||
|
||||
auto delta_payload = rumble_payload(type_1_one_sample(17, 20), neutral);
|
||||
auto delta_report = complete_rumble_report(delta_payload);
|
||||
tud_hid_report_received_cb(1, 0, delta_report.data(), delta_report.size());
|
||||
expect(rumble_events[1].count == 1 && rumble_events[1].instance == 1,
|
||||
"interrupt output did not route to instance 1 callback");
|
||||
expect(rumble_events[1].output.low_frequency_magnitude == 0 &&
|
||||
rumble_events[1].output.high_frequency_magnitude == 1,
|
||||
"instance 1 decoder inherited instance 0 rumble state");
|
||||
tud_hid_report_received_cb(0, 0, delta_report.data(), delta_report.size());
|
||||
expect(rumble_events[0].count == 2 &&
|
||||
rumble_events[0].output.low_frequency_magnitude == 17 &&
|
||||
rumble_events[0].output.high_frequency_magnitude == 18,
|
||||
"instance 0 decoder lost its own prior rumble state");
|
||||
|
||||
for (uint8_t instance = 2; instance < kInstanceCount; ++instance) {
|
||||
const uint8_t magnitude = instance == 2 ? 16 : 32;
|
||||
auto payload =
|
||||
rumble_payload(type_2(64, magnitude, 64, magnitude), neutral);
|
||||
auto report = complete_rumble_report(payload);
|
||||
tud_hid_report_received_cb(instance, 0, report.data(), report.size());
|
||||
expect(rumble_events[instance].count == 1 &&
|
||||
rumble_events[instance].instance == instance,
|
||||
"rumble output did not route to its configured instance");
|
||||
expect(rumble_events[instance].output.low_frequency_magnitude ==
|
||||
magnitude &&
|
||||
rumble_events[instance].output.high_frequency_magnitude ==
|
||||
magnitude,
|
||||
"configured instance decoded another rumble context");
|
||||
}
|
||||
expect(rumble_events[1].count == 1,
|
||||
"another instance's rumble reached instance 1 callback");
|
||||
}
|
||||
|
||||
void test_lifecycle_and_invalid_instances() {
|
||||
initialize_contexts();
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
expect(switch_pro_is_ready(instance),
|
||||
"initialized context was not ready");
|
||||
}
|
||||
|
||||
tud_mount_cb();
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
expect(!switch_pro_is_ready(instance),
|
||||
"mount did not reset every configured context");
|
||||
}
|
||||
|
||||
for (uint8_t addressed = 0; addressed < kInstanceCount; ++addressed) {
|
||||
send_config(addressed, DISABLE_USB_TIMEOUT);
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
expect(switch_pro_is_ready(instance) == (instance <= addressed),
|
||||
"handshake readiness crossed configured contexts");
|
||||
}
|
||||
}
|
||||
|
||||
tud_umount_cb();
|
||||
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
|
||||
expect(!switch_pro_is_ready(instance),
|
||||
"unmount did not reset every configured context");
|
||||
}
|
||||
|
||||
SwitchInputState ignored{};
|
||||
ignored.button_home = true;
|
||||
switch_pro_init(kInvalidInstance);
|
||||
switch_pro_set_input(kInvalidInstance, ignored);
|
||||
switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback);
|
||||
expect(!switch_pro_task(kInvalidInstance),
|
||||
"invalid instance ran a driver task");
|
||||
expect(!switch_pro_is_ready(kInvalidInstance),
|
||||
"invalid instance reported ready");
|
||||
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> buffer{};
|
||||
expect(tud_hid_get_report_cb(kInvalidInstance, 0, HID_REPORT_TYPE_INPUT,
|
||||
buffer.data(), buffer.size()) == 0,
|
||||
"invalid instance served GET_REPORT data");
|
||||
expect(tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr,
|
||||
"invalid instance served a report descriptor");
|
||||
}
|
||||
|
||||
void test_uart_parser_is_pure() {
|
||||
initialize_contexts();
|
||||
SwitchInputState driver_state{};
|
||||
driver_state.lx = driver_state.ly = driver_state.rx = driver_state.ry =
|
||||
SWITCH_PRO_JOYSTICK_MID;
|
||||
driver_state.button_x = true;
|
||||
switch_pro_set_input(0, driver_state);
|
||||
now_ms = 15;
|
||||
switch_pro_task(0);
|
||||
|
||||
std::array<uint8_t, 12> packet{};
|
||||
packet[0] = 0xaa;
|
||||
packet[1] = 0x02;
|
||||
packet[2] = 8;
|
||||
uint16_t buttons = SWITCH_PRO_MASK_A | SWITCH_PRO_MASK_L;
|
||||
packet[3] = static_cast<uint8_t>(buttons);
|
||||
packet[4] = static_cast<uint8_t>(buttons >> 8u);
|
||||
packet[5] = SWITCH_PRO_HAT_DOWNLEFT;
|
||||
packet[6] = 0x12;
|
||||
packet[7] = 0x34;
|
||||
packet[8] = 0x56;
|
||||
packet[9] = 0x78;
|
||||
for (unsigned i = 0; i < packet.size() - 1; ++i) {
|
||||
packet.back() = static_cast<uint8_t>(packet.back() + packet[i]);
|
||||
}
|
||||
SwitchInputState parsed{};
|
||||
expect(switch_pro_apply_uart_packet(packet.data(), packet.size(), parsed),
|
||||
"valid UART packet was rejected");
|
||||
expect(parsed.button_a && parsed.button_l && parsed.dpad_down &&
|
||||
parsed.dpad_left,
|
||||
"UART buttons or hat were parsed incorrectly");
|
||||
expect(parsed.lx == 0x1212 && parsed.ly == 0x3434 &&
|
||||
parsed.rx == 0x5656 && parsed.ry == 0x7878,
|
||||
"UART axes were parsed incorrectly");
|
||||
|
||||
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> current{};
|
||||
tud_hid_get_report_cb(0, 0, HID_REPORT_TYPE_INPUT, current.data(),
|
||||
current.size());
|
||||
SwitchProReport current_report{};
|
||||
std::memcpy(¤t_report, current.data(), sizeof(current_report));
|
||||
expect(current_report.inputs.buttonX && !current_report.inputs.buttonA,
|
||||
"UART parsing mutated driver context state");
|
||||
|
||||
SwitchInputState unchanged{};
|
||||
unchanged.button_home = true;
|
||||
unchanged.lx = 123;
|
||||
packet.back() ^= 0xffu;
|
||||
expect(!switch_pro_apply_uart_packet(packet.data(), packet.size(),
|
||||
unchanged),
|
||||
"invalid UART checksum was accepted");
|
||||
expect(unchanged.button_home && unchanged.lx == 123,
|
||||
"failed UART parse modified its output reference");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" absolute_time_t get_absolute_time(void) {
|
||||
return {now_ms};
|
||||
}
|
||||
extern "C" uint32_t to_ms_since_boot(absolute_time_t time) {
|
||||
return static_cast<uint32_t>(time.milliseconds);
|
||||
}
|
||||
extern "C" uint32_t get_rand_32(void) {
|
||||
return random_value++;
|
||||
}
|
||||
extern "C" bool tud_hid_n_ready(uint8_t instance) {
|
||||
return instance < SWITCH_PICO_HID_INSTANCE_COUNT && hid_ready[instance];
|
||||
}
|
||||
extern "C" bool tud_hid_n_report(uint8_t instance, uint8_t report_id,
|
||||
const void* report, uint16_t length) {
|
||||
if (instance >= SWITCH_PICO_HID_INSTANCE_COUNT || report == nullptr ||
|
||||
length > SWITCH_PRO_ENDPOINT_SIZE) {
|
||||
return false;
|
||||
}
|
||||
++hid_report_attempts[instance];
|
||||
if (!hid_report_succeeds[instance] ||
|
||||
sent_report_count >= sent_reports.size()) {
|
||||
return false;
|
||||
}
|
||||
SentReport& sent = sent_reports[sent_report_count++];
|
||||
sent.instance = instance;
|
||||
sent.report_id = report_id;
|
||||
sent.length = length;
|
||||
std::memcpy(sent.data.data(), report, length);
|
||||
return true;
|
||||
}
|
||||
extern "C" bool tud_suspended(void) {
|
||||
return false;
|
||||
}
|
||||
extern "C" bool tud_remote_wakeup(void) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_reset_materializes_neutral_sticks();
|
||||
test_startup_identify_preserves_first_reply_counter();
|
||||
test_failed_startup_identify_retries_preserve_counter();
|
||||
test_input_reports_and_timers_are_isolated();
|
||||
test_callback_send_and_imu_modes_are_isolated();
|
||||
test_rumble_callbacks_and_decoders_are_isolated();
|
||||
test_grip_colors_are_isolated();
|
||||
test_lifecycle_and_invalid_instances();
|
||||
test_uart_parser_is_pure();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " driver context test(s) failed\n";
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "bluepad32_backend_lifecycle_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-DSWITCH_PICO_HID_INSTANCE_COUNT=4",
|
||||
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
|
||||
f"-I{root}",
|
||||
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
for scenario in (
|
||||
"ready-forward",
|
||||
"ready-reverse",
|
||||
"rejections",
|
||||
"lifecycle",
|
||||
"pairing-policy",
|
||||
"slot-lighting",
|
||||
"abxy-hotkey",
|
||||
"motion-hotkey",
|
||||
"clear-pairings",
|
||||
"flash-core-start",
|
||||
"flash-core-failure",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_bluepad32_imu_normalization_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "bluepad32_imu_normalization_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
f"-I{root / 'external' / 'bluepad32' / 'src' / 'components' / 'bluepad32' / 'include'}",
|
||||
str(root / "tests" / "bluepad32_imu_normalization_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_bootsel_pairing_button_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
for platform, rp2350 in (("rp2350", 1), ("rp2040", 0)):
|
||||
executable = tmp_path / f"bootsel_pairing_button_test_{platform}"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-DPICO_RP2350={rp2350}",
|
||||
f"-I{root / 'tests' / 'bootsel_native_stubs'}",
|
||||
f"-I{root}",
|
||||
str(root / "bootsel_pairing_button.cpp"),
|
||||
str(root / "tests" / "bootsel_pairing_button_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
"""Regression tests for SDL sensor buffering in the UART bridge."""
|
||||
|
||||
from argparse import Namespace
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from typing import Protocol, cast
|
||||
|
||||
import sdl3
|
||||
from rich.console import Console
|
||||
|
||||
import switch_pico_bridge.controller_uart_bridge as bridge
|
||||
from switch_pico_bridge.switch_pico_uart import (
|
||||
IMUSample,
|
||||
PicoUART,
|
||||
SENSOR_ACCEL,
|
||||
SENSOR_GYRO,
|
||||
SwitchButton,
|
||||
SwitchReport,
|
||||
UART_BAUD,
|
||||
)
|
||||
|
||||
|
||||
class MonkeyPatch(Protocol):
|
||||
def setattr(self, target: object, name: str, value: object) -> None: ...
|
||||
|
||||
|
||||
class RecordingUART:
|
||||
def __init__(self) -> None:
|
||||
self.sent_imu: list[tuple[IMUSample, ...]] = []
|
||||
|
||||
def send_report(self, report: SwitchReport) -> None:
|
||||
self.sent_imu.append(tuple(report.imu_samples))
|
||||
|
||||
def read_rumble(self) -> tuple[float, float] | None:
|
||||
return None
|
||||
|
||||
|
||||
def make_config() -> bridge.BridgeConfig:
|
||||
return bridge.BridgeConfig(
|
||||
interval=0.002,
|
||||
deadzone_raw=0,
|
||||
trigger_threshold=0,
|
||||
zero_sticks=False,
|
||||
zero_hotkey="",
|
||||
swap_hotkey="",
|
||||
button_map_default={},
|
||||
button_map_swapped={},
|
||||
swap_abxy_indices=set(),
|
||||
swap_abxy_ids=set(),
|
||||
swap_abxy_global=False,
|
||||
)
|
||||
|
||||
|
||||
def test_sensor_buffer_retains_latest_three_samples() -> None:
|
||||
controller = cast(sdl3.SDL_Gamepad, object())
|
||||
ctx = bridge.ControllerContext(controller, 7, 0, "dualsense", None, None)
|
||||
ctx.sensors_enabled = True
|
||||
ctx.gyro_bias_locked = True
|
||||
contexts = {ctx.instance_id: ctx}
|
||||
config = make_config()
|
||||
|
||||
accel_event = cast(
|
||||
sdl3.SDL_Event,
|
||||
cast(
|
||||
object,
|
||||
SimpleNamespace(
|
||||
gsensor=SimpleNamespace(
|
||||
which=ctx.instance_id,
|
||||
sensor=SENSOR_ACCEL,
|
||||
data=(0.0, 9.80665, 0.0),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
bridge.handle_sensor_update(accel_event, contexts, config)
|
||||
|
||||
for gyro_x in (1.0, 2.0, 3.0, 4.0):
|
||||
gyro_event = cast(
|
||||
sdl3.SDL_Event,
|
||||
cast(
|
||||
object,
|
||||
SimpleNamespace(
|
||||
gsensor=SimpleNamespace(
|
||||
which=ctx.instance_id,
|
||||
sensor=SENSOR_GYRO,
|
||||
data=(gyro_x, 0.0, 0.0),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
bridge.handle_sensor_update(gyro_event, contexts, config)
|
||||
|
||||
assert len(ctx.imu_samples) == 3
|
||||
assert [sample.gyro_y for sample in ctx.imu_samples] == [
|
||||
bridge.convert_gyro_to_raw(-2.0),
|
||||
bridge.convert_gyro_to_raw(-3.0),
|
||||
bridge.convert_gyro_to_raw(-4.0),
|
||||
]
|
||||
|
||||
|
||||
def test_service_republishes_latest_imu_window(monkeypatch: MonkeyPatch) -> None:
|
||||
uart = RecordingUART()
|
||||
controller = cast(sdl3.SDL_Gamepad, object())
|
||||
ctx = bridge.ControllerContext(
|
||||
controller, 7, 0, "dualsense", "/dev/null", cast(PicoUART, cast(object, uart))
|
||||
)
|
||||
ctx.sensors_enabled = True
|
||||
samples = [
|
||||
IMUSample(1, 2, 3, 4, 5, 6),
|
||||
IMUSample(7, 8, 9, 10, 11, 12),
|
||||
IMUSample(13, 14, 15, 16, 17, 18),
|
||||
]
|
||||
ctx.imu_samples = samples.copy()
|
||||
contexts = {ctx.instance_id: ctx}
|
||||
config = make_config()
|
||||
|
||||
def ignore_poll(
|
||||
_ctx: bridge.ControllerContext, _button_map: dict[int, SwitchButton]
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(bridge, "poll_controller_buttons", ignore_poll)
|
||||
|
||||
args = Namespace(baud=UART_BAUD)
|
||||
console = Console(file=StringIO())
|
||||
bridge.service_contexts(1.0, args, config, contexts, [], console)
|
||||
bridge.service_contexts(2.0, args, config, contexts, [], console)
|
||||
|
||||
assert uart.sent_imu == [tuple(samples), tuple(samples)]
|
||||
assert ctx.imu_samples == samples
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
import switch_pico_bridge.pairing_manager as pairing_manager
|
||||
|
||||
|
||||
def make_payload(
|
||||
generation: int,
|
||||
records: list[tuple[int, int, bytes]],
|
||||
*,
|
||||
status: int = pairing_manager.STATUS_READY,
|
||||
overflow: bool = False,
|
||||
) -> bytes:
|
||||
payload = bytearray(b"SPPM")
|
||||
payload.extend(
|
||||
[
|
||||
pairing_manager.PROTOCOL_VERSION,
|
||||
status,
|
||||
len(records),
|
||||
int(overflow),
|
||||
]
|
||||
)
|
||||
payload.extend(struct.pack("<I", generation))
|
||||
for transport, address_type, address in records:
|
||||
payload.extend([transport, address_type])
|
||||
payload.extend(address)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
bus = 1
|
||||
address = 7
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.generation = 3
|
||||
self.records = [
|
||||
(
|
||||
pairing_manager.TRANSPORT_CLASSIC,
|
||||
0xFE,
|
||||
bytes.fromhex("010203040506"),
|
||||
),
|
||||
(
|
||||
pairing_manager.TRANSPORT_BLE,
|
||||
2,
|
||||
bytes.fromhex("A1A2A3A4A5A6"),
|
||||
),
|
||||
]
|
||||
self.requests: list[int] = []
|
||||
|
||||
def ctrl_transfer(
|
||||
self,
|
||||
bm_request_type: int,
|
||||
request: int,
|
||||
value: int,
|
||||
index: int,
|
||||
data_or_w_length: object,
|
||||
timeout: int,
|
||||
) -> bytes | int:
|
||||
assert value == pairing_manager.REQUEST_VALUE
|
||||
assert index == pairing_manager.REQUEST_INDEX
|
||||
assert timeout == pairing_manager.USB_TIMEOUT_MS
|
||||
self.requests.append(request)
|
||||
if bm_request_type == 0xC0:
|
||||
assert request == pairing_manager.REQUEST_GET
|
||||
return make_payload(self.generation, self.records)
|
||||
assert bm_request_type == 0x40
|
||||
if request == pairing_manager.REQUEST_REFRESH:
|
||||
self.generation += 1
|
||||
elif request == pairing_manager.REQUEST_CLEAR:
|
||||
self.records = []
|
||||
self.generation += 1
|
||||
else:
|
||||
raise AssertionError(f"unexpected request {request}")
|
||||
return 0
|
||||
|
||||
|
||||
def test_parse_snapshot() -> None:
|
||||
snapshot = pairing_manager.parse_snapshot(
|
||||
make_payload(
|
||||
0x78563412,
|
||||
[
|
||||
(
|
||||
pairing_manager.TRANSPORT_CLASSIC,
|
||||
0xFE,
|
||||
bytes.fromhex("010203040506"),
|
||||
),
|
||||
(
|
||||
pairing_manager.TRANSPORT_BLE,
|
||||
3,
|
||||
bytes.fromhex("A1A2A3A4A5A6"),
|
||||
),
|
||||
],
|
||||
overflow=True,
|
||||
)
|
||||
)
|
||||
assert snapshot.generation == 0x78563412
|
||||
assert snapshot.overflow
|
||||
assert snapshot.records[0].transport_text == "Classic"
|
||||
assert snapshot.records[0].address_text == "01:02:03:04:05:06"
|
||||
assert snapshot.records[1].transport_text == "BLE (random identity)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"",
|
||||
b"NOPE" + bytes(8),
|
||||
b"SPPM\x02" + bytes(7),
|
||||
b"SPPM\x01\x00\x11\x00" + bytes(4),
|
||||
],
|
||||
)
|
||||
def test_parse_rejects_invalid_payload(payload: bytes) -> None:
|
||||
with pytest.raises(pairing_manager.PairingManagerError):
|
||||
pairing_manager.parse_snapshot(payload)
|
||||
|
||||
|
||||
def test_list_and_clear_commands(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
monkeypatch.setattr(pairing_manager, "_candidate_devices", lambda: [device])
|
||||
|
||||
assert pairing_manager.main(["list"]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "Classic 01:02:03:04:05:06" in output
|
||||
assert "BLE (public identity) A1:A2:A3:A4:A5:A6" in output
|
||||
|
||||
assert pairing_manager.main(["clear"]) == 2
|
||||
assert "requires --yes" in capsys.readouterr().err
|
||||
|
||||
assert pairing_manager.main(["clear", "--yes"]) == 0
|
||||
assert capsys.readouterr().out == "Cleared 2 stored pairing(s).\n"
|
||||
assert device.records == []
|
||||
assert pairing_manager.REQUEST_REFRESH in device.requests
|
||||
assert pairing_manager.REQUEST_CLEAR in device.requests
|
||||
|
||||
|
||||
def test_find_requires_selector_for_multiple_picos(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = FakeDevice()
|
||||
second = FakeDevice()
|
||||
second.address = 8
|
||||
monkeypatch.setattr(
|
||||
pairing_manager, "_candidate_devices", lambda: [first, second]
|
||||
)
|
||||
with pytest.raises(
|
||||
pairing_manager.PairingManagerError,
|
||||
match="multiple switch-pico devices",
|
||||
):
|
||||
pairing_manager.find_pico(None, None)
|
||||
assert pairing_manager.find_pico(1, 8) is second
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
"""
|
||||
Tests for prepare_bluepad32.py patch preparation tool.
|
||||
|
||||
Tests cover:
|
||||
- Fresh patch application
|
||||
- Idempotence (second invocation succeeds without changing content)
|
||||
- Missing paths validation
|
||||
- Diverged/ambiguous repository states
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "tools"))
|
||||
|
||||
from prepare_bluepad32 import (
|
||||
PatchError,
|
||||
resolve_paths,
|
||||
check_paths,
|
||||
is_patch_applied,
|
||||
apply_patch,
|
||||
prepare_bluepad32,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_repo_structure():
|
||||
"""Create a temporary directory structure with git repositories."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
|
||||
# Create bluepad32 repo
|
||||
bp_dir = root / "external" / "bluepad32"
|
||||
bp_dir.mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Create a file to patch
|
||||
test_file = bp_dir / "test.txt"
|
||||
test_file.write_text("line 1\n")
|
||||
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "initial"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Create patches dir
|
||||
patches_dir = root / "patches"
|
||||
patches_dir.mkdir()
|
||||
|
||||
yield root, bp_dir, patches_dir
|
||||
|
||||
|
||||
def create_simple_patch(repo_path: Path, patch_path: Path, file_to_patch: str = "test.txt") -> str:
|
||||
"""
|
||||
Create a simple patch file that modifies a file in the repository.
|
||||
|
||||
Returns the patch content as a string.
|
||||
"""
|
||||
# Create the modification
|
||||
test_file = repo_path / file_to_patch
|
||||
original_content = test_file.read_text()
|
||||
modified_content = original_content + "line 2\n"
|
||||
|
||||
# Generate patch using git diff
|
||||
test_file.write_text(modified_content)
|
||||
result = subprocess.run(
|
||||
["git", "diff", file_to_patch],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
patch_content = result.stdout
|
||||
|
||||
# Reset the file to original state
|
||||
test_file.write_text(original_content)
|
||||
|
||||
# Write patch to file
|
||||
patch_path.write_text(patch_content)
|
||||
return patch_content
|
||||
|
||||
|
||||
def test_resolve_paths_with_defaults():
|
||||
"""Test that resolve_paths returns expected default paths."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bp_path, patch_path = resolve_paths(Path(tmpdir))
|
||||
assert bp_path == Path(tmpdir) / "external" / "bluepad32"
|
||||
assert patch_path == Path(tmpdir) / "patches" / "bluepad32-sdl3-imu.patch"
|
||||
|
||||
|
||||
def test_resolve_paths_no_root():
|
||||
"""Test resolve_paths with no root uses current directory."""
|
||||
bp_path, patch_path = resolve_paths()
|
||||
assert bp_path.is_absolute()
|
||||
assert patch_path.is_absolute()
|
||||
|
||||
|
||||
def test_check_paths_missing_bluepad32(temp_repo_structure):
|
||||
"""Test that check_paths fails if bluepad32 dir is missing."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
# Remove bluepad32
|
||||
import shutil
|
||||
shutil.rmtree(bp_dir)
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
patch_file.write_text("dummy")
|
||||
|
||||
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
|
||||
check_paths(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_check_paths_missing_patch(temp_repo_structure):
|
||||
"""Test that check_paths fails if patch file is missing."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "nonexistent.patch"
|
||||
|
||||
with pytest.raises(PatchError, match="patch file does not exist"):
|
||||
check_paths(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_check_paths_bluepad32_not_git_repo(temp_repo_structure):
|
||||
"""Test that check_paths fails if bluepad32 is not a git repo."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
# Remove .git to make it not a git repo
|
||||
import shutil
|
||||
shutil.rmtree(bp_dir / ".git")
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
patch_file.write_text("dummy")
|
||||
|
||||
with pytest.raises(PatchError, match="not a git repository"):
|
||||
check_paths(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_fresh_patch_application(temp_repo_structure):
|
||||
"""Test applying a fresh patch to a clean repository."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Verify test.txt before patch
|
||||
test_file = bp_dir / "test.txt"
|
||||
original = test_file.read_text()
|
||||
assert "line 2" not in original
|
||||
|
||||
# Apply patch
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
# Verify test.txt after patch
|
||||
patched = test_file.read_text()
|
||||
assert "line 2" in patched
|
||||
|
||||
|
||||
def test_idempotent_patch_application(temp_repo_structure):
|
||||
"""Test that applying the same patch twice succeeds (idempotence)."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# First application
|
||||
apply_patch(bp_dir, patch_file)
|
||||
test_file = bp_dir / "test.txt"
|
||||
after_first = test_file.read_text()
|
||||
|
||||
# Second application should succeed without changing content
|
||||
apply_patch(bp_dir, patch_file)
|
||||
after_second = test_file.read_text()
|
||||
|
||||
assert after_first == after_second
|
||||
|
||||
|
||||
def test_is_patch_applied_not_applied(temp_repo_structure):
|
||||
"""Test is_patch_applied returns False for unapplied patch."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Patch not applied yet
|
||||
assert is_patch_applied(bp_dir, patch_file) is False
|
||||
|
||||
|
||||
def test_is_patch_applied_already_applied(temp_repo_structure):
|
||||
"""Test is_patch_applied returns True for already applied patch."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Apply patch first
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
# Now check should detect it's applied
|
||||
assert is_patch_applied(bp_dir, patch_file) is True
|
||||
|
||||
|
||||
def test_diverged_repository_state(temp_repo_structure):
|
||||
"""Test that diverged repository (patch doesn't apply cleanly) is rejected."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Diverge the repository by modifying the file such that the patch conflicts
|
||||
test_file = bp_dir / "test.txt"
|
||||
test_file.write_text("completely different line 1\n")
|
||||
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "divergence"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Try to apply patch - should fail because file content doesn't match
|
||||
with pytest.raises(PatchError, match="Patch validation failed"):
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_missing_bluepad32_path(temp_repo_structure):
|
||||
"""Test prepare_bluepad32 fails gracefully with missing bluepad32."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(bp_dir)
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
patch_file.write_text("dummy")
|
||||
|
||||
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_missing_patch_file(temp_repo_structure):
|
||||
"""Test prepare_bluepad32 fails gracefully with missing patch file."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "nonexistent.patch"
|
||||
|
||||
with pytest.raises(PatchError, match="patch file does not exist"):
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_prepare_bluepad32_full_workflow(temp_repo_structure):
|
||||
"""Test complete prepare_bluepad32 workflow: apply then idempotent re-apply."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
test_file = bp_dir / "test.txt"
|
||||
original = test_file.read_text()
|
||||
|
||||
# First prepare (should apply patch)
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
after_first = test_file.read_text()
|
||||
assert after_first != original
|
||||
assert "line 2" in after_first
|
||||
|
||||
# Second prepare (should be idempotent)
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
after_second = test_file.read_text()
|
||||
assert after_first == after_second
|
||||
|
||||
|
||||
def test_prepare_bluepad32_with_defaults(temp_repo_structure):
|
||||
"""Test prepare_bluepad32 uses correct defaults when paths not provided."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "bluepad32-sdl3-imu.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Change to root directory and call with defaults
|
||||
import os
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(root)
|
||||
prepare_bluepad32() # Use defaults
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
# Verify patch was applied
|
||||
test_file = bp_dir / "test.txt"
|
||||
assert "line 2" in test_file.read_text()
|
||||
|
||||
|
||||
def test_patch_application_with_conflicting_content(temp_repo_structure):
|
||||
"""Test that patch with conflicting content is rejected."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
# Create a patch that adds a specific change
|
||||
patch_content = """--- a/test.txt
|
||||
+++ b/test.txt
|
||||
@@ -1 +1,3 @@
|
||||
line 1
|
||||
+line 2
|
||||
+line 3
|
||||
"""
|
||||
|
||||
patch_file = patches_dir / "conflict.patch"
|
||||
patch_file.write_text(patch_content)
|
||||
|
||||
# Modify the file to have different content that won't match the patch context
|
||||
test_file = bp_dir / "test.txt"
|
||||
test_file.write_text("modified line 1\n")
|
||||
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "modify"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Try to apply patch - should fail due to context mismatch
|
||||
with pytest.raises(PatchError):
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_cli_with_explicit_paths(temp_repo_structure):
|
||||
"""Test CLI argument parsing with explicit paths."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Simulate CLI call
|
||||
sys.argv = [
|
||||
"prepare_bluepad32.py",
|
||||
"--bluepad32", str(bp_dir),
|
||||
"--patch", str(patch_file),
|
||||
]
|
||||
|
||||
from prepare_bluepad32 import main
|
||||
|
||||
# Should not raise
|
||||
try:
|
||||
main()
|
||||
except SystemExit as e:
|
||||
# main() calls sys.exit on success, which we need to catch
|
||||
if e.code != 0:
|
||||
raise
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch_haptics_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "switch_haptics_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root}",
|
||||
str(root / "switch_haptics.cpp"),
|
||||
str(root / "tests" / "switch_haptics_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def compile_descriptor_test(
|
||||
root: Path,
|
||||
compiler: str,
|
||||
output: Path,
|
||||
expected_count: int,
|
||||
configured_count: int | None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = [
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-DEXPECTED_HID_INSTANCE_COUNT={expected_count}",
|
||||
]
|
||||
if configured_count is not None:
|
||||
command.append(f"-DSWITCH_PICO_HID_INSTANCE_COUNT={configured_count}")
|
||||
command.extend(
|
||||
[
|
||||
f"-I{root}",
|
||||
str(root / "tests" / "switch_pro_descriptors_test.cpp"),
|
||||
"-o",
|
||||
str(output),
|
||||
]
|
||||
)
|
||||
return subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
cwd=root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def host_compiler() -> str:
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
return compiler
|
||||
|
||||
|
||||
def test_default_descriptor_contract_is_single_hid(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
executable = tmp_path / "switch_pro_descriptors_default_test"
|
||||
result = compile_descriptor_test(root, host_compiler(), executable, 1, None)
|
||||
assert result.returncode == 0, result.stderr
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
||||
|
||||
def test_supported_descriptor_contracts(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = host_compiler()
|
||||
for instance_count in range(1, 5):
|
||||
executable = (
|
||||
tmp_path / f"switch_pro_descriptors_{instance_count}_test"
|
||||
)
|
||||
result = compile_descriptor_test(
|
||||
root,
|
||||
compiler,
|
||||
executable,
|
||||
instance_count,
|
||||
instance_count,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
||||
|
||||
def test_unsupported_hid_instance_counts_fail_to_compile(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = host_compiler()
|
||||
for unsupported_count in (0, 5):
|
||||
executable = tmp_path / f"switch_pro_descriptors_invalid_{unsupported_count}"
|
||||
result = compile_descriptor_test(
|
||||
root,
|
||||
compiler,
|
||||
executable,
|
||||
unsupported_count,
|
||||
unsupported_count,
|
||||
)
|
||||
assert result.returncode != 0, (
|
||||
f"unsupported HID instance count {unsupported_count} compiled successfully"
|
||||
)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch_pro_driver_four_contexts_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "switch_pro_driver_context_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-DSWITCH_PICO_HID_INSTANCE_COUNT=4",
|
||||
f"-I{root / 'tests' / 'native_stubs'}",
|
||||
f"-I{root}",
|
||||
str(root / "switch_pro_driver.cpp"),
|
||||
str(root / "switch_haptics.cpp"),
|
||||
str(root / "tests" / "switch_pro_driver_context_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -6,11 +6,8 @@ from switch_pico_bridge.switch_pico_uart import (
|
|||
SwitchReport,
|
||||
IMUSample,
|
||||
SwitchDpad,
|
||||
PicoUART,
|
||||
UART_HEADER,
|
||||
UART_PROTOCOL_VERSION,
|
||||
RUMBLE_HEADER,
|
||||
RUMBLE_TYPE_DECODED,
|
||||
ACCEL_LSB_PER_G,
|
||||
GYRO_LSB_PER_RAD_S,
|
||||
MS2_PER_G,
|
||||
|
|
@ -18,36 +15,6 @@ from switch_pico_bridge.switch_pico_uart import (
|
|||
)
|
||||
|
||||
|
||||
class BufferedSerial:
|
||||
def __init__(self, data: bytes = b""):
|
||||
self._data = bytearray(data)
|
||||
|
||||
@property
|
||||
def in_waiting(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
data = bytes(self._data[:size])
|
||||
del self._data[:size]
|
||||
return data
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
self._data.extend(data)
|
||||
|
||||
|
||||
def make_rumble_frame(low: int, high: int) -> bytes:
|
||||
frame = bytes([RUMBLE_HEADER, RUMBLE_TYPE_DECODED, low, high])
|
||||
return frame + bytes([compute_checksum(frame)])
|
||||
|
||||
|
||||
def make_uart(data: bytes = b"") -> tuple[PicoUART, BufferedSerial]:
|
||||
uart = object.__new__(PicoUART)
|
||||
serial_port = BufferedSerial(data)
|
||||
uart.serial = serial_port
|
||||
uart._buffer = bytearray()
|
||||
return uart, serial_port
|
||||
|
||||
|
||||
def test_v2_frame_with_imu_samples():
|
||||
"""V2 frame with 3 IMU samples should be 48 bytes with correct layout."""
|
||||
r = SwitchReport(
|
||||
|
|
@ -151,34 +118,3 @@ def test_max_imu_samples_capped():
|
|||
assert len(data) == 48 # 3 samples, not 5
|
||||
assert data[10] == 3
|
||||
assert data[2] == 44 # payload_len for 3 samples
|
||||
|
||||
|
||||
def test_decoded_rumble_frame_survives_fragmented_input():
|
||||
frame = make_rumble_frame(64, 192)
|
||||
uart, serial_port = make_uart(frame[:3])
|
||||
|
||||
assert uart.read_rumble() is None
|
||||
|
||||
serial_port.feed(frame[3:])
|
||||
assert uart.read_rumble() == pytest.approx((64 / 255.0, 192 / 255.0))
|
||||
|
||||
|
||||
def test_decoded_rumble_frame_resynchronizes_after_garbage():
|
||||
uart, _ = make_uart(b"\x00\xffnot-a-frame" + make_rumble_frame(12, 34))
|
||||
|
||||
assert uart.read_rumble() == pytest.approx((12 / 255.0, 34 / 255.0))
|
||||
|
||||
|
||||
def test_decoded_rumble_frame_rejects_bad_checksum():
|
||||
corrupted = bytearray(make_rumble_frame(25, 50))
|
||||
corrupted[-1] ^= 0x01
|
||||
uart, _ = make_uart(bytes(corrupted) + make_rumble_frame(75, 100))
|
||||
|
||||
assert uart.read_rumble() == pytest.approx((75 / 255.0, 100 / 255.0))
|
||||
|
||||
|
||||
def test_decoded_rumble_zero_and_full_magnitudes():
|
||||
uart, _ = make_uart(make_rumble_frame(0, 0) + make_rumble_frame(255, 255))
|
||||
|
||||
assert uart.read_rumble() == (0.0, 0.0)
|
||||
assert uart.read_rumble() == (1.0, 1.0)
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
"""Focused tests for decoded UART rumble delivery to SDL3."""
|
||||
|
||||
from argparse import Namespace
|
||||
from io import StringIO
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
import sdl3
|
||||
from rich.console import Console
|
||||
|
||||
import switch_pico_bridge.controller_uart_bridge as bridge
|
||||
from switch_pico_bridge.switch_pico_uart import PicoUART, SwitchReport, UART_BAUD
|
||||
|
||||
|
||||
class RecordingUART:
|
||||
def __init__(self) -> None:
|
||||
self.rumble: list[tuple[float, float]] = []
|
||||
|
||||
def send_report(self, _report: SwitchReport) -> None:
|
||||
pass
|
||||
|
||||
def read_rumble(self) -> tuple[float, float] | None:
|
||||
if not self.rumble:
|
||||
return None
|
||||
return self.rumble.pop(0)
|
||||
|
||||
|
||||
def make_config() -> bridge.BridgeConfig:
|
||||
return bridge.BridgeConfig(
|
||||
interval=10.0,
|
||||
deadzone_raw=0,
|
||||
trigger_threshold=0,
|
||||
zero_sticks=False,
|
||||
zero_hotkey="",
|
||||
swap_hotkey="",
|
||||
button_map_default={},
|
||||
button_map_swapped={},
|
||||
swap_abxy_indices=set(),
|
||||
swap_abxy_ids=set(),
|
||||
swap_abxy_global=False,
|
||||
no_imu=True,
|
||||
)
|
||||
|
||||
|
||||
def test_apply_rumble_maps_low_and_high_with_50ms_duration(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[int, int, int]] = []
|
||||
monkeypatch.setattr(
|
||||
bridge.sdl3,
|
||||
"SDL_RumbleGamepad",
|
||||
lambda _controller, low, high, duration: calls.append((low, high, duration)),
|
||||
)
|
||||
controller = cast(sdl3.SDL_Gamepad, object())
|
||||
|
||||
assert bridge.apply_rumble(controller, 1.0, 0.5)
|
||||
assert calls[-1] == (0xFFFF, 0x7FFF, 50)
|
||||
|
||||
assert not bridge.apply_rumble(controller, 0.0, 0.0)
|
||||
assert calls[-1] == (0, 0, 50)
|
||||
|
||||
|
||||
def test_repeated_constant_rumble_stays_active_until_idle_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[int, int, int]] = []
|
||||
monkeypatch.setattr(
|
||||
bridge.sdl3,
|
||||
"SDL_RumbleGamepad",
|
||||
lambda _controller, low, high, duration: calls.append((low, high, duration)),
|
||||
)
|
||||
monkeypatch.setattr(bridge, "poll_controller_buttons", lambda _ctx, _map: None)
|
||||
|
||||
uart = RecordingUART()
|
||||
controller = cast(sdl3.SDL_Gamepad, object())
|
||||
ctx = bridge.ControllerContext(
|
||||
controller,
|
||||
7,
|
||||
0,
|
||||
"controller",
|
||||
"/dev/null",
|
||||
cast(PicoUART, cast(object, uart)),
|
||||
)
|
||||
contexts = {ctx.instance_id: ctx}
|
||||
args = Namespace(baud=UART_BAUD)
|
||||
console = Console(file=StringIO())
|
||||
|
||||
magnitude = (64 / 255.0, 192 / 255.0)
|
||||
uart.rumble.append(magnitude)
|
||||
bridge.service_contexts(1.0, args, make_config(), contexts, [], console)
|
||||
uart.rumble.append(magnitude)
|
||||
bridge.service_contexts(1.7, args, make_config(), contexts, [], console)
|
||||
bridge.service_contexts(1.71, args, make_config(), contexts, [], console)
|
||||
|
||||
assert calls == [(16448, 49344, 50), (16448, 49344, 50)]
|
||||
assert ctx.rumble_active
|
||||
|
||||
bridge.service_contexts(1.96, args, make_config(), contexts, [], console)
|
||||
|
||||
assert calls[-1] == (0, 0, 0)
|
||||
assert not ctx.rumble_active
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
def test_usb_pairing_management_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "usb_pairing_management_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'tests' / 'usb_management_native_stubs'}",
|
||||
f"-I{root}",
|
||||
str(root / "tests" / "usb_pairing_management_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
enum {
|
||||
CONTROL_STAGE_SETUP = 0,
|
||||
CONTROL_STAGE_DATA = 1,
|
||||
CONTROL_STAGE_ACK = 2,
|
||||
TUSB_REQ_RCPT_DEVICE = 0,
|
||||
TUSB_DIR_OUT = 0,
|
||||
TUSB_DIR_IN = 1,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint8_t recipient;
|
||||
uint8_t type;
|
||||
uint8_t direction;
|
||||
} tusb_request_type_bits_t;
|
||||
|
||||
typedef struct {
|
||||
tusb_request_type_bits_t bmRequestType_bit;
|
||||
uint8_t bRequest;
|
||||
uint16_t wValue;
|
||||
uint16_t wIndex;
|
||||
uint16_t wLength;
|
||||
} tusb_control_request_t;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
bool tud_control_xfer(uint8_t rhport,
|
||||
const tusb_control_request_t* request,
|
||||
void* buffer, uint16_t length);
|
||||
bool tud_control_status(uint8_t rhport,
|
||||
const tusb_control_request_t* request);
|
||||
bool tud_vendor_control_xfer_cb(
|
||||
uint8_t rhport, uint8_t stage,
|
||||
const tusb_control_request_t* request);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
#include "usb_pairing_management.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <tusb.h>
|
||||
|
||||
namespace {
|
||||
|
||||
Bluepad32PairingSnapshot current_snapshot{};
|
||||
bool refresh_requested = false;
|
||||
bool clear_requested = false;
|
||||
bool control_status_sent = false;
|
||||
std::vector<uint8_t> control_payload;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void test_encoding() {
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
snapshot.generation = 0x78563412;
|
||||
snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
snapshot.record_count = 2;
|
||||
snapshot.overflow = true;
|
||||
snapshot.records[0].transport =
|
||||
Bluepad32PairingTransport::kClassic;
|
||||
snapshot.records[0].address_type = 0xfe;
|
||||
const uint8_t classic_address[6] = {1, 2, 3, 4, 5, 6};
|
||||
memcpy(snapshot.records[0].address, classic_address, 6);
|
||||
snapshot.records[1].transport = Bluepad32PairingTransport::kBle;
|
||||
snapshot.records[1].address_type = 2;
|
||||
const uint8_t ble_address[6] = {6, 5, 4, 3, 2, 1};
|
||||
memcpy(snapshot.records[1].address, ble_address, 6);
|
||||
|
||||
uint8_t payload[UsbPairingManagement::kMaximumResponseSize]{};
|
||||
const size_t size = UsbPairingManagement::encode_snapshot(
|
||||
snapshot, payload, sizeof(payload));
|
||||
require(size == UsbPairingManagement::kResponseHeaderSize +
|
||||
2 * UsbPairingManagement::kRecordSize,
|
||||
"snapshot encoded with the wrong size");
|
||||
require(memcmp(payload, "SPPM", 4) == 0 &&
|
||||
payload[4] == UsbPairingManagement::kProtocolVersion &&
|
||||
payload[5] == 0 && payload[6] == 2 && payload[7] == 1,
|
||||
"snapshot header encoding is invalid");
|
||||
require(payload[8] == 0x12 && payload[9] == 0x34 &&
|
||||
payload[10] == 0x56 && payload[11] == 0x78,
|
||||
"snapshot generation is not little endian");
|
||||
require(payload[12] == 1 && payload[13] == 0xfe &&
|
||||
memcmp(&payload[14], classic_address, 6) == 0 &&
|
||||
payload[20] == 2 && payload[21] == 2 &&
|
||||
memcmp(&payload[22], ble_address, 6) == 0,
|
||||
"pairing records are encoded incorrectly");
|
||||
require(UsbPairingManagement::encode_snapshot(
|
||||
snapshot, payload, size - 1) == 0,
|
||||
"encoder accepted a short destination buffer");
|
||||
}
|
||||
|
||||
void test_vendor_requests() {
|
||||
current_snapshot = {};
|
||||
current_snapshot.generation = 7;
|
||||
current_snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
current_snapshot.record_count = 1;
|
||||
current_snapshot.records[0].transport =
|
||||
Bluepad32PairingTransport::kClassic;
|
||||
|
||||
tusb_control_request_t request{};
|
||||
request.bmRequestType_bit.recipient = TUSB_REQ_RCPT_DEVICE;
|
||||
request.bmRequestType_bit.direction = TUSB_DIR_IN;
|
||||
request.bRequest = UsbPairingManagement::kRequestGet;
|
||||
request.wValue = UsbPairingManagement::kRequestValue;
|
||||
request.wIndex = UsbPairingManagement::kRequestIndex;
|
||||
request.wLength = UsbPairingManagement::kMaximumResponseSize;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
control_payload.size() ==
|
||||
UsbPairingManagement::kResponseHeaderSize +
|
||||
UsbPairingManagement::kRecordSize &&
|
||||
control_payload[8] == 7,
|
||||
"GET request did not return the current pairing snapshot");
|
||||
|
||||
request.bmRequestType_bit.direction = TUSB_DIR_OUT;
|
||||
request.wLength = 0;
|
||||
request.bRequest = UsbPairingManagement::kRequestRefresh;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
refresh_requested && control_status_sent,
|
||||
"REFRESH request was not acknowledged and queued");
|
||||
|
||||
control_status_sent = false;
|
||||
request.bRequest = UsbPairingManagement::kRequestClear;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
clear_requested && control_status_sent,
|
||||
"CLEAR request was not acknowledged and queued");
|
||||
|
||||
request.wValue = 0;
|
||||
require(!tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"request with invalid magic was accepted");
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_ACK, &request),
|
||||
"non-setup control stage was rejected");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void bluepad32_input_backend_request_pairing_snapshot() {
|
||||
refresh_requested = true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_clear_pairings() {
|
||||
clear_requested = true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out) {
|
||||
*out = current_snapshot;
|
||||
}
|
||||
|
||||
bool tud_control_xfer(uint8_t, const tusb_control_request_t*,
|
||||
void* buffer, uint16_t length) {
|
||||
const auto* bytes = static_cast<const uint8_t*>(buffer);
|
||||
control_payload.assign(bytes, bytes + length);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tud_control_status(uint8_t, const tusb_control_request_t*) {
|
||||
control_status_sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
#include "../usb_pairing_management.cpp"
|
||||
|
||||
int main() {
|
||||
test_encoding();
|
||||
test_vendor_requests();
|
||||
return 0;
|
||||
}
|
||||
205
tools/debug_imu_raw.py
Executable file
205
tools/debug_imu_raw.py
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Raw SDL3 IMU diagnostic tool.
|
||||
|
||||
Prints every gyro/accel sensor event directly from SDL3, bypassing all
|
||||
bridge logic. Use this to confirm SDL3 is delivering sensor events before
|
||||
debugging conversion or axis mapping issues.
|
||||
|
||||
Usage:
|
||||
uv run python tools/debug_imu_raw.py
|
||||
uv run python tools/debug_imu_raw.py --count 500 # stop after N gyro events
|
||||
uv run python tools/debug_imu_raw.py --no-bias # skip bias calibration window
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
|
||||
import sdl3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
SDL_SENSOR_ACCEL = 1
|
||||
SDL_SENSOR_GYRO = 2
|
||||
GRAVITY = 9.80665 # m/s²
|
||||
LSB_PER_G = 4096.0 # Nintendo accel scale
|
||||
LSB_PER_RAD_S = 818.5 # Nintendo gyro scale
|
||||
BIAS_SAMPLES = 200 # ~1 second at 200 Hz
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Raw SDL3 IMU diagnostic")
|
||||
parser.add_argument("--count", type=int, default=0,
|
||||
help="Stop after this many gyro events (0 = run forever)")
|
||||
parser.add_argument("--no-bias", action="store_true",
|
||||
help="Skip bias calibration window, print raw values immediately")
|
||||
parser.add_argument("--raw", action="store_true",
|
||||
help="Also print converted Nintendo-native raw counts")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Init SDL3 with gamepad + sensor support
|
||||
if not sdl3.SDL_Init(sdl3.SDL_INIT_GAMEPAD | sdl3.SDL_INIT_EVENTS):
|
||||
print(f"SDL_Init failed: {sdl3.SDL_GetError().decode()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sdl3.SDL_SetGamepadEventsEnabled(True)
|
||||
|
||||
# Find first gamepad
|
||||
count = ctypes.c_int(0)
|
||||
ids = sdl3.SDL_GetJoysticks(ctypes.byref(count))
|
||||
if not ids or count.value == 0:
|
||||
print("No joysticks/gamepads found.", file=sys.stderr)
|
||||
sdl3.SDL_Quit()
|
||||
sys.exit(1)
|
||||
|
||||
gamepad = None
|
||||
instance_id = None
|
||||
for i in range(count.value):
|
||||
if sdl3.SDL_IsGamepad(ids[i]):
|
||||
gamepad = sdl3.SDL_OpenGamepad(ids[i])
|
||||
instance_id = ids[i]
|
||||
break
|
||||
sdl3.SDL_free(ids)
|
||||
|
||||
if not gamepad:
|
||||
print("No gamepad found (only non-gamepad joysticks detected).", file=sys.stderr)
|
||||
sdl3.SDL_Quit()
|
||||
sys.exit(1)
|
||||
|
||||
name = sdl3.SDL_GetGamepadName(gamepad)
|
||||
print(f"Gamepad: {name.decode() if name else 'unknown'} (instance_id={instance_id})")
|
||||
|
||||
# Check sensor support
|
||||
has_accel = bool(sdl3.SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL))
|
||||
has_gyro = bool(sdl3.SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO))
|
||||
print(f" Accelerometer supported: {has_accel}")
|
||||
print(f" Gyroscope supported: {has_gyro}")
|
||||
|
||||
if not (has_accel and has_gyro):
|
||||
print("\nThis controller does not expose IMU sensors to SDL3.")
|
||||
print("Possible reasons:")
|
||||
print(" - Controller doesn't have IMU (Xbox, generic gamepads)")
|
||||
print(" - Missing kernel driver (Linux: hid-nintendo not loaded)")
|
||||
print(" - SDL3 HIDAPI disabled for this controller")
|
||||
sdl3.SDL_CloseGamepad(gamepad)
|
||||
sdl3.SDL_Quit()
|
||||
sys.exit(1)
|
||||
|
||||
# Enable sensors
|
||||
ok_accel = bool(sdl3.SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, True))
|
||||
ok_gyro = bool(sdl3.SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, True))
|
||||
print(f" Accelerometer enabled: {ok_accel}")
|
||||
print(f" Gyroscope enabled: {ok_gyro}")
|
||||
|
||||
if not (ok_accel and ok_gyro):
|
||||
print(f"\nFailed to enable sensors: {sdl3.SDL_GetError().decode()}")
|
||||
sdl3.SDL_CloseGamepad(gamepad)
|
||||
sdl3.SDL_Quit()
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
if args.no_bias:
|
||||
print("Skipping bias calibration. Showing raw values immediately.")
|
||||
else:
|
||||
print(f"Hold controller STILL — collecting {BIAS_SAMPLES} gyro samples for bias calibration...")
|
||||
print("Press Ctrl+C to stop.\n")
|
||||
print(f"{'EVENT':<8} {'AX':>8} {'AY':>8} {'AZ':>8} {'GX':>8} {'GY':>8} {'GZ':>8} {'STATUS'}")
|
||||
print("-" * 80)
|
||||
|
||||
# State
|
||||
last_accel = (0.0, 0.0, 0.0)
|
||||
bias = [0.0, 0.0, 0.0]
|
||||
bias_count = 0
|
||||
bias_locked = args.no_bias
|
||||
gyro_events = 0
|
||||
last_print = time.monotonic()
|
||||
event = sdl3.SDL_Event()
|
||||
|
||||
try:
|
||||
while True:
|
||||
while sdl3.SDL_PollEvent(ctypes.byref(event)):
|
||||
t = event.type
|
||||
|
||||
if t == sdl3.SDL_EVENT_GAMEPAD_SENSOR_UPDATE:
|
||||
gs = event.gsensor
|
||||
# Only handle events from our gamepad
|
||||
if gs.which != instance_id:
|
||||
continue
|
||||
|
||||
sensor_type = gs.sensor
|
||||
d = gs.data # c_float_Array_3
|
||||
|
||||
if sensor_type == SDL_SENSOR_ACCEL:
|
||||
last_accel = (float(d[0]), float(d[1]), float(d[2]))
|
||||
continue
|
||||
|
||||
if sensor_type != SDL_SENSOR_GYRO:
|
||||
continue
|
||||
|
||||
gx, gy, gz = float(d[0]), float(d[1]), float(d[2])
|
||||
|
||||
# Bias accumulation
|
||||
if not bias_locked:
|
||||
if bias_count < BIAS_SAMPLES:
|
||||
bias[0] += gx
|
||||
bias[1] += gy
|
||||
bias[2] += gz
|
||||
bias_count += 1
|
||||
if bias_count >= BIAS_SAMPLES:
|
||||
bias = [b / BIAS_SAMPLES for b in bias]
|
||||
bias_locked = True
|
||||
print(f" [BIAS LOCKED] bias_rad_s=({bias[0]:.5f}, {bias[1]:.5f}, {bias[2]:.5f})\n")
|
||||
continue # Don't print during calibration
|
||||
|
||||
gyro_events += 1
|
||||
ax, ay, az = last_accel
|
||||
ux, uy, uz = gx - bias[0], gy - bias[1], gz - bias[2]
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_print >= 0.1: # 10 Hz display update
|
||||
last_print = now
|
||||
# In m/s² and rad/s (SDL values)
|
||||
status = f"events={gyro_events}"
|
||||
if args.raw:
|
||||
# Nintendo-native counts (reversed SDL axis mapping)
|
||||
nx = int(-uz * LSB_PER_RAD_S)
|
||||
ny = int(-ux * LSB_PER_RAD_S)
|
||||
nz = int( uy * LSB_PER_RAD_S)
|
||||
nax = int(-az / GRAVITY * LSB_PER_G)
|
||||
nay = int(-ax / GRAVITY * LSB_PER_G)
|
||||
naz = int( ay / GRAVITY * LSB_PER_G)
|
||||
status += f" raw_g=({nax},{nay},{naz}) raw_gyro=({nx},{ny},{nz})"
|
||||
print(
|
||||
f"{'GYRO':<8} "
|
||||
f"{ax:>8.3f} {ay:>8.3f} {az:>8.3f} "
|
||||
f"{ux:>8.4f} {uy:>8.4f} {uz:>8.4f} "
|
||||
f"{status}"
|
||||
)
|
||||
|
||||
elif t == sdl3.SDL_EVENT_GAMEPAD_REMOVED:
|
||||
print("\nGamepad disconnected.")
|
||||
break
|
||||
|
||||
if args.count and gyro_events >= args.count:
|
||||
print(f"\nReached {args.count} gyro events. Done.")
|
||||
break
|
||||
|
||||
time.sleep(0.001)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nStopped.")
|
||||
|
||||
print(f"\nTotal gyro events received: {gyro_events}")
|
||||
if bias_locked:
|
||||
print(f"Final bias (rad/s): ({bias[0]:.5f}, {bias[1]:.5f}, {bias[2]:.5f})")
|
||||
print(f"Bias magnitude: {math.sqrt(sum(b**2 for b in bias)):.5f} rad/s "
|
||||
f"= {math.sqrt(sum(b**2 for b in bias)) * 180/math.pi:.3f} deg/s")
|
||||
|
||||
sdl3.SDL_CloseGamepad(gamepad)
|
||||
sdl3.SDL_Quit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the project Bluepad32 patch exactly once."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PatchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path]:
|
||||
root = Path.cwd() if repo_root is None else Path(repo_root)
|
||||
return (
|
||||
root / "external" / "bluepad32",
|
||||
root / "patches" / "bluepad32-sdl3-imu.patch",
|
||||
)
|
||||
|
||||
|
||||
def check_paths(bluepad32_path: Path, patch_path: Path) -> None:
|
||||
if not bluepad32_path.is_dir():
|
||||
raise PatchError(f"bluepad32 directory does not exist: {bluepad32_path}")
|
||||
if not (bluepad32_path / ".git").exists():
|
||||
raise PatchError(f"Bluepad32 is not a git repository: {bluepad32_path}")
|
||||
if not patch_path.is_file():
|
||||
raise PatchError(f"patch file does not exist: {patch_path}")
|
||||
|
||||
|
||||
def git_apply(bluepad32_path: Path, patch_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(bluepad32_path), "apply", *args, str(patch_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def is_patch_applied(bluepad32_path: Path, patch_path: Path) -> bool:
|
||||
return git_apply(bluepad32_path, patch_path, "--reverse", "--check").returncode == 0
|
||||
|
||||
|
||||
def apply_patch(bluepad32_path: Path, patch_path: Path) -> None:
|
||||
if is_patch_applied(bluepad32_path, patch_path):
|
||||
return
|
||||
|
||||
check = git_apply(bluepad32_path, patch_path, "--check")
|
||||
if check.returncode != 0:
|
||||
detail = check.stderr.strip() or "patch does not apply"
|
||||
raise PatchError(f"Patch validation failed (repository may be diverged):\n{detail}")
|
||||
|
||||
result = git_apply(bluepad32_path, patch_path)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "git apply failed"
|
||||
raise PatchError(f"Could not patch Bluepad32: {detail}")
|
||||
|
||||
|
||||
def prepare_bluepad32(
|
||||
bluepad32_path: Path | None = None,
|
||||
patch_path: Path | None = None,
|
||||
) -> None:
|
||||
default_bluepad32, default_patch = resolve_paths()
|
||||
dependency = Path(bluepad32_path or default_bluepad32)
|
||||
patch = Path(patch_path or default_patch)
|
||||
check_paths(dependency, patch)
|
||||
apply_patch(dependency, patch)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bluepad32", type=Path)
|
||||
parser.add_argument("--patch", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
prepare_bluepad32(args.bluepad32, args.patch)
|
||||
except PatchError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,19 +1,11 @@
|
|||
// TinyUSB configuration for one to four Switch Pro style HID interfaces.
|
||||
// Each interface uses independent 64-byte interrupt IN and OUT endpoints.
|
||||
// TinyUSB configuration tailored for a single Switch Pro style HID interface.
|
||||
// Data is derived from TinyUSB examples and tuned for a 64-byte HID endpoint.
|
||||
#ifndef _TUSB_CONFIG_H_
|
||||
#define _TUSB_CONFIG_H_
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#ifndef SWITCH_PICO_HID_INSTANCE_COUNT
|
||||
#define SWITCH_PICO_HID_INSTANCE_COUNT 1
|
||||
#endif
|
||||
|
||||
#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4
|
||||
#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4"
|
||||
#endif
|
||||
|
||||
|
||||
#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED)
|
||||
#ifndef CFG_TUSB_OS
|
||||
|
|
@ -31,7 +23,7 @@ extern "C" {
|
|||
#define CFG_TUD_ENDPOINT0_SIZE 64
|
||||
|
||||
// Device class configuration
|
||||
#define CFG_TUD_HID SWITCH_PICO_HID_INSTANCE_COUNT
|
||||
#define CFG_TUD_HID 1
|
||||
#define CFG_TUD_CDC 0
|
||||
#define CFG_TUD_MSC 0
|
||||
#define CFG_TUD_MIDI 0
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
#include "usb_pairing_management.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "tusb.h"
|
||||
|
||||
namespace UsbPairingManagement {
|
||||
|
||||
size_t encode_snapshot(const Bluepad32PairingSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size) {
|
||||
const size_t required =
|
||||
kResponseHeaderSize + snapshot.record_count * kRecordSize;
|
||||
if (output == nullptr || output_size < required ||
|
||||
snapshot.record_count > BLUEPAD32_PAIRING_RECORD_CAPACITY) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
output[0] = 'S';
|
||||
output[1] = 'P';
|
||||
output[2] = 'P';
|
||||
output[3] = 'M';
|
||||
output[4] = kProtocolVersion;
|
||||
output[5] = static_cast<uint8_t>(snapshot.status);
|
||||
output[6] = snapshot.record_count;
|
||||
output[7] = snapshot.overflow ? 1 : 0;
|
||||
output[8] = static_cast<uint8_t>(snapshot.generation);
|
||||
output[9] = static_cast<uint8_t>(snapshot.generation >> 8);
|
||||
output[10] = static_cast<uint8_t>(snapshot.generation >> 16);
|
||||
output[11] = static_cast<uint8_t>(snapshot.generation >> 24);
|
||||
|
||||
size_t offset = kResponseHeaderSize;
|
||||
for (uint8_t index = 0; index < snapshot.record_count; ++index) {
|
||||
const Bluepad32PairingRecord& record = snapshot.records[index];
|
||||
output[offset] = static_cast<uint8_t>(record.transport);
|
||||
output[offset + 1] = record.address_type;
|
||||
memcpy(&output[offset + 2], record.address,
|
||||
sizeof(record.address));
|
||||
offset += kRecordSize;
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
} // namespace UsbPairingManagement
|
||||
|
||||
extern "C" bool tud_vendor_control_xfer_cb(
|
||||
uint8_t rhport, uint8_t stage,
|
||||
tusb_control_request_t const* request) {
|
||||
if (stage != CONTROL_STAGE_SETUP) {
|
||||
return true;
|
||||
}
|
||||
if (request == nullptr ||
|
||||
request->bmRequestType_bit.recipient != TUSB_REQ_RCPT_DEVICE ||
|
||||
request->wValue != UsbPairingManagement::kRequestValue ||
|
||||
request->wIndex != UsbPairingManagement::kRequestIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (request->bRequest) {
|
||||
case UsbPairingManagement::kRequestGet: {
|
||||
if (request->bmRequestType_bit.direction != TUSB_DIR_IN) {
|
||||
return false;
|
||||
}
|
||||
static uint8_t response[
|
||||
UsbPairingManagement::kMaximumResponseSize];
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
const size_t response_size =
|
||||
UsbPairingManagement::encode_snapshot(
|
||||
snapshot, response, sizeof(response));
|
||||
return response_size != 0 &&
|
||||
tud_control_xfer(
|
||||
rhport, request, response,
|
||||
static_cast<uint16_t>(response_size));
|
||||
}
|
||||
case UsbPairingManagement::kRequestRefresh:
|
||||
if (request->bmRequestType_bit.direction != TUSB_DIR_OUT ||
|
||||
request->wLength != 0) {
|
||||
return false;
|
||||
}
|
||||
bluepad32_input_backend_request_pairing_snapshot();
|
||||
return tud_control_status(rhport, request);
|
||||
case UsbPairingManagement::kRequestClear:
|
||||
if (request->bmRequestType_bit.direction != TUSB_DIR_OUT ||
|
||||
request->wLength != 0) {
|
||||
return false;
|
||||
}
|
||||
bluepad32_input_backend_clear_pairings();
|
||||
return tud_control_status(rhport, request);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "bluepad32_input_backend.h"
|
||||
|
||||
namespace UsbPairingManagement {
|
||||
|
||||
constexpr uint8_t kRequestClear = 0x50;
|
||||
constexpr uint8_t kRequestGet = 0x51;
|
||||
constexpr uint8_t kRequestRefresh = 0x52;
|
||||
constexpr uint16_t kRequestValue = 0x5350;
|
||||
constexpr uint16_t kRequestIndex = 0x4d47;
|
||||
constexpr uint8_t kProtocolVersion = 1;
|
||||
constexpr size_t kResponseHeaderSize = 12;
|
||||
constexpr size_t kRecordSize = 8;
|
||||
constexpr size_t kMaximumResponseSize =
|
||||
kResponseHeaderSize +
|
||||
BLUEPAD32_PAIRING_RECORD_CAPACITY * kRecordSize;
|
||||
|
||||
size_t encode_snapshot(const Bluepad32PairingSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size);
|
||||
|
||||
} // namespace UsbPairingManagement
|
||||
115
uv.lock
generated
115
uv.lock
generated
|
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
revision = 2
|
||||
requires-python = ">=3.9"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
|
|
@ -451,84 +451,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/f6/caad9ed701fbb9223eb9e0b41a5514390769b4cb3084a2704ab69e9df0fe/hidapi-0.15.0.tar.gz", hash = "sha256:ecbc265cbe8b7b88755f421e0ba25f084091ec550c2b90ff9e8ddd4fcd540311", size = 184995, upload-time = "2025-12-09T09:48:54.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/5a/46620fc194f3fa728dce1966ce977334b080fc33b8b525018ad0e0324b91/hidapi-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b0e1781f7fb8b4015e318d839d66fa79e98900d53900e31d04edb336e0103846", size = 70517, upload-time = "2025-12-09T09:44:47.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/97/bcbcb89f9461c29d3b12dd32affd29e6312fd521154ee7f394496d0039a9/hidapi-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fa3e792987d4b7ed66d785491307e23d4f09d3636f8a23665a9694c43e92409", size = 70181, upload-time = "2025-12-09T09:44:49.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/19/b9f1cd2bce226ed43afa7e7649caeee5552e7ea844e997521d747f0da184/hidapi-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c2d3b83a8300953653ddd7f5190d5ee6072d4a6ee5944db47e92d97344ed92", size = 1039531, upload-time = "2025-12-09T09:44:55.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b9/5a6a1a4219ada2da251225c706d450076fd2b3d624000699ff4d329326ab/hidapi-0.15.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:91b43099b123363c8935a264acffb4c7e5c8dba3390f9b2bf19ff76597677a31", size = 1573936, upload-time = "2025-12-09T09:44:51.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/7c/a2df1c993db58f9337d5ee64d3c58f5b9c5127f3ef68907d0c50070d1e50/hidapi-0.15.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:cea09ccc3efa5b92ab18d3ae8636836edce2c421d67ed4409761da090e230e5a", size = 1654515, upload-time = "2025-12-09T09:44:54.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ca/341b3f5c713f72a7e61a942859b003a507f4a7d7cf5135a20da519bbcf05/hidapi-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2de66142cf780ee7c797b7c35e488d67bccdf2de98178ac12a4038c490c44ea", size = 669173, upload-time = "2025-12-09T09:44:57.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/55/e5922d48b59959820d976c8e232b14c1f57d65742e416831ce53fda32f6b/hidapi-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e283c3e9255850d66152adbf580744d38c840d399141b98d64d359cb97cbc128", size = 664906, upload-time = "2025-12-09T09:44:58.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b2/87683617901cbc5232a1d99cb8e51a967792aae48d462a8e2a842d63ec25/hidapi-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9f6d2f3bc15c718d8e2ce349d2d019a0fcc343ed1a705a91d0f1c7e792d6ea", size = 671123, upload-time = "2025-12-09T09:45:00.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/cc/03f7d56b82a9dc2abcfcd8d55915504e156b6558fb66926629836e551595/hidapi-0.15.0-cp310-cp310-win32.whl", hash = "sha256:81de6b5fcb4fbbbfc71c6d201a2ac6914d1d86e51930ffde5f96faf50e922473", size = 60160, upload-time = "2025-12-09T09:45:04.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/20/a39e33a9088d76f98f80d9320f8413bdaeaa0458b42470e63a47ac4ccf94/hidapi-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:c36895abaef3a4004af6c5020ca214fcdacb2a491e4cde5576afbb1dad903548", size = 67063, upload-time = "2025-12-09T09:45:02.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c5/3cfa157e7d1fd6af5ad52ea6ea031b1c8da141c61a2506ad5cb3420afa7f/hidapi-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53d018e6ac639a217c019c6dcd79a1d30c2401ac7a8147eedd11f2aa29307661", size = 70578, upload-time = "2025-12-09T09:45:05.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/7e/a43efc66b3b0a68058e76ad0cb2267ce9b30d9006dce10824f3c8b314b08/hidapi-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a11ef4b5ab0a2f36e35f44af394bff41cb645b03ce36c5b2f2c00873f112661f", size = 70233, upload-time = "2025-12-09T09:45:06.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/30/d8551d99528e0b7b2962cc76fb9a2b5990ea16aeb1457d1c3c61015d020f/hidapi-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e47d4fe13ddfc63fd1961974e10f3c7d50a57ae0bb73974e465cfe95dad5da18", size = 1070472, upload-time = "2025-12-09T09:45:16.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/26/433fc4e9efdfb9d1beb52e39ccdec265ae02003b1d2f8395aba3022b665b/hidapi-0.15.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f39917c9e9bea895384b1a8869159d808b4a53561af9b4f7c3148bdf6bfd97a0", size = 1602092, upload-time = "2025-12-09T09:45:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/13/911b757f6847a197ab5c978fe8dd55181035096a5659cf7183e013ed1ea2/hidapi-0.15.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f4d294fcd2551fb5b41d5f478a08e8945382e1d86d7d40320d02594922d6b667", size = 1677973, upload-time = "2025-12-09T09:45:10.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3f/cd92833886a15b34225bbd765ed1eb0270bc78b4cf6385e302c0e4bae494/hidapi-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:68cb85467151a3d4aa6ab4e11ca89a87c2404a778dbccb8fb5ae51e998133bb3", size = 699789, upload-time = "2025-12-09T09:45:18.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/0e/9cdad5783bf1b2aea6667da37f2435b286750d650071dcc50b42834c8810/hidapi-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:06fbcd7696ad768d7e9500b64e2ae742b5d90a2531b5aa8d355c0f049516a09d", size = 695983, upload-time = "2025-12-09T09:45:20.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ac/a57bc6e28d0dc1a0aed3dcc24302262ecbf2b723f9c4ba1af4ea51add5b4/hidapi-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:221e3a0c181503061bfc4a672eccd9f86b1da1167b31914be00d919edfd6e1bf", size = 697710, upload-time = "2025-12-09T09:45:21.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/40/6a45375a52027d8142e7acdaeab182a44ff6b818df41384019662eb4f351/hidapi-0.15.0-cp311-cp311-win32.whl", hash = "sha256:2c35bd9cc62227ec91047e36b260f75bdbf50814f3cf3c3b28648ac3ffabd9d7", size = 60070, upload-time = "2025-12-09T09:45:24.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a9/8ad4e6423c7416eb8dd765327f3be67f083c985b41b9b48ef3061a64c5f2/hidapi-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c5f7ee9ce8e3373fdb7002497f16bc652d9d4acf20c91275877f55165caf6f0c", size = 67296, upload-time = "2025-12-09T09:45:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/fd/9076aba0736f339b71bda5ee26cb678c02420bf96c7ea5bd9ebcbaf0aa3c/hidapi-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b7eb950214191c936b5bdabaeaf1cab99c0dfc3ae3edc220e3c6d4547296053", size = 70157, upload-time = "2025-12-09T09:45:25.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/9c/9335957cb7e1ca1be997e97afe850f62a9ff0708015048b3871b553eed5c/hidapi-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27933e7f3da7007e5e8d0b25bcf48a79a74e802555e496bba2c7d87f8a77cd61", size = 69597, upload-time = "2025-12-09T09:45:26.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/bc/0eb92f4f238adc8edc3decb859662cf87e302b0a6ecdabe0e4d0560eb5fd/hidapi-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cfdbc74d5c4b5fdeef58a246d80f7dbf4ca33fe741c889505a4a350dd2eb54fb", size = 1082139, upload-time = "2025-12-09T09:45:32.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/04/856827cfecaa89091ed470f697e3f9cfb172fc852960e6b902e765d7d650/hidapi-0.15.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4b7b6207c1c7df3524e9d4019c7196547327636e9924010b9b0bf0c40029e329", size = 1620074, upload-time = "2025-12-09T09:45:28.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/63/1338cf0e273d95e07202e391b17198f27bd2cfef71348707eac98cd8db2b/hidapi-0.15.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:908e4b8c35041aa800b51094fd9fbaa5d6161bfb71e55821f43c6433f54e2865", size = 1689689, upload-time = "2025-12-09T09:45:31.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/08/28ce99698e14bb6bdf9d0dcf7ab45ab86d8bade894885aa15bb3fa43bcc1/hidapi-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9356fffebf723ccfbf0f9370d3725de37b3e2f1f6bffc40c3466f854542861c", size = 713596, upload-time = "2025-12-09T09:45:34.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/16/23e446e2d0ad0d75de149d94f8e6313329aec4d7c18d027e56f5a386779e/hidapi-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fd082e13ca92bbd7a4ec65d9fbd9ef06ecd71798ce9f036b7144b3d498623a7e", size = 697243, upload-time = "2025-12-09T09:45:36.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/ef/e2fa4d795235eb09951f806bbfdbd766d1be805df448d67918e828b7cb31/hidapi-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35eb5797d86306bd67aee80ad8973ab0a0b4e6d54018d3efb328767d0e8c9373", size = 717599, upload-time = "2025-12-09T09:45:37.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a7/9b7fe9acd09ed90d702f8cc01190ab53e01d5022c21808b079144bc9017d/hidapi-0.15.0-cp312-cp312-win32.whl", hash = "sha256:ce6f99554dae15c48cd89a12d5aede77a92a8bd184b45d0b257a17c97e053cdb", size = 60136, upload-time = "2025-12-09T09:45:40.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/35/f9e2d3ead60b573140546b041dd41c78a48c0e6b573d61a03130adccd32e/hidapi-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:9abc71a62bf8a8f1d70a6fd3613f86feb37ddb67e05ed934e734df79e27bf4f6", size = 67073, upload-time = "2025-12-09T09:45:39.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/19/bda2dce4af8b8028e6d611d5caf60e223a4d32a638d1155eefdc6d8f2462/hidapi-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f336dd2dc308928d54a6fdce137814a941a3633ad6722919d7a3142dd99005c2", size = 69045, upload-time = "2025-12-09T09:45:41.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/18/9b7d6b6a7561654b9b0d7b22fba9c5c971b36f5fc541b04e02d71928349d/hidapi-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8017f2213810c3e57d9ab64c2328c593a1129b25804240108a6451fe35cde193", size = 68755, upload-time = "2025-12-09T09:45:42.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b0/f144cee5ce9e45518d234fd07c7a4f9a66de3a78ed42bf9f3f4083165e10/hidapi-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:208c7fc89f78c3448d55957c3e12893778aa91218ed7dc22697e554f091fe4fe", size = 1072286, upload-time = "2025-12-09T09:45:47.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bf/7fee015cc48b719bd40c6416f65084a900e7889f0cf52fcfedb9be107b10/hidapi-0.15.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:90f8bd963ae43a44036af5d689d090fab0e3eb52861186e4d2b863dd910a2726", size = 1609789, upload-time = "2025-12-09T09:45:44.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/b6/ac33532711c2ecda5a22d8b2db3223c8a13f16db6d2793ee73a809cca5b6/hidapi-0.15.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:cd6edc7af885755163f4ffb29639c14f8a4ad5cca13ed641e47aa11f9646d8b6", size = 1680636, upload-time = "2025-12-09T09:45:45.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/e1/5b4426b8a77f9efbef70c0dbc1d64602d36ea8878f76c3607959901c978c/hidapi-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6ef0f80b2ff32ba446c9a2d4bfca6623ab5f0c05d742a0161ea1f7af2dc33b12", size = 708617, upload-time = "2025-12-09T09:45:48.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a6/1ecdadc3e19f6755eed40f2111f447ec071e3d4f5f4f99b95d26a6d67219/hidapi-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6106d10873c1e465a2764e979fb96ec0d16bb926eedac829310830f90816cc76", size = 689589, upload-time = "2025-12-09T09:45:50.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/02/579af4220acdde5ab76d091edaa92f62e110f89131a56b11856f77779607/hidapi-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f7a710df215ea3b53a81029488f78e7303b83f01a2e8478db20d7c9756588dd", size = 710213, upload-time = "2025-12-09T09:45:51.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c7/52f2d903a607f024086829349383dcc7f0c22533fc242f26f9754eba2f06/hidapi-0.15.0-cp313-cp313-win32.whl", hash = "sha256:e4ddb57e71e2b8aca2c685b94b8587dc7d7cfae3c529a7c4076ba0775eb28d4a", size = 59749, upload-time = "2025-12-09T09:45:53.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/00/7dd3f866a748b0c9eb4adedaa025ec8c533ed1946e9e918661c948a5c0f4/hidapi-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:04e0c1ed6d742bc6e1d00599394bec6b2afb8ee2fc5a0a183d3c4c2d4e315c34", size = 66362, upload-time = "2025-12-09T09:45:52.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/65/e9c70deb396f5baea92cd2ea7803914a55d455c3982367bae4390483e6cc/hidapi-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a6bb079bcd5152dd8968893aecdcbde3954b849896078f54df2254c0d1f301e", size = 69532, upload-time = "2025-12-09T09:45:55.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/eb/d006a7e28ec63b5db76340e4aca6ff077ad336f8fbe64ac804b968927011/hidapi-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d80c745d285dfc9889e856226daa8eee6f9e83025ef2cb97e5fe0b1396f7818a", size = 69350, upload-time = "2025-12-09T09:45:56.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2b/95e7595eb61982c651ed2b3ecd9bae05a0298b668c801a992e4e814b22f2/hidapi-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0f4411fd479345ca86742d9dda77200476a902749d8e47ed438f31ec98af418", size = 1065210, upload-time = "2025-12-09T09:46:02.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a5/0344a7c223c3610510ec7f91f7220b0e5852c92d52aff23377f1c2888880/hidapi-0.15.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d93f7173dcbec61b928f4cf800bf26535f81ef3b7bd89199237207f6ca7e4f43", size = 1610362, upload-time = "2025-12-09T09:45:58.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/96/781212cf852705bc1db26edd4340b01b9ce04cadd5d327c00b64f7324978/hidapi-0.15.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:dacf0b9e5d776fde69b92d68c09ab878a09486629e1fe8e6ae851252b0f33ce5", size = 1680942, upload-time = "2025-12-09T09:46:00.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/48/6d51a37d3a81355740a82cd265d4fc8485a0a279e4b545cbf790bd106f51/hidapi-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda6ce04a77662e77a4b6b2598211935ca281e1cf663248d54bce75bd684fed8", size = 707550, upload-time = "2025-12-09T09:46:03.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/77/175efa84ecea161c70948dc67b412b7444c2a196e4def8b305cb756077c6/hidapi-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:74900b5c7e884e0e4316d4deb746e5dd294b5aaaf90eac056c4f9f19cf2c8097", size = 689865, upload-time = "2025-12-09T09:46:05.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/29/0e513390a8d686448672fabd30385f9cda6e01663765851d0cef88e6f0b1/hidapi-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:76236468509dfcb256fa0fbd5a73708c598b3819695b7d08499ae489c82116b6", size = 706809, upload-time = "2025-12-09T09:46:07.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/d6/c0b9d8568fdb38eebb9caaa56643a81d92cccb5d59a5b44e0d975a0f3808/hidapi-0.15.0-cp314-cp314-win32.whl", hash = "sha256:901c03817306eac7edd589f09e0e07467871b45f665949cc4caa645b54abc97b", size = 61089, upload-time = "2025-12-09T09:46:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/14/3b27516b2867e53545164ebd8ef45d0c36857d2a062036e788e56e2e34ce/hidapi-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:df0dd435562654e27ad0c7d045074eb1e1e016b09167dd48a258dec1ce4b9127", size = 67710, upload-time = "2025-12-09T09:46:08.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8f/ff034661faf99acaabe8954fb7db87c5242c79bf763dd972b23b1be5f104/hidapi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2f16e9ea8a1d24ac7a6bf316905823eafd58f78ec815a93118d4710faf95a224", size = 70893, upload-time = "2025-12-09T09:46:10.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/44/617666a0919d06521d732ef07998306b8ddcbd96038e19348c831acd76e8/hidapi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77f0965ca81a9be44694e84aa18e501d5cda49e372202a38a52d22acc38deadd", size = 71430, upload-time = "2025-12-09T09:46:11.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/64/129aed49fa45b6a5e2fd955618eebe93ff7e3889f05237da6b45051c77bb/hidapi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541c85b8225ce19b38ceacd404283ca1857345ccfa7ca12f54131203ff2d7f75", size = 1094257, upload-time = "2025-12-09T09:46:17.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ca/266ca521ff643957823a7f04a67030f6d2917e86e31c60c3d4878cd6b913/hidapi-0.15.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:89f909a6783307c2f067e8e7948f38ec43386f15d04759d11e5b32e3b2fdf3d8", size = 1663756, upload-time = "2025-12-09T09:46:13.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/eb9d0fe6d9ccd2645e993930cc7fdeb6e12ddcdec086f3ef7dec947286ca/hidapi-0.15.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:fd32f311633fad21a52e750775cd4ea23da912199c63d1331f4d8ae00ab0874b", size = 1710998, upload-time = "2025-12-09T09:46:15.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/43/e5c0cd80df4a65a468b7a9fc6891ea77435a75e64bb273104c37f12e05b3/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f7ca38699557ab057333f1628a346073ae6088c09897b2cd9fbc68f4896780c", size = 751383, upload-time = "2025-12-09T09:46:19.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/8f/033e19857bcc3ad1b9880b5c0dfbaa7c945e577b7dee18919c1b2478484d/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1c4e707c82629637a311fa1a7adc1ae1386de7b539e84b4e37125e52e449318d", size = 722837, upload-time = "2025-12-09T09:46:20.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/f0/2b4e7ff42cdc6a6bf7def0591dfd3854af81038a1925ac1ed7a96578e834/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc99bb16b359023190bf857297f6492a1c8441ccacff66dac881c9e278f5c262", size = 750923, upload-time = "2025-12-09T09:46:23.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/eb/c0676ff1f7e9ec9d99eaa428c66745cdab5b1742808f0c16061c85bd0b18/hidapi-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:005c84c74c940a314b211674edc763a931ace0b63685ace88148496f563e25f9", size = 66288, upload-time = "2025-12-09T09:46:25.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/5b/f22dbf886824559d3e66d84710c5fb48c09856e6816885da97f2f51700eb/hidapi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e384430363d400c4ffb33fe65f22a61c9f288a870158dc5a9f5c9d917b7250c7", size = 74723, upload-time = "2025-12-09T09:46:24.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/dc8d3fa85a5a4feb6ed8d79da9f5fefdd3b1607ce9220e1b54faa95927e5/hidapi-0.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3d220314975b99a083f4ee21abfd796a9ceeb99cbf3487a41c5eb80269102a4a", size = 71079, upload-time = "2025-12-09T09:46:40.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c0/73d0b576332467e1ff84c51bef3eb21f07125835a7a580f4047919a67d1d/hidapi-0.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc61ab7f45dddbb2df2fca93565c575c0b035337d1e13ce2868c092ff24e9c47", size = 70800, upload-time = "2025-12-09T09:46:41.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a7/9819036af3a3f6ab58570c4292c28bfd42ac9898e04070f598e92939ac51/hidapi-0.15.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aefc2afd146733e3ec8b33474bfb832c9281dd106309a216a20bade19dc0e95f", size = 1039182, upload-time = "2025-12-09T09:46:49.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c4/f696f62702b75eccc4dbcf619e82cf773b0d892d2bcc7e9cf85aefb3d9f2/hidapi-0.15.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:ef3b43b1a4d4df8ea542487cda888138eaac4707db46bbca0f7bb78d97a06323", size = 1572449, upload-time = "2025-12-09T09:46:43.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/7c/e58643470288fc6132933475ba810482616ad08c333770f23e9d4c9f31ca/hidapi-0.15.0-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:0608a882f06d7f37e0f1786e255e9a8b1e2386fdb8f9fb9d209b82682551f4b8", size = 1654806, upload-time = "2025-12-09T09:46:46.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a0/021d8f40ebed1f21a90c0f6c794657e3e9e9fe013788e2467bc5ce3b42f2/hidapi-0.15.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:788e16711a4530a2a7a8a4cd942f7250fa7f907a3be892b11d8eb51ec697739a", size = 667407, upload-time = "2025-12-09T09:46:50.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a5/4c7355b133be152d1e085190e37880230a47575a853752a06a824cd37951/hidapi-0.15.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:48e79274384c23b250bc95016c8aa6497cf0a23b3a112d05376cbf5ace266886", size = 665330, upload-time = "2025-12-09T09:46:51.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/02/24656533d5b7d0047ca70f9abaeae8fd9fe694c32e91a88a5885652aa605/hidapi-0.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c49aa659ecfe46a93a7057bca42208bfd123c2fcbdfd4a28eb1f9b8189260b68", size = 669911, upload-time = "2025-12-09T09:46:53.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4e/7dd283c34d85898b4a259b8ea1116800de2b603256b20d19fad31bf88be4/hidapi-0.15.0-cp39-cp39-win32.whl", hash = "sha256:27d288d405bd4abedd047eefb9c4ab797d232454b35e17e7b61a03edf70c64e8", size = 60498, upload-time = "2025-12-09T09:46:55.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/8c/b51934ac6dd24409d94b949c143bb55207a3a8213a520671cbdd95f24f42/hidapi-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:cb835b81624a72830e5ebebde26b93ab5ee812de648386a47d13dfbf64b348c7", size = 67432, upload-time = "2025-12-09T09:46:54.653Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
|
|
@ -546,7 +468,7 @@ resolution-markers = [
|
|||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
{ name = "mdurl", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
||||
wheels = [
|
||||
|
|
@ -561,7 +483,7 @@ resolution-markers = [
|
|||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
{ name = "mdurl", marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
|
|
@ -882,16 +804,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pysdl3"
|
||||
version = "0.9.11b1"
|
||||
version = "0.9.11b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/71/1de90f3baf555003b97e8c09db2f40376aa44105a2f2f98e239260cd07b4/pysdl3-0.9.11b1.tar.gz", hash = "sha256:1608b1e979fd6693b321af38e1d70a8a1df8141753096ade3477ff2b437c6ed0", size = 1421849, upload-time = "2026-05-06T23:50:21.42Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/2a/86e2b1cc7ac5a186ac3bad262cfbe55313268b6a8f7a3e61a4212fa4af09/pysdl3-0.9.11b0.tar.gz", hash = "sha256:e87dce397221c4943763bb45ec8725deb07b2bf5636d5da875e9d0ae34504068", size = 1421769, upload-time = "2026-03-15T19:28:04.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/5f/290b001a4f46a3811caf103bd98fb23bae0c700ab231fde8e1d6bfe2ecdf/pysdl3-0.9.11b1-py3-none-any.whl", hash = "sha256:2dd0bfe859ae93564b38c97f718d1310dbb4ea898fe85baea499f03012b1a152", size = 99721, upload-time = "2026-05-06T23:50:19.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ff/58a5b5a5bbf1fb822359c8fcf1a307d9dadc078d988cd6e770f4381081bf/pysdl3-0.9.11b0-py3-none-any.whl", hash = "sha256:98bd71dd4e1bcdaa5e83da200a208439048690da627b2d19163f5e222518cb24", size = 99667, upload-time = "2026-03-15T19:28:02.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -903,15 +825,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyusb"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
|
|
@ -946,19 +859,15 @@ name = "switch-pico-bridge"
|
|||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "hidapi" },
|
||||
{ name = "pysdl3" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyusb" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "hidapi" },
|
||||
{ name = "pysdl3" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyusb" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
|
|
@ -988,9 +897,9 @@ resolution-markers = [
|
|||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "idna", marker = "python_full_version < '3.10'" },
|
||||
{ name = "multidict", marker = "python_full_version < '3.10'" },
|
||||
{ name = "propcache", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
|
||||
wheels = [
|
||||
|
|
@ -1133,9 +1042,9 @@ resolution-markers = [
|
|||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "idna", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "multidict", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "propcache", marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
|
||||
wheels = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue