diff --git a/CMakeLists.txt b/CMakeLists.txt index d3a831a..4bc104b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,10 @@ endif() option(SWITCH_PICO_LOG "Enable UART debug logging" OFF) option(SWITCH_PICO_ADAPTER_FEASIBILITY "Build the automatic Switch/XInput feasibility prototype" OFF) +option(SWITCH_PICO_HAPTICS_EXPERIMENT + "Enable the host-triggered DualSense PCM transport experiment" OFF) +option(SWITCH_PICO_HAPTICS_EXPERIMENT_RAM + "Execute the haptics experiment hot path from SRAM" ON) 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" @@ -45,6 +49,11 @@ if(SWITCH_PICO_ADAPTER_FEASIBILITY message(FATAL_ERROR "SWITCH_PICO_ADAPTER_FEASIBILITY requires the BLUEPAD32 backend") endif() +if(SWITCH_PICO_HAPTICS_EXPERIMENT + AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + message(FATAL_ERROR + "SWITCH_PICO_HAPTICS_EXPERIMENT requires the BLUEPAD32 backend") +endif() # Pull in Raspberry Pi Pico SDK (must be before project) include(pico_sdk_import.cmake) @@ -135,14 +144,33 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") else() set(SWITCH2_WAKE_CONFIGURED_VALUE 0) endif() + # The experiment yields after one received packet; its transport wrapper + # explicitly reschedules remaining input so timers run between packets. target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_BLUEPAD32=1 SWITCH_PICO_HID_INSTANCE_COUNT=4 SWITCH_PICO_USB_OUTPUT_MODES=1 PICO_FLASH_ASSUME_CORE1_SAFE=0 - PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=16 + PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=$,1,16> SWITCH2_WAKE_CONFIGURED=${SWITCH2_WAKE_CONFIGURED_VALUE} ) + if(SWITCH_PICO_HAPTICS_EXPERIMENT) + target_sources(switch-pico PRIVATE + ${SWITCH_PICO_SOURCE_DIR}/input/haptics_experiment.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/haptics_transport_probe.cpp) + target_compile_definitions(switch-pico PRIVATE + SWITCH_PICO_HAPTICS_EXPERIMENT=1 + SWITCH_PICO_HAPTICS_EXPERIMENT_RAM=$) + target_compile_definitions(bluepad32 PRIVATE + SWITCH_PICO_HAPTICS_EXPERIMENT=1) + # These boundaries are defined in different SDK translation units from + # their callers. GNU ld --wrap intercepts unresolved symbol references; + # an LTO build must verify actual caller edges, not just wrapper symbols. + target_link_options(switch-pico PRIVATE + "LINKER:--wrap=cyw43_bluetooth_hci_write" + "LINKER:--wrap=cyw43_bluetooth_hci_read" + "LINKER:--wrap=btstack_run_loop_base_poll_data_sources") + endif() else() target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HID_INSTANCE_COUNT=1 diff --git a/HAPTICS_EXPERIMENT.md b/HAPTICS_EXPERIMENT.md new file mode 100644 index 0000000..3e35f76 --- /dev/null +++ b/HAPTICS_EXPERIMENT.md @@ -0,0 +1,176 @@ +# DualSense low-latency haptics experiment + +## Goal and evidence + +Prove a bounded-latency native Bluetooth PCM transport on one Pico 2 W / DualSense connection before reconnecting Nintendo HD-rumble decoding. Normal builds retain compatibility rumble. This is an opt-in deterministic transport experiment, not a claim of complete HD Rumble support. + +The user observed 1–2 seconds of gameplay-to-haptics delay in OMP session `01a06fa9-cdc7-72de-ac0e-7de08c355f06`. Both a DS5Dongle-style 0x39 stream and a short 0x32 stream failed after continuous silence, latest-state replacement and can-send callbacks were tried. Do not repeat those changes as newly discovered fixes or attribute the observed delay to profile feedback. + +Recovered source and retained object identify three concrete issues: + +- `request_can_send()` armed `send_requested` after `l2cap_request_can_send_now_event()`. This BTstack can deliver the event synchronously; the callback discarded it and left a pending flag with no notification. An isolated reproduction produced zero sends / pending=true, versus one send / pending=false when armed before requesting. +- The final short report used haptic descriptor 0xd2. SAxense uses 0x92 for its single 64-byte block. The extra bit is undocumented; the mismatch is established, its contribution to the physical delay is not. +- Relative Pico BTstack timers add one millisecond tick. Rescheduling `{11,11,10}` relative to each callback does not implement an exact 3 kHz clock and accumulates callback lateness. + +The original diagnostic captures were not from a live native-haptic stream. CPU saturation, ACL-credit starvation, and controller-internal latency were not measured. + +Sources: + +- https://github.com/egormanga/SAxense/blob/master/SAxense.c +- https://github.com/awalol/DS5Dongle/blob/master/src/audio.cpp +- https://github.com/awalol/DS5Dongle/blob/master/src/bt.cpp +- https://github.com/awalol/DS5Dongle/blob/master/CMakeLists.txt +- Local SDK `lib/btstack/src/l2cap.c`, `src/rp2_common/pico_btstack/btstack_run_loop_async_context.c`. + +## Implementation contract + +1. Build-only opt-in `SWITCH_PICO_HAPTICS_EXPERIMENT`; separate build directory/artifacts. Preserve wake identity, pairing storage, USB modes and ordinary firmware artifacts. Use stock clock/voltage. Experimental builds use the controller's advertised outgoing ACL capacity and one-packet receive batches with explicit rescheduling; normal builds retain the three-credit cap and sixteen-packet batches. Incoming flow control and all FIFO sizes remain unchanged. +2. One selected connected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. Explicit management start/stop; no tones at pairing or boot. +3. 142-byte report 0x32 plus A2 transaction = 143-byte L2CAP SDU. The first report selects native mode using sized state block 0x90, length 63, with rumble-selection bits and other write flags clear; it carries one silent 64-byte haptic block (0x92). Subsequent reports use compact audio control `{0x91,3,0x62,16,counter}` and **two** 64-byte haptic blocks: descriptor 0xd2, length 64, followed by 128 sample bytes. Thus 0xd2 is valid here, unlike the original spike's single-block mismatch. Deterministic padding and Bluetooth CRC. No speaker, microphone, USB audio endpoint, Opus or resampler. +4. Steady-state 64 stereo frames per report at 3 kHz, 46.875 reports/s. Absolute microsecond/sample deadlines use integer rational arithmetic; preserve fractional time and skip obsolete packets after stalls rather than burst-replaying them. Timer wakeups account for SDK +1 tick. Can-send permission and audio deadlines are separate. Arm flags before requests and handle synchronous callbacks without recursive stream generation. +5. Finite sequence: 48 report intervals of priming silence (1.024 s), four cycles of left 100 Hz tone / silence / right 200 Hz tone / silence (12 reports = 256 ms per phase), then 48 reports of trailing silence. Total 288 reports / 6.144 s. The initial mode handoff contains 32 silent frames; normal two-block streaming follows. Signed sample peak 32/127 is deliberately gentle, not a claim of 25% perceived force. Stop preempts the pattern, emits silence when sendable and restores compatibility output. Disconnect cancels without stale-pointer use. Only the selected controller's conventional outputs are overridden. +6. No historical PCM FIFO. Generate only the current due block when transmission is permitted; bounded control mailbox across cores. Record packet counts, skipped blocks, failed sends, synchronous callbacks, generation cost, send gaps, lateness, request wait and first-tone timestamps. HCI submission is not physical actuator onset. +7. Host `haptics-experiment start --slot 0`, `status`, `stop`, and `profile` use existing USB management framing. The experiment reports unsupported on ordinary builds. Existing general runtime diagnostics remain unchanged. Both experiment and transport-profile responses now require schema 2; update the host and experimental firmware together. +8. Regression coverage must include synchronous callback delivery, rational clock and late wakeups, reference packet interpretation, finite completion/stop, disconnect/reconnect and compatibility restoration. Native probes cannot prove controller acceptance or physical latency. + +## Building and running + +Use the repository's provisioned Pico SDK/toolchain environment. These CMake commands only build; they do not flash the adapter: + +```sh +cmake -S . -B build-haptics -DPICO_BOARD=pico2_w \ + -DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \ + -DSWITCH_PICO_HAPTICS_EXPERIMENT=ON \ + -DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON \ + -DSWITCH_PICO_LOG=OFF +cmake --build build-haptics +``` + +The experimental artifacts are `build-haptics/switch-pico.elf` and `.uf2`. For the same-clock flash comparison, use another build directory and `-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=OFF`. The standard `build.py` entry points explicitly disable the experiment, including when reusing an old CMake cache. + +After loading the chosen image and connecting a DualSense: + +```sh +uv run switch-pico-config haptics-experiment status +uv run switch-pico-config haptics-experiment start --slot 0 --watch --json +uv run switch-pico-config haptics-experiment profile --json +uv run switch-pico-config haptics-experiment stop --slot 0 +``` + +The first tone is intentionally scheduled 1.024 seconds after the stream starts; that priming silence is not transport delay. Compare physical onset against `first_tone_due_us` / `first_tone_sent_us`, not the time the start command was entered. Each 256 ms tone/silence phase is a second timing marker. Host USB polling can miss intermediate state but the firmware retains maxima and final counters. + +## Protocol + +USB vendor management operation 0x40: OUT two-byte payload `{action, slot}` (0=stop, 1=start, slot 0..3); existing request envelope. IN diagnostics, existing response envelope, schema 2, 72-byte payload. Schema 2 identifies the two-block/288-report pattern; schema 1 used one block/576 reports. + +- Seventeen little-endian u32 fields: run_id, connection_generation, start_us, generated_packets, sent_packets, skipped_packets, send_failures, can_send_requests, synchronous_callbacks, max_generate_us, max_send_gap_us, max_lateness_us, max_request_wait_us, first_tone_due_us, first_tone_sent_us, last_sent_us, elapsed_us. +- Four u8 fields: state, slot, last_error, reserved (zero). +- State: idle=0, pending=1, running=2, completed=3, stopped=4, disconnected=5, unsupported=6, error=7. Disabled build reports unsupported. +- Microsecond timestamps are low 32 bits of Pico uptime; use unsigned modular differences for this bounded experiment. Host receipt time is not a hardware onset measurement. +- Error: none=0, unsupported controller=1, insufficient MTU=2, disconnected=3, timeout=4, transport failure=5, queued conventional output=6. Start rejects a nonempty conventional output queue rather than discarding LED/control reports or interleaving them with PCM; let prior output drain before retrying. + +### Transport timing probe + +Operation `0x41` is IN-only. Schema 2 contains 32 little-endian u32 fields in the declaration order of `HapticsTransportProbe` in `input/haptics_transport_probe.h` (128 bytes). The final two fields expose ACL packet size/count from the controller's raw HCI Read Buffer Size response, before the SDK's software cap. Measurements reset for each run, retain their final values, and correlate by run ID and connection generation. Ordinary firmware reports this operation as unsupported. + +```sh +uv run switch-pico-config haptics-experiment profile --json +``` + +The probe measures scheduled timer lateness before choosing a current packet, permission wait before generating/sending, full synchronous `l2cap_send` duration, CYW43 write/read duration, data-source polling duration/gaps, selected-handle Number Of Completed Packets events, and observed ACL credit extrema. The first successful tone's send-return timestamp complements the original pre-send timestamp. Neither is radio transmission or actuator onset. + +Timing totals are inclusive and overlap: a data-source poll can dispatch a completion callback that sends a packet, and a synchronous send can call the CYW43 writer. Do not add those totals as independent CPU costs. Read/write/poll measurements cover all controller traffic during the selected run; completion counts and outstanding packets are filtered to the selected connection. Free ACL slots come from the shared controller pool. Extrema are observations, not a complete occupancy trace. + +The instrumentation stores bounded counters in memory, not per-packet UART logs. GNU linker wrappers preserve each transport call's arguments, return values and call count. After a bounded poll returns nonempty input, the wrapper marks future receive work pending rather than draining recursively; this fairness behavior runs even while measurement is inactive. Verify wrapper call sites in the actual ELF and nonzero live counters; native tests alone cannot establish that a differently optimized SDK build retained the boundaries. + +## SRAM relocation (not RAM replacement) + +DS5Dongle executes selected hot code from SRAM instead of external XIP flash. Its full audio build also relocates roughly 220 KB of Opus code/data; that does not belong in this haptics-only experiment. + +Start by placing this experiment's packet synthesis/send callback and small waveform constants in SRAM using the Pico SDK's time-critical sections; verify symbol placement and SRAM cost in the linked ELF. Do not claim that this alone fixes transport latency. Keep a build switch for flash-versus-SRAM comparison without changing the protocol or clock. + +Only after timing evidence, consider selected L2CAP/HCI/CYW43 and USB hot call chains. Relocating only a wrapper leaves callees in flash. Broad object-section rewriting is SDK/compiler sensitive, consumes SRAM needed by stacks and buffers, and must be checked in the map. Do not wholesale replace memcpy/memset or move the entire stack without measurements. Shared flash-safe operations and BOOTSEL sampling remain legitimate jitter sources to measure, not disable unsafely. + +## Optional overclocking + +Overclocking is permitted, but the first transport run stays at the Pico 2 W's stock 150 MHz and stock regulator setting. Compare flash versus SRAM at the same clock first; then compare clocks with identical packet contents and scheduling. An overclock cannot repair a malformed block descriptor or a lost synchronous callback, and it does not increase Bluetooth's negotiated air rate or controller ACL credits. + +The [ClockworkPi thread](https://forum.clockworkpi.com/t/overclocking-pico-2/18226) reports successful 300 MHz and higher configurations, alongside warnings about peripheral limits. [Pimoroni's measurements](https://learn.pimoroni.com/article/overclocking-the-pico-2) found 312 MHz at 1.1 V on one sample under their initial benchmark. Those are experimental observations, not a stability guarantee for this board running both cores, USB and CYW43 simultaneously. Do not copy the article's extreme voltages, voltage-limit removal, or dry-ice setup. + +For a subsequent opt-in clock experiment, record requested/measured system clock, regulator voltage, flash divider, CYW43 PIO divider, temperature/environment, and the same packet diagnostics. Preserve the 48 MHz USB clock; adjust flash and CYW43 dividers before raising the system clock so neither bus is inadvertently overclocked. Validate USB enumeration/control transfers, sustained controller input, reconnect, flash-safe persistence and packet timing—not just a CPU benchmark. Keep a stock UF2 and BOOTSEL recovery path. No clock or voltage change is applied by this transport experiment. + +## Verification and acceptance + +Build normal and opt-in firmware, run focused regressions, verify time-critical symbols and retained wake configuration. Exercise the actual USB CLI and connected controller. Capture live counters during the finite run. The current two-block format requires 46.875 reports/s and nominal HID+A2 traffic of 6,703.125 bytes/s, before L2CAP/HCI/radio overhead. Acceptance is 288/288 submissions with zero skipped slots and failures, repeated runs, bounded stop and continuing controller input. + +Physical acceptance requires correlating first-tone scheduling/sending with actuator onset using an accelerometer/contact microphone or a synchronized observation. Record controller model/firmware, packet gaps and CPU generation time. A successful `l2cap_send` is not an acknowledgement of playback. Only a measured low-latency result permits integration with Nintendo's per-side, per-band timeline. + +## Results + +### Initial single-block baseline (superseded) + +- Normal all-in-one, experimental SRAM, and experimental flash variants built successfully for Pico 2 W / RP2350 Arm, using the provisioned Pico SDK and GNU Arm 15.2.1 toolchain. +- 99 focused host-control, USB-management, build-helper and Bluepad32-preparation tests passed. Both native sender variants passed, including independent zlib validation of all 576 generated reports. +- The first hardware start exposed an additional integration defect: the Classic path leaves Bluepad32's cached `conn.protocol` unset. Eligibility now uses `gap_get_connection_type(handle)`, matching the existing backend identity code. Modeling the real unset field made the native test fail before this correction and pass afterward; the corrected hardware accepted the run. +- The initial SRAM image put the callback at `0x20000174` and waveform at `0x20000910`, with 2,024 bytes extra SRAM versus its flash counterpart. Final-image placement is recorded below. +- At this initial stage no wake capture, pairing reset, clock, voltage, FIFO-size or HCI-credit-limit change was performed. Wake transmission itself was not exercised. + +### Initial connected-controller run + +The SRAM variant was flashed and exercised over the actual USB management endpoint with a reconnected Sony DualSense (`054c:0ce6`) in slot 0, using the configured stock 150 MHz clock and unchanged regulator settings. Controller firmware revision and physical actuator onset were not measured. + +```sh +uv run switch-pico-config --timeout 15 haptics-experiment start --slot 0 --watch --json +``` + +The host captured 63 snapshots during run 1: + +| Measurement | Result | +| --- | ---: | +| Scheduled packet slots | 576 | +| Successful PCM submissions | 113 | +| Obsolete packet slots skipped | 463 | +| Send failures | 0 | +| CAN_SEND_NOW requests | 113 | +| Synchronous callbacks | 58 | +| Maximum packet generation time | 55 us | +| Maximum permission wait | 72,231 us | +| Maximum submission gap | 108,791 us | +| First tone submission after scheduled first tone | 15,618 us | +| Terminal elapsed time, including restoration | 6,264,335 us | + +The finite lifecycle reached `completed`, but **the PCM cadence acceptance criterion failed**: 113 submissions over a 6.144-second pattern window is about 18.4 packets/s, versus the required 93.75. `completed` means the finite run and compatibility restoration finished, not that every packet was delivered or low-latency playback was proved. There was no historical PCM queue replay; old sample slots were discarded. + +Run 2 exercised early stop: firmware-confirmed `stopped`, 37 submissions, 142 skipped slots, zero send failures. Controller input remained live afterward. Configuration generation 9 / CRC `b740995b` matched the pre-flash baseline. + +This established that synthesis was inexpensive but did not separate synchronous I/O, receive-loop work and credit wait. The follow-on measurements below did so. The flash-resident comparison was built, not flashed or timed; no SRAM latency improvement was established by this baseline. + +### Transport diagnosis and corrections + +1. **Instrumented sixteen-packet baseline:** 106/576 submissions, 470 skipped slots. Every poll consumed all 16 reads: 2,624 reads across 164 polls. Maximum poll duration 37,834 us; timer lateness 38,544 us. In contrast, synchronous send peaked at 1,429 us and CYW43 write at 1,090 us. Nested timing totals must not be summed. +2. **Receive fairness:** cap each experimental poll at one packet and explicitly reschedule further work after nonempty reads. Maximum timer lateness fell to 4,126 us. However, the three-slot outgoing cap still filled and permission waits reached 127,578 us; this alone did not sustain PCM. +3. **Negotiated credits:** capture the controller's raw initialization response: **8 ACL packets of 1,021 bytes**. Remove only the experimental software clamp, allowing BTstack to use the advertised capacity. Keep controller-to-host flow control enabled. The single-block sender improved to 356/576 submissions but still skipped 220 slots. +4. **Native mode handoff:** the user felt no vibration. Compatibility restoration had explicitly selected rumble; audio reports did not clear that selection. Sending the sized 0x10 native-state block first produced user-confirmed vibration, albeit weak and still interrupted. +5. **Compact two-block format:** [DS5Dongle's audio implementation](https://github.com/awalol/DS5Dongle/blob/master/src/audio.cpp) documents that audio controls can be reduced to a buffer-length value and packet counter. With microphone streaming disabled, mask 0x62 and length 3 leave room for two 64-byte blocks in the same 143-byte SDU. Buffer length 16 is the reference configuration's minimum. The counter advances by two. This halves required report cadence without reducing the 3 kHz sample rate or changing the audible pattern. + +### Final live acceptance + +Four consecutive six-second runs on the connected DualSense completed with **288/288 reports, zero skipped slots and zero send failures in every run**. Across those runs: + +| Measurement | Worst observed value | +| --- | ---: | +| Packet generation | 57 us | +| Send-permission wait | 191 us | +| Report gap (nominal 21,333.33 us) | 24,081 us | +| Submission lateness | 2,988 us | +| First tone send-return lateness | 3,095 us | + +The user confirmed **alternating pulses** and described them as still a bit weak. Peak 32/127 is intentional; it remained unchanged across packing comparisons. This establishes native haptic acceptance and recognizable channel alternation, not a precise physical latency measurement. + +Early-stop run 5 reached `stopped` with 72 submissions, zero skipped slots and zero failures. The host observed stop confirmation in approximately 111 ms, including USB querying/restoration. During that check 605 controller reports arrived, and the live input state advanced by 565 generations while remaining connected. Button-to-Switch latency and actuator-stop latency were not measured. + +Final verification: **122 focused tests passed**; normal, experimental SRAM and experimental flash images built. The SRAM callback is at `0x20000174`, waveform at `0x20000920`; the flash variant places them at `0x10014624` and `0x10068534`. SRAM relocation adds **2,056 bytes** versus the final flash variant. Real SDK call sites were verified to enter the transport wrappers. + +Configuration remains generation 9 / CRC `b740995b`; wake configuration remains included. Clock stays at the configured stock 150 MHz, voltage unchanged. No FIFO enlargement, incoming flow-control removal, speaker/microphone stream or broad stack relocation was needed. + +The SRAM experimental image remains loaded, with the experiment stopped. Nintendo HD-rumble decoding is **not yet connected** to this PCM sender; normal gameplay retains compatibility rumble. Physical onset still needs a synchronized sensor/contact-microphone measurement before claiming a gameplay-to-actuator latency bound. diff --git a/bluepad32_config/btstack_config.h b/bluepad32_config/btstack_config.h index 424d437..e006856 100644 --- a/bluepad32_config/btstack_config.h +++ b/bluepad32_config/btstack_config.h @@ -56,9 +56,13 @@ #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. +// Retain the normal firmware's conservative outgoing credit cap. The PCM +// experiment uses the controller's advertised ACL capacity (8 on CYW43439); +// BTstack still clamps to the real HCI Read Buffer Size response. +#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT #define MAX_NR_CONTROLLER_ACL_BUFFERS 3 +#endif +// Incoming flow control remains enabled; do not overrun the shared CYW43 bus. #define MAX_NR_CONTROLLER_SCO_PACKETS 3 #define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL #define HCI_HOST_ACL_PACKET_LEN 1024 diff --git a/build.py b/build.py index cc524d5..712b3b2 100644 --- a/build.py +++ b/build.py @@ -400,6 +400,8 @@ def build( "-DPICO_BOARD=pico", "-DSWITCH_PICO_INPUT_BACKEND=UART", ] + # Experimental images are built explicitly in their own CMake directory. + definitions.append("-DSWITCH_PICO_HAPTICS_EXPERIMENT=OFF") run_cmd( [ diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index bf48e82..fb9aa07 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -584,3 +584,30 @@ index 67841e8..9fe7134 100644 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); +diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c +--- a/src/components/bluepad32/bt/uni_bt.c ++++ b/src/components/bluepad32/bt/uni_bt.c +@@ -73,6 +73,12 @@ + #include "uni_log.h" + #include "uni_property.h" + ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO. ++extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device, ++ uint16_t local_cid); ++#endif ++ + // globals + bd_addr_t uni_local_bd_addr; + +@@ -473,6 +479,10 @@ void uni_bt_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t* pack + if (device == NULL) { + loge("--->>> CANNOT FIND DEVICE"); + } else { ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++ if (uni_platform_on_l2cap_can_send_now(device, local_cid)) ++ break; ++#endif + uni_hid_device_send_queued_reports(device); + } + break; diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 53227bb..133905a 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1,6 +1,9 @@ #include "input/bluepad32_input_backend.h" #include "input/controller_hotkey_config.h" #include "input/switch2_wake.h" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/haptics_experiment.h" +#endif #include "configuration/configuration_service.h" #include "profile/profile_service.h" #include @@ -1271,6 +1274,9 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } const bool xinput_host_mode = host_rumble_duration_ms() == kXInputHostRumbleDurationMs; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_poll(); +#endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { RumbleEnvelope envelope{}; @@ -1287,6 +1293,15 @@ void process_rumble_timer(btstack_timer_source_t* timer) { critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (haptics_experiment_owns(slot.device)) { + // The experiment owns all output on this connection while active. + // Keep the latest XInput state but don't replay stale Switch pulses. + slot.rumble_pending = false; + critical_section_exit(&g_state_lock); + continue; + } +#endif if (slot.retained_host_rumble_valid && (!xinput_host_mode || slot.retained_host_rumble.duration_ms != @@ -1556,6 +1571,9 @@ void platform_on_device_connected(uni_hid_device_t* device) { } void platform_on_device_disconnected(uni_hid_device_t* device) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_detach(device); +#endif const int slot_index = slot_for_device(device); if (slot_index < 0) { return; @@ -1624,6 +1642,10 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { return UNI_ERROR_NO_SLOTS; } if (became_active) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_attach( + static_cast(slot_index), lighting_generation, device); +#endif if (lighting_target_is_current( static_cast(slot_index), lighting_generation, device)) { @@ -1733,6 +1755,13 @@ uni_platform* get_platform() { } // namespace +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +extern "C" bool uni_platform_on_l2cap_can_send_now( + uni_hid_device_t* device, uint16_t cid) { + return haptics_experiment_on_can_send_now(device, cid); +} +#endif + void bluepad32_input_backend_init() { if (g_initialized) { return; @@ -1741,6 +1770,9 @@ void bluepad32_input_backend_init() { critical_section_init(&g_state_lock); configuration_service_prepare(); profile_service_prepare(); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_prepare(); +#endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; slot = {}; diff --git a/src/firmware/input/haptics_experiment.cpp b/src/firmware/input/haptics_experiment.cpp new file mode 100644 index 0000000..da020b2 --- /dev/null +++ b/src/firmware/input/haptics_experiment.cpp @@ -0,0 +1,762 @@ +#include "input/haptics_experiment.h" +#include "input/haptics_transport_probe.h" + +#include +#include +#include +#include + +#if SWITCH_PICO_HAPTICS_EXPERIMENT_RAM +#define HAPTICS_HOT(name) __time_critical_func(name) +#define HAPTICS_DATA __not_in_flash("haptics_experiment_waveform") +#else +#define HAPTICS_HOT(name) name +#define HAPTICS_DATA +#endif + +namespace { + +constexpr uint32_t kPacketNumeratorUs = 64000; +constexpr uint32_t kPacketDenominator = 3; +constexpr uint32_t kPackets = 288; +constexpr uint32_t kPrimingPackets = 48; +constexpr uint32_t kToneEndPacket = 240; +constexpr uint32_t kPhasePackets = 12; +constexpr uint32_t kDrainTimeoutUs = 100000; +// Briefly retain ownership while compatibility output drains. Its parser timer +// is canceled synchronously; it must not survive a device disconnect/reuse. +constexpr uint32_t kRestoreSettleUs = 3000; +constexpr uint16_t kReportBytes = 143; // A2 + 142-byte report 0x32. +constexpr uint16_t kCrcOffset = kReportBytes - 4; +constexpr uint8_t kNoSlot = 0xff; + +enum Error : uint8_t { + kNoError = 0, + kUnsupported = 1, + kMtu = 2, + kConnection = 3, + kTimeout = 4, + kTransport = 5, + kQueuedOutput = 6, +}; + +enum class Phase { kIdle, kPattern, kDrain, kRestore }; + +struct Attachment { + uni_hid_device_t* device = nullptr; + uint32_t generation = 0; + uint16_t cid = 0; +}; + +struct Command { + bool pending = false; + uint8_t action = 0; + uint8_t slot = kNoSlot; + uint32_t run_id = 0; + Attachment connection{}; +}; + +// Only the attachment identities, mailbox and published snapshot cross cores. +// No BTstack call (including synchronous reentry) holds this lock. +critical_section_t g_lock; +HapticsExperimentDiagnostics g_snapshot; +bool g_snapshot_waiting = false; +uint32_t g_snapshot_request_us = 0; +Command g_command; +bool g_busy = false; +bool g_prepared = false; + +// Written on core 1 under the lock; core 0 only reads identities for requests. +Attachment g_attachments[4]; + +// All remaining state belongs exclusively to the BTstack core. +HapticsExperimentDiagnostics g_diagnostics; +Attachment g_connection; +Phase g_phase = Phase::kIdle; +HapticsExperimentState g_finish_state = HapticsExperimentState::kCompleted; +btstack_timer_source_t g_cadence_timer{}; +btstack_timer_source_t g_lifecycle_timer{}; +bool g_cadence_armed = false; +bool g_lifecycle_armed = false; +bool g_send_requested = false; +bool g_request_in_progress = false; +bool g_in_callback = false; +bool g_last_was_silence = true; +bool g_first_tone_sent = false; +uint32_t g_next_packet = 0; +uint64_t g_start_us = 0; +uint64_t g_end_us = 0; +uint64_t g_lifecycle_due_us = 0; +uint64_t g_request_us = 0; +uint64_t g_restore_deadline_us = 0; + +// round(32 * sin(2*pi*n/30)). A stride of one is 100 Hz at 3 kHz; +// a stride of two is 200 Hz. Preserve phase across all 12 packets of a tone. +static const int8_t HAPTICS_DATA kSine[30] = { + 0, 7, 13, 19, 24, 28, 30, 32, 32, 30, 28, 24, 19, 13, 7, + 0, -7, -13, -19, -24, -28, -30, -32, -32, -30, -28, -24, -19, -13, -7, +}; + +void cadence_timer(btstack_timer_source_t*); +void lifecycle_timer(btstack_timer_source_t*); +void request_send(); +void restore_compatibility(HapticsExperimentState state); + +void update_max(uint32_t* value, uint32_t candidate) { + if (candidate > *value) { + *value = candidate; + } +} + +uint64_t packet_due(uint32_t packet) { + // Round each absolute rational deadline up, never its relative interval. + return g_start_us + + (static_cast(packet) * kPacketNumeratorUs + + kPacketDenominator - 1) / kPacketDenominator; +} + +bool connection_current() { + if (g_phase == Phase::kIdle || g_diagnostics.slot >= 4) { + return false; + } + const Attachment& attached = g_attachments[g_diagnostics.slot]; + return attached.device == g_connection.device && + attached.generation == g_connection.generation && + attached.cid == g_connection.cid; +} + +void publish(bool finished = false) { + if (g_phase != Phase::kIdle) { + g_diagnostics.elapsed_us = + static_cast(time_us_64() - g_start_us); + } + critical_section_enter_blocking(&g_lock); + // A newly accepted start must not be overwritten by the preceding run. + if (g_snapshot.run_id == g_diagnostics.run_id) { + g_snapshot = g_diagnostics; + g_snapshot_waiting = g_send_requested; + g_snapshot_request_us = static_cast(g_request_us); + if (finished) { + g_busy = false; + } + } + critical_section_exit(&g_lock); +} + +void cancel_timer(btstack_timer_source_t* timer, bool* armed) { + if (*armed) { + btstack_run_loop_remove_timer(timer); + *armed = false; + } +} + +void schedule_timer(btstack_timer_source_t* timer, bool* armed, + uint64_t deadline_us) { + cancel_timer(timer, armed); + const uint64_t now_us = time_us_64(); + // Pico's relative timer is floor(now_us/1000) + timeout_ms + 1. + // Aim for the deadline's millisecond (possibly early); the handlers check + // microseconds again. Only that final fractional tick needs a zero-delay + // rearm. There is no permanent millisecond polling timer. + const uint64_t now_ms = now_us / 1000; + const uint64_t due_ms = deadline_us / 1000; + const uint32_t delay_ms = due_ms > now_ms + 1 + ? static_cast(due_ms - now_ms - 1) + : 0; + btstack_run_loop_set_timer(timer, delay_ms); + *armed = true; + btstack_run_loop_add_timer(timer); +} + +void schedule_lifecycle(uint64_t deadline_us) { + g_lifecycle_due_us = deadline_us; + schedule_timer(&g_lifecycle_timer, &g_lifecycle_armed, deadline_us); +} + +void finish(HapticsExperimentState state, uint8_t error) { + haptics_transport_probe_end(); + cancel_timer(&g_cadence_timer, &g_cadence_armed); + cancel_timer(&g_lifecycle_timer, &g_lifecycle_armed); + g_send_requested = false; + g_diagnostics.elapsed_us = + static_cast(time_us_64() - g_start_us); + g_diagnostics.state = state; + if (error != kNoError) { + g_diagnostics.last_error = error; + } + g_phase = Phase::kIdle; + g_connection = {}; + publish(true); +} + +void account_wait(uint64_t now_us) { + update_max(&g_diagnostics.max_request_wait_us, + static_cast(now_us - g_request_us)); +} + +void timeout_drain() { + if (g_send_requested) { + account_wait(time_us_64()); + } + ++g_diagnostics.send_failures; + g_diagnostics.last_error = kTimeout; + // The outstanding BTstack notification cannot be canceled. Stop accepting + // it as PCM permission; the normal FIFO may use it for compatibility output. + g_send_requested = false; + restore_compatibility(HapticsExperimentState::kError); +} + +void begin_drain(HapticsExperimentState state, uint64_t deadline_us) { + cancel_timer(&g_cadence_timer, &g_cadence_armed); + g_phase = Phase::kDrain; + g_finish_state = state; + schedule_lifecycle(deadline_us); + if (time_us_64() >= deadline_us) { + timeout_drain(); + } else if (!g_send_requested) { + request_send(); + } +} + +void end_pattern() { + g_diagnostics.skipped_packets += kPackets - g_next_packet; + g_next_packet = kPackets; + if (!g_send_requested && g_diagnostics.sent_packets != 0 && + g_last_was_silence) { + restore_compatibility(HapticsExperimentState::kCompleted); + } else { + // Do not let a never-delivered CAN_SEND_NOW strand ownership forever. + begin_drain(HapticsExperimentState::kCompleted, + g_end_us + kDrainTimeoutUs); + } +} + +void restore_compatibility(HapticsExperimentState state) { + cancel_timer(&g_cadence_timer, &g_cadence_armed); + g_send_requested = false; + g_phase = Phase::kRestore; + g_finish_state = g_diagnostics.send_failures != 0 + ? HapticsExperimentState::kError + : state; + // duration=0 is a no-op when the parser already believes rumble is off. + // Force the HAPTICS_SELECT / compatible-vibration report with zero motors. + // Set the phase first: synchronous notifications here belong to that FIFO. + g_connection.device->report_parser.play_dual_rumble( + g_connection.device, 0, 1, 0, 0); + // Immediately cancel the newly installed parser timer and emit its zero + // stop. Upstream device deletion does not remove private parser timers. + // No host effect can interleave between these two calls on the BT core. + g_connection.device->report_parser.play_dual_rumble( + g_connection.device, 0, 0, 0, 0); + const uint64_t now_us = time_us_64(); + g_restore_deadline_us = now_us + kDrainTimeoutUs; + schedule_lifecycle(now_us + kRestoreSettleUs); + publish(); +} + +void request_send() { + if (g_send_requested || g_request_in_progress || + (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { + return; + } + g_request_us = time_us_64(); + g_send_requested = true; + g_request_in_progress = true; + ++g_diagnostics.can_send_requests; + const uint8_t status = + l2cap_request_can_send_now_event(g_connection.cid); + g_request_in_progress = false; + if (status == ERROR_CODE_SUCCESS && !g_send_requested) { + // The synchronous callback already published its result and armed the + // next deadline. Do not touch that timer or copy its snapshot again. + return; + } + if (status != ERROR_CODE_SUCCESS && g_send_requested) { + g_send_requested = false; + ++g_diagnostics.send_failures; + g_diagnostics.last_error = kTransport; + // A failed request is not permission. Retry on a future deadline, not + // through a recursive callback or a tight loop. + if (g_phase == Phase::kPattern) { + const uint64_t now_us = time_us_64(); + if (now_us >= g_end_us) { + end_pattern(); + } else { + const uint32_t current = static_cast( + ((now_us - g_start_us) * kPacketDenominator) / + kPacketNumeratorUs); + g_diagnostics.skipped_packets += current + 1 - g_next_packet; + g_next_packet = current + 1; + if (g_next_packet < kPackets) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } + } + } else if (g_phase == Phase::kDrain) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + time_us_64() + 21334); + } + } + publish(); +} + +// Returns whether the block contains a scheduled tone (not actuator evidence). +bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet, + bool silence) { + for (uint16_t i = 0; i < kReportBytes; ++i) { + report[i] = 0; + } + report[0] = 0xa2; + report[1] = 0x32; + uint16_t sample_offset; + uint32_t frames; + if (g_diagnostics.sent_packets == 0) { + // Explicitly leave compatibility mode with a sized 0x10 state block. + // All other state-write flags remain clear, preserving other outputs. + report[3] = 0x90; + report[4] = 63; + report[68] = 0x92; + report[69] = 64; + sample_offset = 70; + frames = 32; // Initial mode handoff occupies the first silent interval. + } else { + // Compact 0x11: mic disabled, buffer length and sample-block counter. + // Two 64-byte blocks fit in 0x32 with these reference-supported fields. + report[3] = 0x91; + report[4] = 3; + report[5] = 0x62; + report[6] = 16; + report[7] = static_cast(g_diagnostics.sent_packets * 2); + report[8] = 0xd2; // Double-sized 0x12: genuinely two 64-byte blocks. + report[9] = 64; + sample_offset = 10; + frames = 64; + } + bool tone = false; + if (!silence && packet >= kPrimingPackets && packet < kToneEndPacket) { + const uint32_t relative = packet - kPrimingPackets; + const uint32_t phase = (relative / kPhasePackets) % 4; + if (phase == 0 || phase == 2) { + tone = true; + const uint32_t stride = phase == 0 ? 1 : 2; + uint32_t wave = ((relative % kPhasePackets) * 64 * stride) % 30; + const uint32_t channel = phase == 0 ? 0 : 1; + for (uint32_t frame = 0; frame < frames; ++frame) { + report[sample_offset + frame * 2 + channel] = + static_cast(kSine[wave]); + wave += stride; + if (wave >= 30) { + wave -= 30; + } + } + } + } + // Bluetooth CRC includes the A2 transaction byte and excludes only CRC. + uint32_t crc = 0xffffffffu; + for (uint16_t i = 0; i < kCrcOffset; ++i) { + crc ^= report[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); + } + } + crc = ~crc; + for (uint8_t byte = 0; byte < 4; ++byte) { + report[kCrcOffset + byte] = static_cast(crc >> (byte * 8)); + } + return tone; +} + +void cadence_timer(btstack_timer_source_t*) { + g_cadence_armed = false; + if (!connection_current()) { + if (g_phase != Phase::kIdle) { + finish(HapticsExperimentState::kDisconnected, kConnection); + } + return; + } + const uint64_t now_us = time_us_64(); + if (g_phase == Phase::kPattern) { + const uint64_t due_us = packet_due(g_next_packet); + haptics_transport_probe_timer( + now_us > due_us ? static_cast(now_us - due_us) : 0); + } + if (g_phase == Phase::kPattern) { + if (now_us >= g_end_us) { + end_pattern(); + } else if (now_us < packet_due(g_next_packet)) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } else { + request_send(); + } + } else if (g_phase == Phase::kDrain) { + if (now_us >= g_lifecycle_due_us) { + timeout_drain(); + } else { + request_send(); + } + } +} + +void lifecycle_timer(btstack_timer_source_t*) { + g_lifecycle_armed = false; + if (!connection_current()) { + if (g_phase != Phase::kIdle) { + finish(HapticsExperimentState::kDisconnected, kConnection); + } + return; + } + if (time_us_64() < g_lifecycle_due_us) { + schedule_lifecycle(g_lifecycle_due_us); + return; + } + if (g_phase == Phase::kPattern) { + end_pattern(); + } else if (g_phase == Phase::kDrain) { + timeout_drain(); + } else if (g_phase == Phase::kRestore) { + if (uni_circular_buffer_is_empty(&g_connection.device->outgoing_buffer)) { + finish(g_finish_state, kNoError); + } else if (time_us_64() < g_restore_deadline_us) { + schedule_lifecycle(g_restore_deadline_us); + } else { + ++g_diagnostics.send_failures; + finish(HapticsExperimentState::kError, kTimeout); + } + } +} + +void start(const Command& command) { + g_diagnostics = {}; + g_diagnostics.run_id = command.run_id; + g_diagnostics.slot = command.slot; + g_connection = command.connection; + g_diagnostics.connection_generation = g_connection.generation; + g_start_us = time_us_64(); + g_diagnostics.start_us = static_cast(g_start_us); + uni_hid_device_t* device = g_connection.device; + const Attachment& attached = g_attachments[command.slot]; + const bool current = device != nullptr && attached.device == device && + attached.generation == g_connection.generation && + attached.cid == g_connection.cid; + haptics_transport_probe_begin( + command.run_id, g_connection.generation, + current ? device->conn.handle : 0xffff); + if (!current) { + finish(HapticsExperimentState::kDisconnected, kConnection); + return; + } + if (device->vendor_id != 0x054c || + (device->product_id != 0x0ce6 && device->product_id != 0x0df2) || + gap_get_connection_type(device->conn.handle) != GAP_CONNECTION_ACL || + device->report_parser.play_dual_rumble == nullptr) { + finish(HapticsExperimentState::kUnsupported, kUnsupported); + return; + } + if (g_connection.cid == 0 || !device->conn.connected || + device->conn.interrupt_cid != g_connection.cid) { + finish(HapticsExperimentState::kDisconnected, kConnection); + return; + } + if (l2cap_get_remote_mtu_for_local_cid(g_connection.cid) < kReportBytes) { + finish(HapticsExperimentState::kUnsupported, kMtu); + return; + } + // Never discard unrelated LED/control reports or allow them to switch the + // controller back to compatibility midstream. A queued start is retryable + // once the ordinary sender has drained it. + if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + finish(HapticsExperimentState::kError, kQueuedOutput); + return; + } + // Cancel any existing parser duration/delayed-start timer before taking + // over. In the already-disabled case this deliberately emits no report. + device->report_parser.play_dual_rumble(device, 0, 0, 0, 0); + if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + finish(HapticsExperimentState::kError, kQueuedOutput); + return; + } + g_start_us = time_us_64(); + g_end_us = g_start_us + 6144000; + g_diagnostics.start_us = static_cast(g_start_us); + g_diagnostics.first_tone_due_us = + static_cast(packet_due(kPrimingPackets)); + g_diagnostics.state = HapticsExperimentState::kRunning; + g_phase = Phase::kPattern; + g_next_packet = 0; + g_send_requested = false; + g_last_was_silence = true; + g_first_tone_sent = false; + btstack_run_loop_set_timer_handler(&g_cadence_timer, cadence_timer); + btstack_run_loop_set_timer_handler(&g_lifecycle_timer, lifecycle_timer); + schedule_lifecycle(g_end_us); + request_send(); +} + +} // namespace + +void haptics_experiment_prepare() { + if (!g_prepared) { + critical_section_init(&g_lock); + haptics_transport_probe_prepare(); + g_prepared = true; + } +} + +bool haptics_experiment_request(uint8_t action, uint8_t slot) { + if (action > 1 || slot >= 4) { + return false; + } + critical_section_enter_blocking(&g_lock); + bool accepted = true; + if (action == 1) { + if (g_busy) { + accepted = false; + } else { + const uint32_t run_id = g_snapshot.run_id + 1; + g_snapshot = {}; + g_snapshot.run_id = run_id; + g_snapshot.slot = slot; + g_snapshot.connection_generation = g_attachments[slot].generation; + g_snapshot.state = HapticsExperimentState::kPending; + g_snapshot_waiting = false; + g_busy = true; + g_command = {true, action, slot, run_id, g_attachments[slot]}; + } + } else if (g_busy) { + if (slot != g_snapshot.slot) { + accepted = false; + } else { + // Replaces even an unconsumed start, without a FIFO of commands. + g_command = { + true, action, slot, g_snapshot.run_id, + {nullptr, g_snapshot.connection_generation, 0}}; + } + } + critical_section_exit(&g_lock); + return accepted; +} + +void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_snapshot; + const bool waiting = g_snapshot_waiting; + const uint32_t requested_us = g_snapshot_request_us; + critical_section_exit(&g_lock); + if (output->state == HapticsExperimentState::kRunning) { + const uint32_t now_us = static_cast(time_us_64()); + output->elapsed_us = now_us - output->start_us; + if (waiting) { + update_max(&output->max_request_wait_us, now_us - requested_us); + } + } +} + +void haptics_experiment_attach(uint8_t slot, uint32_t generation, + uni_hid_device_t* device) { + if (slot >= 4 || device == nullptr) { + return; + } + const uint16_t cid = device->conn.interrupt_cid; + if (g_phase != Phase::kIdle && + (g_diagnostics.slot == slot || g_connection.device == device) && + (g_diagnostics.slot != slot || g_connection.device != device || + g_connection.generation != generation || g_connection.cid != cid)) { + finish(HapticsExperimentState::kDisconnected, kConnection); + } + // A reused instance must not remain selectable through an old slot. + critical_section_enter_blocking(&g_lock); + for (Attachment& attached : g_attachments) { + if (attached.device == device) { + attached = {}; + } + } + g_attachments[slot] = {device, generation, cid}; + critical_section_exit(&g_lock); +} + +void haptics_experiment_detach(uni_hid_device_t* device) { + if (device == nullptr) { + return; + } + if (g_phase != Phase::kIdle && g_connection.device == device) { + // Do not dereference the device or send restoration on a dead link. + finish(HapticsExperimentState::kDisconnected, kConnection); + } + critical_section_enter_blocking(&g_lock); + for (Attachment& attached : g_attachments) { + if (attached.device == device) { + attached = {}; + } + } + critical_section_exit(&g_lock); +} + +void haptics_experiment_poll() { + critical_section_enter_blocking(&g_lock); + const Command command = g_command; + g_command.pending = false; + critical_section_exit(&g_lock); + if (!command.pending) { + return; + } + if (command.action == 1) { + start(command); + } else if (g_phase != Phase::kIdle && + g_diagnostics.run_id == command.run_id) { + if (g_phase == Phase::kRestore) { + if (g_finish_state != HapticsExperimentState::kError) { + g_finish_state = HapticsExperimentState::kStopped; + } + } else if (g_phase != Phase::kDrain || + g_finish_state != HapticsExperimentState::kStopped) { + begin_drain(HapticsExperimentState::kStopped, + time_us_64() + kDrainTimeoutUs); + } + } else { + // Stop preempted a start still in the mailbox, or raced completion. + if (g_diagnostics.run_id != command.run_id) { + g_diagnostics = {}; + g_diagnostics.run_id = command.run_id; + g_diagnostics.slot = command.slot; + g_diagnostics.connection_generation = command.connection.generation; + g_diagnostics.state = HapticsExperimentState::kStopped; + haptics_transport_probe_begin( + command.run_id, command.connection.generation, 0xffff); + haptics_transport_probe_end(); + } + publish(true); + } +} + +bool haptics_experiment_owns(const uni_hid_device_t* device) { + return device != nullptr && device == g_connection.device && + connection_current(); +} + +bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, + uint16_t cid) { + if (device != g_connection.device || !connection_current() || + (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { + return false; + } + // The generic FIFO is device-wide, not CID-specific. Consume control-CID + // and unsolicited events too, without treating them as PCM permission. + if (cid != g_connection.cid || !g_send_requested || g_in_callback) { + return true; + } + g_send_requested = false; + g_in_callback = true; + if (g_request_in_progress) { + ++g_diagnostics.synchronous_callbacks; + } + const uint64_t now_us = time_us_64(); + account_wait(now_us); + haptics_transport_probe_permission( + static_cast(now_us - g_request_us)); + if (g_phase == Phase::kPattern && now_us >= g_end_us) { + g_diagnostics.skipped_packets += kPackets - g_next_packet; + g_next_packet = kPackets; + g_phase = Phase::kDrain; + g_finish_state = HapticsExperimentState::kCompleted; + schedule_lifecycle(g_end_us + kDrainTimeoutUs); + } + if (g_phase == Phase::kDrain && now_us >= g_lifecycle_due_us) { + timeout_drain(); + g_in_callback = false; + return true; + } + if (g_phase == Phase::kPattern && now_us < packet_due(g_next_packet)) { + // Notifications are not reservations of credit for a future deadline. + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + g_in_callback = false; + publish(); + return true; + } + const bool stopping = g_phase == Phase::kDrain; + uint64_t due_us = now_us; + if (!stopping) { + const uint32_t current = static_cast( + ((now_us - g_start_us) * kPacketDenominator) / kPacketNumeratorUs); + g_diagnostics.skipped_packets += current - g_next_packet; + g_next_packet = current; + due_us = packet_due(current); + } + uint8_t report[kReportBytes]; + const uint64_t generate_start_us = time_us_64(); + const bool tone = generate_packet(report, g_next_packet, stopping); + const uint64_t submit_us = time_us_64(); + update_max(&g_diagnostics.max_generate_us, + static_cast(submit_us - generate_start_us)); + ++g_diagnostics.generated_packets; + update_max(&g_diagnostics.max_lateness_us, + static_cast(submit_us - due_us)); + if (!stopping && submit_us >= packet_due(g_next_packet + 1)) { + // A flash/interrupt stall can occur during synthesis as well as before + // CAN_SEND_NOW. Never submit a now-obsolete tone after its phase ended. + ++g_diagnostics.skipped_packets; + ++g_next_packet; + if (g_next_packet < kPackets) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } + g_in_callback = false; + publish(); + return true; + } + if (stopping && submit_us >= g_lifecycle_due_us) { + timeout_drain(); + g_in_callback = false; + return true; + } + const uint64_t send_started_us = time_us_64(); + const uint8_t status = l2cap_send(cid, report, sizeof(report)); + const uint64_t send_returned_us = time_us_64(); + haptics_transport_probe_send( + static_cast(send_returned_us - send_started_us), + static_cast(send_returned_us), + tone && !g_first_tone_sent && status == ERROR_CODE_SUCCESS); + if (!connection_current()) { + // A transport may synchronously report teardown. Detach already + // published the terminal state; do not rearm a timer on its old CID. + g_in_callback = false; + return true; + } + if (status == ERROR_CODE_SUCCESS) { + if (g_diagnostics.sent_packets != 0) { + update_max(&g_diagnostics.max_send_gap_us, + static_cast(submit_us) - g_diagnostics.last_sent_us); + } + ++g_diagnostics.sent_packets; + g_diagnostics.last_sent_us = static_cast(submit_us); + g_last_was_silence = !tone; + if (tone && !g_first_tone_sent) { + g_first_tone_sent = true; + g_diagnostics.first_tone_sent_us = static_cast(submit_us); + } + } else { + ++g_diagnostics.send_failures; + g_diagnostics.last_error = kTransport; + } + if (stopping) { + if (status == ERROR_CODE_SUCCESS) { + restore_compatibility(g_finish_state); + } else { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + time_us_64() + 21334); + } + } else { + ++g_next_packet; + if (g_next_packet < kPackets) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } + } + g_in_callback = false; + publish(); + return true; +} diff --git a/src/firmware/input/haptics_experiment.h b/src/firmware/input/haptics_experiment.h new file mode 100644 index 0000000..21001ff --- /dev/null +++ b/src/firmware/input/haptics_experiment.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +struct uni_hid_device_s; +typedef struct uni_hid_device_s uni_hid_device_t; + +enum class HapticsExperimentState : uint8_t { + kIdle = 0, + kPending = 1, + kRunning = 2, + kCompleted = 3, + kStopped = 4, + kDisconnected = 5, + kUnsupported = 6, + kError = 7, +}; + +struct HapticsExperimentDiagnostics { + uint32_t run_id = 0; + uint32_t connection_generation = 0; + uint32_t start_us = 0; + uint32_t generated_packets = 0; + uint32_t sent_packets = 0; + uint32_t skipped_packets = 0; + uint32_t send_failures = 0; + uint32_t can_send_requests = 0; + uint32_t synchronous_callbacks = 0; + uint32_t max_generate_us = 0; + uint32_t max_send_gap_us = 0; + uint32_t max_lateness_us = 0; + uint32_t max_request_wait_us = 0; + uint32_t first_tone_due_us = 0; + uint32_t first_tone_sent_us = 0; + uint32_t last_sent_us = 0; + uint32_t elapsed_us = 0; + HapticsExperimentState state = HapticsExperimentState::kIdle; + uint8_t slot = 0xff; + uint8_t last_error = 0; +}; + +// Core 0 before launching BTstack; request/snapshot are cross-core safe. +void haptics_experiment_prepare(); +bool haptics_experiment_request(uint8_t action, uint8_t slot); +void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output); + +// Core 1 / BTstack only. Poll consumes management requests, not PCM cadence. +void haptics_experiment_attach(uint8_t slot, uint32_t generation, + uni_hid_device_t* device); +void haptics_experiment_detach(uni_hid_device_t* device); +void haptics_experiment_poll(); +bool haptics_experiment_owns(const uni_hid_device_t* device); +bool haptics_experiment_on_can_send_now(uni_hid_device_t* device, + uint16_t cid); diff --git a/src/firmware/input/haptics_transport_probe.cpp b/src/firmware/input/haptics_transport_probe.cpp new file mode 100644 index 0000000..590efba --- /dev/null +++ b/src/firmware/input/haptics_transport_probe.cpp @@ -0,0 +1,325 @@ +#include "input/haptics_transport_probe.h" + +#include +#include +#include + +extern "C" { +#include + +// Derive the declarations from the SDK: in particular write uses size_t, +// whereas read's capacity and output length are uint32_t even on a 64-bit host. +decltype(cyw43_bluetooth_hci_write) __real_cyw43_bluetooth_hci_write; +decltype(cyw43_bluetooth_hci_write) __wrap_cyw43_bluetooth_hci_write; +decltype(cyw43_bluetooth_hci_read) __real_cyw43_bluetooth_hci_read; +decltype(cyw43_bluetooth_hci_read) __wrap_cyw43_bluetooth_hci_read; +decltype(btstack_run_loop_base_poll_data_sources) + __real_btstack_run_loop_base_poll_data_sources; +decltype(btstack_run_loop_base_poll_data_sources) + __wrap_btstack_run_loop_base_poll_data_sources; +} + +namespace { + +critical_section_t g_lock; +HapticsTransportProbe g_probe; +bool g_prepared = false; + +// Only the snapshot crosses cores. All other state, including wrapper entry, +// belongs to core 1; the lock never spans SDK calls or event delivery. +uint64_t g_epoch = 0; +bool g_registered = false; +bool g_credit_sampled = false; +bool g_tone_recorded = false; +bool g_have_completion = false; +bool g_have_poll = false; +uint32_t g_last_completion_us = 0; +uint32_t g_last_poll_us = 0; +bool g_in_write = false; +bool g_in_read = false; +bool g_in_poll = false; +bool g_receive_work = false; +uint16_t g_controller_acl_bytes = 0; +uint16_t g_controller_acl_count = 0; +btstack_packet_callback_registration_t g_registration{}; + +uint32_t now_us() { + return static_cast(time_us_64()); +} + +void add(uint32_t& total, uint32_t value) { + total = value > UINT32_MAX - total ? UINT32_MAX : total + value; +} + +void maximum(uint32_t& peak, uint32_t value) { + if (value > peak) peak = value; +} + +void duration(uint32_t& calls, uint32_t& peak, uint32_t& total, + uint32_t elapsed) { + add(calls, 1); + maximum(peak, elapsed); + add(total, elapsed); +} + +bool current(uint64_t epoch) { + return g_probe.active != 0 && g_epoch == epoch; +} + +void sample_credits(uint64_t epoch) { + if (!current(epoch) || g_probe.connection_handle > 0x0fff) return; + const auto handle = static_cast(g_probe.connection_handle); + // SDK lookup is fresh each time: never retain a connection across I/O or + // callbacks, which can synchronously disconnect and free it. + const hci_connection_t* connection = hci_connection_for_handle(handle); + if (connection == nullptr) return; + const uint32_t outstanding = connection->num_packets_sent; + const int free_slots = hci_number_free_acl_slots_for_handle(handle); + if (!current(epoch) || free_slots < 0) return; + critical_section_enter_blocking(&g_lock); + maximum(g_probe.max_outstanding_acl, outstanding); + const auto available = static_cast(free_slots); + if (!g_credit_sampled || available < g_probe.min_free_acl) { + g_probe.min_free_acl = available; + } + g_credit_sampled = true; + critical_section_exit(&g_lock); +} + +// Suppress only same-boundary recursive measurement, not its real call. Poll, +// read, write and sender totals are inclusive and deliberately overlap. Epoch +// checks discard a call that spans finish or a new begin, even with reused IDs. +struct Call { + bool& entered; + const bool outer; + const uint64_t epoch; + const bool measured; + uint32_t start_us = 0; + + explicit Call(bool& guard) + : entered(guard), outer(!guard), epoch(g_epoch), + measured(outer && g_probe.active != 0) { + entered = true; + if (measured) { + sample_credits(epoch); + start_us = now_us(); + } + } + + ~Call() { + if (outer) entered = false; + } + + bool live() const { + return measured && current(epoch); + } +}; + +uint16_t read_le16(const uint8_t* bytes) { + return static_cast(bytes[0] | (uint16_t{bytes[1]} << 8)); +} +void capture_controller_capacity(const uint8_t* buffer, uint32_t length) { + // CYW43's four-byte header precedes HCI Command Complete (Read Buffer Size). + // Capture the raw response before BTstack applies its software buffer cap. + if (length < 17 || buffer[3] != HCI_EVENT_PACKET || buffer[4] != 0x0e || + buffer[5] != 11 || buffer[7] != 0x05 || buffer[8] != 0x10 || + buffer[9] != 0) { + return; + } + g_controller_acl_bytes = read_le16(buffer + 10); + g_controller_acl_count = read_le16(buffer + 13); + if (g_prepared) { + critical_section_enter_blocking(&g_lock); + g_probe.controller_acl_packet_bytes = g_controller_acl_bytes; + g_probe.controller_acl_packet_count = g_controller_acl_count; + critical_section_exit(&g_lock); + } +} + + +void handle_event(uint8_t packet_type, uint16_t, uint8_t* packet, + uint16_t size) { + if (!g_probe.active || packet_type != HCI_EVENT_PACKET || packet == nullptr || + size < 3 || packet[0] != HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS || + size != uint16_t{packet[1]} + 2u || + size != 3u + uint16_t{packet[2]} * 4u || + g_probe.connection_handle > 0x0fff) { + return; + } + uint32_t completed = 0; + bool selected = false; + for (uint16_t offset = 3; offset < size; offset += 4) { + if ((read_le16(packet + offset) & 0x0fff) == g_probe.connection_handle) { + selected = true; + completed += read_le16(packet + offset + 2); + } + } + if (!selected) return; + const uint64_t epoch = g_epoch; + const uint32_t timestamp = now_us(); + sample_credits(epoch); + if (!current(epoch)) return; + critical_section_enter_blocking(&g_lock); + add(g_probe.completion_events, 1); + add(g_probe.completed_packets, completed); + // Gap between selected-handle events, not time from run start to first. + if (g_have_completion) { + maximum(g_probe.max_completion_gap_us, timestamp - g_last_completion_us); + } + g_have_completion = true; + g_last_completion_us = timestamp; + critical_section_exit(&g_lock); + // BTstack updates credit accounting before delivering this event. These + // samples and the surrounding I/O samples are observed extrema only. + sample_credits(epoch); +} + +} // namespace + +void haptics_transport_probe_prepare() { + if (!g_prepared) { + critical_section_init(&g_lock); + g_prepared = true; + } +} + +void haptics_transport_probe_snapshot(HapticsTransportProbe* output) { + if (output == nullptr) return; + critical_section_enter_blocking(&g_lock); + *output = g_probe; + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_begin(uint32_t run_id, uint32_t generation, + uint16_t handle) { + ++g_epoch; + critical_section_enter_blocking(&g_lock); + g_probe = {}; + g_probe.run_id = run_id; + g_probe.connection_generation = generation; + g_probe.connection_handle = handle; + g_probe.controller_acl_packet_bytes = g_controller_acl_bytes; + g_probe.controller_acl_packet_count = g_controller_acl_count; + g_probe.active = 1; + g_credit_sampled = false; + g_tone_recorded = false; + g_have_completion = false; + g_have_poll = false; + critical_section_exit(&g_lock); + if (!g_registered) { + g_registered = true; + g_registration.callback = handle_event; + hci_add_event_handler(&g_registration); + } + sample_credits(g_epoch); +} + +void haptics_transport_probe_end() { + sample_credits(g_epoch); + critical_section_enter_blocking(&g_lock); + g_probe.active = 0; + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_timer(uint32_t lateness_us) { + if (!g_probe.active) return; + critical_section_enter_blocking(&g_lock); + duration(g_probe.timer_wakes, g_probe.max_timer_lateness_us, + g_probe.total_timer_lateness_us, lateness_us); + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_permission(uint32_t wait_us) { + if (!g_probe.active) return; + sample_credits(g_epoch); + critical_section_enter_blocking(&g_lock); + duration(g_probe.permission_callbacks, g_probe.max_permission_wait_us, + g_probe.total_permission_wait_us, wait_us); + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_send(uint32_t duration_us, uint32_t return_us, + bool first_tone_success) { + if (!g_probe.active) return; + critical_section_enter_blocking(&g_lock); + duration(g_probe.send_calls, g_probe.max_send_us, g_probe.total_send_us, + duration_us); + if (first_tone_success && !g_tone_recorded) { + g_probe.first_tone_send_return_us = return_us; + g_tone_recorded = true; + } + critical_section_exit(&g_lock); + sample_credits(g_epoch); +} + +extern "C" int __wrap_cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) { + Call call(g_in_write); + const int result = __real_cyw43_bluetooth_hci_write(buffer, length); + if (call.live()) { + const uint32_t elapsed = now_us() - call.start_us; + sample_credits(call.epoch); + if (call.live()) { + critical_section_enter_blocking(&g_lock); + duration(g_probe.write_calls, g_probe.max_write_us, + g_probe.total_write_us, elapsed); + critical_section_exit(&g_lock); + } + } + return result; +} + +extern "C" int __wrap_cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, + uint32_t* length) { + Call call(g_in_read); + const int result = __real_cyw43_bluetooth_hci_read(buffer, capacity, length); + if (result == 0 && length != nullptr && *length <= capacity && buffer != nullptr) { + capture_controller_capacity(buffer, *length); + } + // A nonempty read may have left more input queued. The experiment limits + // each transport poll to one packet so timers can run between packets. + g_receive_work = result == 0 && length != nullptr && *length > 0; + if (call.live()) { + const uint32_t elapsed = now_us() - call.start_us; + // Match the transport's successful/nonempty read criterion. An error + // may leave length untouched, so never inspect it on an error return. + const bool packet = result == 0 && length != nullptr && *length > 0; + sample_credits(call.epoch); + if (call.live()) { + critical_section_enter_blocking(&g_lock); + duration(g_probe.read_calls, g_probe.max_read_us, + g_probe.total_read_us, elapsed); + if (packet) add(g_probe.read_packets, 1); + critical_section_exit(&g_lock); + } + } + return result; +} + +extern "C" void __wrap_btstack_run_loop_base_poll_data_sources() { + Call call(g_in_poll); + const bool have_previous = g_have_poll; + const uint32_t gap = call.start_us - g_last_poll_us; + if (call.measured) { + g_have_poll = true; + g_last_poll_us = call.start_us; + } + if (call.outer) g_receive_work = false; + __real_btstack_run_loop_base_poll_data_sources(); + if (call.live()) { + const uint32_t elapsed = now_us() - call.start_us; + sample_credits(call.epoch); + if (call.live()) { + critical_section_enter_blocking(&g_lock); + duration(g_probe.poll_calls, g_probe.max_poll_us, + g_probe.total_poll_us, elapsed); + if (have_previous) maximum(g_probe.max_poll_gap_us, gap); + critical_section_exit(&g_lock); + } + } + if (call.outer && g_receive_work) { + // Mark future work; never recursively drain from the current callback. + // This also runs while measurements are inactive so input cannot be + // stranded waiting for a new hardware IRQ edge after a bounded poll. + btstack_run_loop_poll_data_sources_from_irq(); + } +} diff --git a/src/firmware/input/haptics_transport_probe.h b/src/firmware/input/haptics_transport_probe.h new file mode 100644 index 0000000..41e3951 --- /dev/null +++ b/src/firmware/input/haptics_transport_probe.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +// Operation 0x41, schema 2: 32 little-endian u32 fields in declaration order. +// All durations are host-side microseconds, not radio/actuator latency. +struct HapticsTransportProbe { + uint32_t run_id = 0; + uint32_t connection_generation = 0; + uint32_t connection_handle = 0xffff; + uint32_t timer_wakes = 0; + uint32_t max_timer_lateness_us = 0; + uint32_t total_timer_lateness_us = 0; + uint32_t send_calls = 0; + uint32_t max_send_us = 0; + uint32_t total_send_us = 0; + uint32_t write_calls = 0; + uint32_t max_write_us = 0; + uint32_t total_write_us = 0; + uint32_t read_calls = 0; + uint32_t read_packets = 0; + uint32_t max_read_us = 0; + uint32_t total_read_us = 0; + uint32_t poll_calls = 0; + uint32_t max_poll_us = 0; + uint32_t total_poll_us = 0; + uint32_t completion_events = 0; + uint32_t completed_packets = 0; + uint32_t max_completion_gap_us = 0; + uint32_t max_outstanding_acl = 0; + uint32_t min_free_acl = 0; + uint32_t first_tone_send_return_us = 0; + uint32_t active = 0; + uint32_t max_permission_wait_us = 0; + uint32_t total_permission_wait_us = 0; + uint32_t permission_callbacks = 0; + uint32_t max_poll_gap_us = 0; + uint32_t controller_acl_packet_bytes = 0; + uint32_t controller_acl_packet_count = 0; +}; + +// Prepare before core 1 starts. Snapshot alone is called from USB/core 0. +void haptics_transport_probe_prepare(); +void haptics_transport_probe_snapshot(HapticsTransportProbe* output); +// Remaining API belongs to core 1. No probe lock may span a stack call. +void haptics_transport_probe_begin(uint32_t run_id, uint32_t generation, + uint16_t handle); +void haptics_transport_probe_end(); +void haptics_transport_probe_timer(uint32_t lateness_us); +void haptics_transport_probe_permission(uint32_t wait_us); +void haptics_transport_probe_send(uint32_t duration_us, + uint32_t return_us, bool first_tone_success); diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 4d3917c..824b4c7 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -5,6 +5,10 @@ #include "adapter/adapter_host_probe.h" #include "adapter/adapter_reboot.h" #include "adapter/adapter_usb_mode.h" +#include "input/haptics_experiment.h" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/haptics_transport_probe.h" +#endif #include "tusb.h" #include "usb/usb_output_driver.h" @@ -110,6 +114,12 @@ bool valid_out_size(Operation operation, size_t size) { case Operation::kPairingRefresh: case Operation::kPairingClear: return size == kRequestHeaderSize; + case Operation::kHapticsExperiment: +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + return size == kRequestHeaderSize + 2; +#else + return false; +#endif default: return false; } @@ -190,6 +200,89 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { output_size); } +size_t encode_haptics_experiment(uint8_t* output, size_t output_size) { + HapticsExperimentDiagnostics diagnostics{}; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_snapshot(&diagnostics); +#else + diagnostics.state = HapticsExperimentState::kUnsupported; +#endif + uint8_t payload[kHapticsExperimentPayloadSize]{}; + write_u32(&payload[0], diagnostics.run_id); + write_u32(&payload[4], diagnostics.connection_generation); + write_u32(&payload[8], diagnostics.start_us); + write_u32(&payload[12], diagnostics.generated_packets); + write_u32(&payload[16], diagnostics.sent_packets); + write_u32(&payload[20], diagnostics.skipped_packets); + write_u32(&payload[24], diagnostics.send_failures); + write_u32(&payload[28], diagnostics.can_send_requests); + write_u32(&payload[32], diagnostics.synchronous_callbacks); + write_u32(&payload[36], diagnostics.max_generate_us); + write_u32(&payload[40], diagnostics.max_send_gap_us); + write_u32(&payload[44], diagnostics.max_lateness_us); + write_u32(&payload[48], diagnostics.max_request_wait_us); + write_u32(&payload[52], diagnostics.first_tone_due_us); + write_u32(&payload[56], diagnostics.first_tone_sent_us); + write_u32(&payload[60], diagnostics.last_sent_us); + write_u32(&payload[64], diagnostics.elapsed_us); + payload[68] = static_cast(diagnostics.state); + payload[69] = diagnostics.slot; + payload[70] = diagnostics.last_error; + return encode_response( + Operation::kHapticsExperiment, Status::kOk, 0, + kHapticsExperimentSchemaVersion, diagnostics.run_id, + payload, sizeof(payload), output, output_size); +} + +size_t encode_haptics_transport_probe(uint8_t* output, size_t output_size) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + HapticsTransportProbe probe{}; + haptics_transport_probe_snapshot(&probe); + uint8_t payload[kHapticsTransportProbePayloadSize]{}; + write_u32(&payload[0], probe.run_id); + write_u32(&payload[4], probe.connection_generation); + write_u32(&payload[8], probe.connection_handle); + write_u32(&payload[12], probe.timer_wakes); + write_u32(&payload[16], probe.max_timer_lateness_us); + write_u32(&payload[20], probe.total_timer_lateness_us); + write_u32(&payload[24], probe.send_calls); + write_u32(&payload[28], probe.max_send_us); + write_u32(&payload[32], probe.total_send_us); + write_u32(&payload[36], probe.write_calls); + write_u32(&payload[40], probe.max_write_us); + write_u32(&payload[44], probe.total_write_us); + write_u32(&payload[48], probe.read_calls); + write_u32(&payload[52], probe.read_packets); + write_u32(&payload[56], probe.max_read_us); + write_u32(&payload[60], probe.total_read_us); + write_u32(&payload[64], probe.poll_calls); + write_u32(&payload[68], probe.max_poll_us); + write_u32(&payload[72], probe.total_poll_us); + write_u32(&payload[76], probe.completion_events); + write_u32(&payload[80], probe.completed_packets); + write_u32(&payload[84], probe.max_completion_gap_us); + write_u32(&payload[88], probe.max_outstanding_acl); + write_u32(&payload[92], probe.min_free_acl); + write_u32(&payload[96], probe.first_tone_send_return_us); + write_u32(&payload[100], probe.active); + write_u32(&payload[104], probe.max_permission_wait_us); + write_u32(&payload[108], probe.total_permission_wait_us); + write_u32(&payload[112], probe.permission_callbacks); + write_u32(&payload[116], probe.max_poll_gap_us); + write_u32(&payload[120], probe.controller_acl_packet_bytes); + write_u32(&payload[124], probe.controller_acl_packet_count); + return encode_response( + Operation::kHapticsTransportProbe, Status::kOk, 0, + kHapticsTransportProbeSchemaVersion, probe.run_id, + payload, sizeof(payload), output, output_size); +#else + return encode_response( + Operation::kHapticsTransportProbe, Status::kUnsupportedSchema, 0, + kHapticsTransportProbeSchemaVersion, 0, + nullptr, 0, output, output_size); +#endif +} + } // namespace bool decode_request(Operation setup_operation, const uint8_t* input, @@ -438,6 +531,7 @@ uint8_t g_request_buffer[ UsbConfigurationManagement::Operation g_pending_operation = UsbConfigurationManagement::Operation::kInfo; bool g_out_pending = false; +bool g_out_processed = false; size_t g_pending_request_size = 0; bool process_out_request() { @@ -665,6 +759,17 @@ bool process_out_request() { case Operation::kPairingClear: bluepad32_input_backend_clear_pairings(); return true; + case Operation::kHapticsExperiment: + if (request.payload_size != 2 || + payload[0] > 1 || + payload[1] >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { + return false; + } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + return haptics_experiment_request(payload[0], payload[1]); +#else + return false; +#endif default: return false; } @@ -675,6 +780,11 @@ bool process_out_request() { bool usb_configuration_management_vendor_control( uint8_t rhport, uint8_t stage, tusb_control_request_t const* request) { + if (stage == CONTROL_STAGE_SETUP) { + g_out_pending = false; + g_out_processed = false; + g_pending_request_size = 0; + } if (adapter_host_probe_vendor_control(rhport, stage, request)) { return true; } @@ -689,6 +799,10 @@ bool usb_configuration_management_vendor_control( const Operation operation = static_cast(request->bRequest); + if (operation == Operation::kHapticsTransportProbe && + request->bmRequestType_bit.direction != TUSB_DIR_IN) { + return false; + } if (stage == CONTROL_STAGE_ACK) { if (request->bmRequestType_bit.direction == TUSB_DIR_IN) { return true; @@ -697,9 +811,22 @@ bool usb_configuration_management_vendor_control( return false; } g_out_pending = false; + if (operation == Operation::kHapticsExperiment) { + return g_out_processed; + } return process_out_request(); } if (stage == CONTROL_STAGE_DATA) { + if (operation == Operation::kHapticsExperiment && + request->bmRequestType_bit.direction == TUSB_DIR_OUT) { + if (!g_out_pending || operation != g_pending_operation || + g_out_processed) { + return false; + } + // Reject before the USB status ACK, and never enqueue twice. + g_out_processed = process_out_request(); + return g_out_processed; + } return true; } if (stage != CONTROL_STAGE_SETUP) { @@ -713,6 +840,7 @@ bool usb_configuration_management_vendor_control( g_pending_operation = operation; g_pending_request_size = request->wLength; g_out_pending = true; + g_out_processed = false; return tud_control_xfer(rhport, request, g_request_buffer, request->wLength); } @@ -742,6 +870,14 @@ bool usb_configuration_management_vendor_control( response_size = encode_runtime_diagnostics(response, sizeof(response)); break; + case Operation::kHapticsExperiment: + response_size = + encode_haptics_experiment(response, sizeof(response)); + break; + case Operation::kHapticsTransportProbe: + response_size = + encode_haptics_transport_probe(response, sizeof(response)); + break; case Operation::kProfileList: { ProfileServiceListSnapshot snapshot{}; profile_service_list_snapshot(&snapshot); diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 04460fd..f5c1363 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -29,6 +29,10 @@ constexpr size_t kProfileMetadataPayloadSize = (CONTROLLER_PROFILE_COUNT + 1) * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); constexpr uint16_t kProfileMetadataSchemaVersion = 1; +constexpr uint16_t kHapticsExperimentSchemaVersion = 2; +constexpr size_t kHapticsExperimentPayloadSize = 72; +constexpr uint16_t kHapticsTransportProbeSchemaVersion = 2; +constexpr size_t kHapticsTransportProbePayloadSize = 128; constexpr size_t kMaximumResponseSize = kResponseHeaderSize + kProfileListPayloadSize; constexpr size_t kMaximumChunkSize = @@ -64,6 +68,8 @@ enum class Operation : uint8_t { kProfileMetadataRead = 0x3a, kProfileMetadataSet = 0x3b, kProfileIdentify = 0x3c, + kHapticsExperiment = 0x40, + kHapticsTransportProbe = 0x41, }; enum class Status : uint8_t { diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 0b5d31d..f6f4d19 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -6,13 +6,14 @@ from __future__ import annotations import argparse import importlib import json +import math import secrets import struct import sys import time import zlib from collections.abc import Iterable, Sequence -from dataclasses import dataclass +from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Protocol, cast @@ -63,9 +64,12 @@ OP_PROFILE_PLAYTEST = 0x39 OP_PROFILE_METADATA_READ = 0x3A OP_PROFILE_METADATA_SET = 0x3B OP_PROFILE_IDENTIFY = 0x3C +OP_HAPTICS_EXPERIMENT = 0x40 +OP_HAPTICS_TRANSPORT_PROBE = 0x41 STATUS_OK = 0 STATUS_PENDING = 1 +STATUS_UNSUPPORTED_SCHEMA = 3 STATUS_NAMES = { 2: "malformed request", 3: "unsupported schema", @@ -131,6 +135,63 @@ PROFILE_METADATA_SCHEMA_VERSION = 1 PROFILE_METADATA_MAX_BYTES = 31 PROFILE_METADATA_VALUE_SIZE = 32 PROFILE_METADATA_SIZE = 288 +HAPTICS_EXPERIMENT_SCHEMA_VERSION = 2 +HAPTICS_EXPERIMENT_SIZE = 72 +HAPTICS_EXPERIMENT_SLOT_COUNT = 4 +HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION = 2 +HAPTICS_TRANSPORT_PROBE_SIZE = 128 +HAPTICS_EXPERIMENT_STATES = ( + "idle", "pending", "running", "completed", + "stopped", "disconnected", "unsupported", "error", +) +HAPTICS_EXPERIMENT_ERRORS = { + 0: "none", + 1: "unsupported controller or Bluetooth protocol", + 2: "insufficient Bluetooth MTU", + 3: "controller connection missing or lost", + 4: "Bluetooth can-send or stop timed out", + 5: "Bluetooth request or send failed", + 6: "queued controller output; wait for prior output to drain, then retry", +} +HAPTICS_EXPERIMENT_ENABLE_HINT = ( + "Install firmware built with SWITCH_PICO_HAPTICS_EXPERIMENT=ON " + "and connect a DualSense or DualSense Edge over Bluetooth Classic." +) +HAPTICS_EXPERIMENT_EVIDENCE_NOTE = ( + "Send timestamps measure firmware/HCI submission, not physical actuator " + "onset or playback; USB ACK only accepts a request. " + "The initial 1.024 s of priming silence is intentional, not transport delay." +) +HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT = ( + "Firmware does not support haptics transport profile operation 0x41. " + "Install updated firmware built with SWITCH_PICO_HAPTICS_EXPERIMENT=ON " + "and transport-probe support; haptics-experiment status still uses 0x40." +) +HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE = ( + "Durations are inclusive and may overlap or nest (poll/read/send/write); " + "do not sum their totals. first_tone_send_return_us is the low 32-bit " + "Pico uptime timestamp after l2cap_send returns successfully, not physical " + "actuator onset or playback. ACL extrema are observed samples, not an " + "exact occupancy timeline; completion counters select this connection " + "handle. Running snapshots are correlated to one run, not one instant." +) +HAPTICS_EXPERIMENT_PATTERN = { + "sample_rate_hz": 3000, + "stereo_frames_per_packet": 64, + "peak_amplitude": 32, + "priming_silence_packets": 48, + "cycles": 4, + "phases": [ + {"channel": "left", "frequency_hz": 100, "packets": 12}, + {"channel": "silence", "packets": 12}, + {"channel": "right", "frequency_hz": 200, "packets": 12}, + {"channel": "silence", "packets": 12}, + ], + "trailing_silence_packets": 48, + "total_packets": 288, + "initial_mode_packet_stereo_frames": 32, + "duration_us": 6144000, +} LOGICAL_BUTTONS = ( "south", @@ -242,6 +303,106 @@ class RuntimeDiagnostics: feedback_pending_slots: int rumble_pending_slots: int + +@dataclass(frozen=True) +class HapticsExperimentDiagnostics: + run_id: int + connection_generation: int + start_us: int + generated_packets: int + sent_packets: int + skipped_packets: int + send_failures: int + can_send_requests: int + synchronous_callbacks: int + max_generate_us: int + max_send_gap_us: int + max_lateness_us: int + max_request_wait_us: int + first_tone_due_us: int + first_tone_sent_us: int + last_sent_us: int + elapsed_us: int + state: int + slot: int | None + last_error: int + + @property + def state_name(self) -> str: + return HAPTICS_EXPERIMENT_STATES[self.state] + + @property + def error_name(self) -> str: + return HAPTICS_EXPERIMENT_ERRORS.get( + self.last_error, f"unknown error {self.last_error}" + ) + + @property + def firmware_supported(self) -> bool: + return not (self.state_name == "unsupported" and self.slot is None) + + @property + def first_tone_submission_delay_us(self) -> int | None: + if self.first_tone_sent_us == 0: + return None + return (self.first_tone_sent_us - self.first_tone_due_us) & 0xFFFFFFFF + + def to_json_object(self) -> dict[str, Any]: + return { + **asdict(self), + "schema_version": HAPTICS_EXPERIMENT_SCHEMA_VERSION, + "state_name": self.state_name, + "error_name": self.error_name, + "firmware_supported": self.firmware_supported, + "first_tone_submission_delay_us": self.first_tone_submission_delay_us, + "pattern": HAPTICS_EXPERIMENT_PATTERN, + "evidence_note": HAPTICS_EXPERIMENT_EVIDENCE_NOTE, + } + + +@dataclass(frozen=True) +class HapticsTransportProbe: + run_id: int + connection_generation: int + connection_handle: int + timer_wakes: int + max_timer_lateness_us: int + total_timer_lateness_us: int + send_calls: int + max_send_us: int + total_send_us: int + write_calls: int + max_write_us: int + total_write_us: int + read_calls: int + read_packets: int + max_read_us: int + total_read_us: int + poll_calls: int + max_poll_us: int + total_poll_us: int + completion_events: int + completed_packets: int + max_completion_gap_us: int + max_outstanding_acl: int + min_free_acl: int + first_tone_send_return_us: int + active: bool + max_permission_wait_us: int + total_permission_wait_us: int + permission_callbacks: int + max_poll_gap_us: int + controller_acl_packet_bytes: int + controller_acl_packet_count: int + + def to_json_object(self) -> dict[str, Any]: + return { + **asdict(self), + "schema_version": HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION, + "evidence_note": HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE, + } + + @dataclass(frozen=True) class AdapterConfiguration: pairing_window_seconds: int @@ -1934,6 +2095,329 @@ def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: ) +def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics: + _raise_status(envelope) + if envelope.schema_version != HAPTICS_EXPERIMENT_SCHEMA_VERSION: + raise ConfigManagerError( + f"unsupported haptics experiment schema {envelope.schema_version}; " + "update the host tool and experiment firmware together" + ) + if len(envelope.payload) != HAPTICS_EXPERIMENT_SIZE: + raise ConfigManagerError("invalid haptics experiment payload size") + counters = struct.unpack_from("<17I", envelope.payload) + state, slot, last_error, reserved = struct.unpack_from( + "<4B", envelope.payload, 68 + ) + if envelope.flags != 0 or reserved != 0: + raise ConfigManagerError("invalid haptics experiment reserved flags") + if state >= len(HAPTICS_EXPERIMENT_STATES): + raise ConfigManagerError(f"invalid haptics experiment state {state}") + if slot >= HAPTICS_EXPERIMENT_SLOT_COUNT and not ( + slot == 0xFF and HAPTICS_EXPERIMENT_STATES[state] in ("idle", "unsupported") + ): + raise ConfigManagerError(f"invalid haptics experiment slot {slot}") + return HapticsExperimentDiagnostics( + *counters, state=state, slot=None if slot == 0xFF else slot, + last_error=last_error, + ) + + +def read_haptics_experiment(device: UsbDevice) -> HapticsExperimentDiagnostics: + try: + envelope = _control_in(device, OP_HAPTICS_EXPERIMENT) + except usb.core.USBError as exc: + if exc.errno == 32 or exc.backend_error_code == -9: + raise ConfigManagerError( + "firmware does not support haptics experiment operation 0x40. " + + HAPTICS_EXPERIMENT_ENABLE_HINT + ) from exc + raise + return parse_haptics_experiment(envelope) + + +def parse_haptics_transport_probe(envelope: Envelope) -> HapticsTransportProbe: + if envelope.status == STATUS_UNSUPPORTED_SCHEMA: + raise ConfigManagerError(HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT) + _raise_status(envelope) + if envelope.schema_version != HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION: + raise ConfigManagerError( + f"unsupported haptics transport probe schema {envelope.schema_version}; " + "update the host tool and experiment firmware together" + ) + if len(envelope.payload) != HAPTICS_TRANSPORT_PROBE_SIZE: + raise ConfigManagerError("invalid haptics transport probe payload size") + if envelope.flags != 0: + raise ConfigManagerError("invalid haptics transport probe reserved flags") + fields = struct.unpack("<32I", envelope.payload) + if fields[2] > 0xFFFF: + raise ConfigManagerError("invalid haptics transport probe connection handle") + if fields[25] not in (0, 1): + raise ConfigManagerError("invalid haptics transport probe active boolean") + if envelope.generation != fields[0]: + raise ConfigManagerError("haptics transport probe envelope run ID mismatch") + return HapticsTransportProbe(*fields[:25], bool(fields[25]), *fields[26:]) + + +def read_haptics_transport_probe(device: UsbDevice) -> HapticsTransportProbe: + try: + envelope = _control_in(device, OP_HAPTICS_TRANSPORT_PROBE) + except usb.core.USBError as exc: + if exc.errno == 32 or exc.backend_error_code == -9: + raise ConfigManagerError(HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT) from exc + raise + return parse_haptics_transport_probe(envelope) + + +def read_haptics_experiment_profile( + device: UsbDevice, +) -> tuple[HapticsExperimentDiagnostics, HapticsTransportProbe]: + before = read_haptics_experiment(device) + transport = read_haptics_transport_probe(device) + after = read_haptics_experiment(device) + correlation = (before.run_id, before.connection_generation) + if ( + (after.run_id, after.connection_generation) != correlation + or (transport.run_id, transport.connection_generation) != correlation + ): + raise ConfigManagerError( + "haptics experiment run or connection generation changed or does not " + "match the transport profile; cannot attribute measurements. " + "Read profile again after the accepted run has started or finished." + ) + return after, transport + + +def _print_haptics_experiment_profile( + snapshot: HapticsExperimentDiagnostics, transport: HapticsTransportProbe, + *, as_json: bool, +) -> None: + if as_json: + values = snapshot.to_json_object() + values["transport"] = transport.to_json_object() + values["host_monotonic_s"] = time.monotonic() + print(json.dumps(values, sort_keys=True), flush=True) + return + _print_haptics_experiment(snapshot, as_json=False) + print( + f"Transport profile: run={transport.run_id}; " + f"connection_generation={transport.connection_generation}; " + f"handle=0x{transport.connection_handle:04x}; active={transport.active}" + ) + for label, calls, maximum, total in ( + ("timer lateness", transport.timer_wakes, + transport.max_timer_lateness_us, transport.total_timer_lateness_us), + ("permission wait", transport.permission_callbacks, + transport.max_permission_wait_us, transport.total_permission_wait_us), + ("l2cap_send", transport.send_calls, + transport.max_send_us, transport.total_send_us), + ("HCI write", transport.write_calls, + transport.max_write_us, transport.total_write_us), + ("HCI read", transport.read_calls, + transport.max_read_us, transport.total_read_us), + ("data-source poll", transport.poll_calls, + transport.max_poll_us, transport.total_poll_us), + ): + print(f" {label}: calls={calls}, max_us={maximum}, total_us={total}") + print(f" read_packets: {transport.read_packets}") + print( + f" completions: events={transport.completion_events}, " + f"packets={transport.completed_packets}, " + f"max_gap_us={transport.max_completion_gap_us}" + ) + print(f" max_poll_gap_us: {transport.max_poll_gap_us}") + print( + f" observed ACL slots: max_outstanding={transport.max_outstanding_acl}, " + f"min_free={transport.min_free_acl}" + ) + print(f" first_tone_send_return_us: {transport.first_tone_send_return_us}") + print( + f" controller advertised ACL: {transport.controller_acl_packet_count} " + f"packets of {transport.controller_acl_packet_bytes} bytes" + ) + print(HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE, flush=True) + + +def _raise_haptics_experiment_failure( + snapshot: HapticsExperimentDiagnostics, +) -> None: + if not snapshot.firmware_supported: + raise ConfigManagerError( + "haptics experiment is unsupported in this firmware. " + + HAPTICS_EXPERIMENT_ENABLE_HINT + ) + if snapshot.state_name in ("disconnected", "unsupported", "error"): + raise ConfigManagerError( + f"haptics experiment run {snapshot.run_id} " + f"{snapshot.state_name}: {snapshot.error_name} " + f"(last_error={snapshot.last_error}); " + "check the selected controller connection and experiment status" + ) + + +def _print_haptics_experiment( + snapshot: HapticsExperimentDiagnostics, *, as_json: bool, +) -> None: + values = snapshot.to_json_object() + if as_json: + values["host_monotonic_s"] = time.monotonic() + print(json.dumps(values, sort_keys=True), flush=True) + return + print( + f"Haptics experiment: {snapshot.state_name}; run={snapshot.run_id}; " + f"slot={snapshot.slot if snapshot.slot is not None else 'none'}" + ) + for name, value in asdict(snapshot).items(): + if name not in ("state", "slot", "last_error"): + print(f" {name}: {value}") + print(f" last_error: {snapshot.last_error} ({snapshot.error_name})") + delay = snapshot.first_tone_submission_delay_us + print( + " first_tone_submission_delay_us: " + f"{delay if delay is not None else 'not recorded'}" + ) + print( + "Pattern: 3 kHz, 64 stereo frames/packet, peak 32/127; " + "48 packets priming silence (1.024 s), 4 cycles of " + "left 100 Hz / silence / right 200 Hz / silence " + "(12 packets = 256 ms each), 48 packets trailing silence (1.024 s); " + "288 packets / 6.144 s total. Initial mode handoff carries 32 silent frames." + ) + print( + "Timestamp fields are low 32-bit Pico uptime microseconds; " + "differences use unsigned wraparound." + ) + print(HAPTICS_EXPERIMENT_EVIDENCE_NOTE, flush=True) + + +def _watch_haptics_experiment( + device: UsbDevice, snapshot: HapticsExperimentDiagnostics, + deadline: float, *, as_json: bool, +) -> None: + run_id = snapshot.run_id + slot = snapshot.slot + while True: + _print_haptics_experiment(snapshot, as_json=as_json) + _raise_haptics_experiment_failure(snapshot) + if snapshot.state_name not in ("pending", "running"): + return + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ConfigManagerError( + f"haptics experiment run {run_id} did not reach a terminal " + "state before --timeout; it may still be active, use status " + "or stop --slot " + str(slot) + ) + time.sleep(min(0.1, remaining)) + snapshot = read_haptics_experiment(device) + if snapshot.run_id != run_id or snapshot.slot != slot: + raise ConfigManagerError( + "haptics experiment run changed while watching; " + "cannot attribute measurements to the requested run" + ) + + +def _run_haptics_experiment_command( + device: UsbDevice, args: argparse.Namespace, +) -> None: + if args.haptics_command == "profile": + snapshot, transport = read_haptics_experiment_profile(device) + _print_haptics_experiment_profile(snapshot, transport, as_json=args.json) + return + before = read_haptics_experiment(device) + deadline = time.monotonic() + args.timeout + action = args.haptics_command + if action == "status": + if not before.firmware_supported: + _print_haptics_experiment(before, as_json=args.json) + print(HAPTICS_EXPERIMENT_ENABLE_HINT, file=sys.stderr) + elif args.watch: + _watch_haptics_experiment( + device, before, deadline, as_json=args.json + ) + else: + _print_haptics_experiment(before, as_json=args.json) + _raise_haptics_experiment_failure(before) + return + if not before.firmware_supported: + _raise_haptics_experiment_failure(before) + active = before.state_name in ("pending", "running") + if action == "start" and active: + raise ConfigManagerError( + f"haptics experiment is already {before.state_name} " + f"on slot {before.slot}; stop that run before starting another" + ) + if action == "stop": + if active and before.slot != args.slot: + raise ConfigManagerError( + f"haptics experiment is active on slot {before.slot}, " + f"not requested slot {args.slot}" + ) + if not active: + _print_haptics_experiment(before, as_json=args.json) + print( + "No active haptics experiment to stop.", + file=sys.stderr if args.json else sys.stdout, + ) + return + _control_out( + device, OP_HAPTICS_EXPERIMENT, + bytes((1 if action == "start" else 0, args.slot)), + ) + print( + f"{action.capitalize()} request accepted; pending firmware confirmation. " + "USB ACK is not evidence of stream start, completion, or playback.", + file=sys.stderr if args.json else sys.stdout, flush=True, + ) + expected_run_id = ( + (before.run_id + 1) & 0xFFFFFFFF if action == "start" else before.run_id + ) + observed_run = False + while True: + snapshot = read_haptics_experiment(device) + if snapshot.run_id == expected_run_id: + observed_run = True + if snapshot.slot != args.slot: + raise ConfigManagerError( + "haptics experiment response belongs to another slot" + ) + if snapshot.state_name in ("disconnected", "unsupported", "error"): + _print_haptics_experiment(snapshot, as_json=args.json) + _raise_haptics_experiment_failure(snapshot) + if action == "start" and args.watch: + _watch_haptics_experiment( + device, snapshot, deadline, as_json=args.json + ) + return + if snapshot.state_name in ("running", "completed") and action == "start": + _print_haptics_experiment(snapshot, as_json=args.json) + if snapshot.state_name == "running": + print( + "Firmware reports running; use start --watch or " + "status --watch to capture completion and later failures.", + file=sys.stderr if args.json else sys.stdout, + ) + return + if snapshot.state_name in ("stopped", "completed") and action == "stop": + _print_haptics_experiment(snapshot, as_json=args.json) + return + if snapshot.state_name not in ("pending", "running"): + raise ConfigManagerError( + f"haptics experiment {action} ended in unexpected " + f"state {snapshot.state_name}" + ) + elif action == "stop" or observed_run or snapshot.run_id != before.run_id: + raise ConfigManagerError( + f"haptics experiment run changed before {action} was confirmed" + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ConfigManagerError( + f"haptics experiment {action} was not confirmed before " + "--timeout; check status (USB ACK alone does not confirm it)" + ) + time.sleep(min(0.1, remaining)) + + def read_configuration(device: UsbDevice) -> AdapterConfiguration: envelope = _control_in(device, OP_CONFIGURATION_READ) _raise_status(envelope) @@ -2899,6 +3383,35 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser( "diagnostics", help="show live Bluetooth and rumble pipeline counters" ) + haptics = commands.add_parser( + "haptics-experiment", help="control the opt-in DualSense PCM experiment" + ) + haptics_commands = haptics.add_subparsers( + dest="haptics_command", required=True + ) + for action in ("start", "status", "stop"): + experiment = haptics_commands.add_parser(action) + if action != "status": + experiment.add_argument( + "--slot", type=int, choices=range(HAPTICS_EXPERIMENT_SLOT_COUNT), + default=0, help="connected controller slot (default: 0)", + ) + if action != "stop": + experiment.add_argument( + "--watch", action="store_true", + help="capture 100 ms status samples until terminal or --timeout", + ) + experiment.add_argument( + "--json", action="store_true", + help="emit JSON diagnostics (one object per sample with --watch)", + ) + profile = haptics_commands.add_parser( + "profile", help="read a run-correlated transport timing snapshot" + ) + profile.add_argument( + "--json", action="store_true", + help="emit diagnostics with a nested transport profile object", + ) reboot = commands.add_parser( "reboot", help="reboot into a firmware or ROM target" ) @@ -2993,6 +3506,9 @@ def main(argv: Sequence[str] | None = None) -> int: if args.timeout <= 0: print("error: --timeout must be positive", file=sys.stderr) return 2 + if args.command == "haptics-experiment" and not math.isfinite(args.timeout): + print("error: --timeout must be finite", file=sys.stderr) + return 2 if ( args.command == "config" and args.config_command == "reset" @@ -3081,6 +3597,8 @@ def main(argv: Sequence[str] | None = None) -> int: "Rumble-pending slots: " f"{diagnostics.rumble_pending_slots}" ) + elif args.command == "haptics-experiment": + _run_haptics_experiment_command(device, args) elif args.command == "reboot": request_bootsel_reboot(device) print("Rebooting into USB BOOTSEL mode.") diff --git a/tests/haptics_experiment_native_stubs/btstack.h b/tests/haptics_experiment_native_stubs/btstack.h new file mode 100644 index 0000000..3219074 --- /dev/null +++ b/tests/haptics_experiment_native_stubs/btstack.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +struct btstack_timer_source_t { + void (*process)(btstack_timer_source_t*) = nullptr; + void* context = nullptr; + uint64_t timeout_us = 0; +}; + +constexpr uint8_t ERROR_CODE_SUCCESS = 0; +enum gap_connection_type_t { + GAP_CONNECTION_INVALID, GAP_CONNECTION_ACL, GAP_CONNECTION_LE, GAP_CONNECTION_SCO +}; +gap_connection_type_t gap_get_connection_type(uint16_t handle); + +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)); +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms); +void btstack_run_loop_add_timer(btstack_timer_source_t* timer); +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer); +uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid); +uint8_t l2cap_request_can_send_now_event(uint16_t cid); +uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size); diff --git a/tests/haptics_experiment_native_stubs/pico/critical_section.h b/tests/haptics_experiment_native_stubs/pico/critical_section.h new file mode 100644 index 0000000..835ac27 --- /dev/null +++ b/tests/haptics_experiment_native_stubs/pico/critical_section.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +struct critical_section_t { + bool initialized = false; +}; + +inline unsigned native_haptics_lock_depth = 0; + +inline void critical_section_init(critical_section_t* section) { + assert(!section->initialized); + section->initialized = true; +} +inline void critical_section_enter_blocking(critical_section_t* section) { + assert(section->initialized); + assert(native_haptics_lock_depth == 0); + ++native_haptics_lock_depth; +} +inline void critical_section_exit(critical_section_t*) { + assert(native_haptics_lock_depth == 1); + --native_haptics_lock_depth; +} diff --git a/tests/haptics_experiment_native_stubs/pico/stdlib.h b/tests/haptics_experiment_native_stubs/pico/stdlib.h new file mode 100644 index 0000000..f54e23b --- /dev/null +++ b/tests/haptics_experiment_native_stubs/pico/stdlib.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +uint64_t time_us_64(); + +// Match SDK section spelling so the RAM build compiles the real annotations. +#define __not_in_flash(group) __attribute__((section(".time_critical." group))) +#define __time_critical_func(name) __not_in_flash(#name) name diff --git a/tests/haptics_experiment_native_stubs/uni.h b/tests/haptics_experiment_native_stubs/uni.h new file mode 100644 index 0000000..181ca3d --- /dev/null +++ b/tests/haptics_experiment_native_stubs/uni.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +struct uni_hid_device_s; +typedef struct uni_hid_device_s uni_hid_device_t; + +struct uni_report_parser_t { + void (*play_dual_rumble)(uni_hid_device_t*, uint16_t, uint16_t, + uint8_t, uint8_t) = nullptr; +}; + +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 { + uint16_t handle = 0; + uint16_t control_cid = 0; + uint16_t interrupt_cid = 0; + bool connected = false; + uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE; +}; + +struct uni_circular_buffer_t { + unsigned queued = 0; +}; +uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer); + +struct uni_hid_device_s { + uint16_t vendor_id = 0; + uint16_t product_id = 0; + uni_report_parser_t report_parser; + uni_bt_conn_t conn; + uni_circular_buffer_t outgoing_buffer; + + // Fake parser/link state. The module only sees the real fields above. + uint16_t remote_mtu = 143; + gap_connection_type_t connection_type = GAP_CONNECTION_ACL; + bool parser_rumble_active = false; + bool parser_rumble_delayed = false; + btstack_timer_source_t parser_timer; + bool notification_pending = false; + bool credit = true; +}; diff --git a/tests/haptics_experiment_test.cpp b/tests/haptics_experiment_test.cpp new file mode 100644 index 0000000..ed1e52d --- /dev/null +++ b/tests/haptics_experiment_test.cpp @@ -0,0 +1,716 @@ +#include "input/haptics_experiment.h" +#include "input/haptics_transport_probe.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +enum class Delivery { kImmediate, kDeferred, kNever }; +enum class GenericKind { kCompatibility, kLed }; + +struct Pcm { + uint64_t at_us; + uint16_t cid; + std::array bytes; +}; +struct Generic { + uni_hid_device_t* device; + uint64_t at_us; + GenericKind kind; + uint8_t weak; + uint8_t strong; +}; + +uint64_t now_us = 10000123; +std::array devices; +std::vector timers; +std::vector pcm; +std::vector generic_sent; +std::vector generic_queue; +Delivery delivery = Delivery::kImmediate; +uni_hid_device_t* permission = nullptr; +unsigned request_depth = 0; +unsigned max_request_depth = 0; +unsigned request_calls = 0; +unsigned send_calls = 0; +unsigned timer_calls = 0; +unsigned fail_requests = 0; +unsigned fail_sends = 0; +uint32_t send_cost_us = 0; +uint32_t request_cost_us = 0; +bool reenter_send = false; + +void no_lock() { + assert(native_haptics_lock_depth == 0); +} + +HapticsExperimentDiagnostics snapshot() { + HapticsExperimentDiagnostics out; + haptics_experiment_snapshot(&out); + return out; +} + +uni_hid_device_t* device_for_cid(uint16_t cid) { + for (auto& device : devices) { + if (device.conn.interrupt_cid == cid) { + return &device; + } + } + assert(false && "stale or unknown L2CAP CID"); + return nullptr; +} + +void emit_generic(uni_hid_device_t* device, GenericKind kind, + uint8_t weak = 0, uint8_t strong = 0) { + no_lock(); + Generic report{device, now_us, kind, weak, strong}; + if (device->credit) { + generic_sent.push_back(report); + } else { + generic_queue.push_back(report); + ++device->outgoing_buffer.queued; + } +} + +void parser_off(btstack_timer_source_t* timer) { + auto* device = static_cast(timer->context); + assert(device->parser_rumble_active); + device->parser_rumble_active = false; + emit_generic(device, GenericKind::kCompatibility); +} + +void parser_delayed_on(btstack_timer_source_t* timer) { + auto* device = static_cast(timer->context); + assert(device->parser_rumble_delayed); + device->parser_rumble_delayed = false; + emit_generic(device, GenericKind::kCompatibility, 17, 23); +} + +// Behavioral fake of the relevant Bluepad32 DS5 parser contract: duration=0 +// does not emit anything if already disabled; duration>0 forces compatibility +// and installs a timer-off. This catches a "restore" which is actually a no-op. +void play_rumble(uni_hid_device_t* device, uint16_t delay_ms, + uint16_t duration_ms, uint8_t weak, uint8_t strong) { + no_lock(); + const bool was_active = device->parser_rumble_active || + device->parser_rumble_delayed; + if (was_active) { + btstack_run_loop_remove_timer(&device->parser_timer); + } + device->parser_rumble_active = false; + device->parser_rumble_delayed = false; + device->parser_timer.context = device; + if (delay_ms != 0) { + device->parser_rumble_delayed = true; + device->parser_timer.process = parser_delayed_on; + btstack_run_loop_set_timer(&device->parser_timer, delay_ms); + btstack_run_loop_add_timer(&device->parser_timer); + } else if (duration_ms != 0) { + emit_generic(device, GenericKind::kCompatibility, weak, strong); + device->parser_rumble_active = true; + device->parser_timer.process = parser_off; + btstack_run_loop_set_timer(&device->parser_timer, duration_ms); + btstack_run_loop_add_timer(&device->parser_timer); + } else if (was_active) { + emit_generic(device, GenericKind::kCompatibility); + } +} + +bool dispatch(uni_hid_device_t* device, uint16_t cid) { + no_lock(); + if (cid == device->conn.interrupt_cid) { + device->notification_pending = false; + } + permission = device->credit ? device : nullptr; + const bool consumed = haptics_experiment_on_can_send_now(device, cid); + if (!consumed && device->credit) { + const auto it = std::find_if(generic_queue.begin(), generic_queue.end(), + [device](const Generic& report) { + return report.device == device; + }); + if (it != generic_queue.end()) { + Generic report = *it; + report.at_us = now_us; + generic_sent.push_back(report); + generic_queue.erase(it); + --device->outgoing_buffer.queued; + } + } + permission = nullptr; + return consumed; +} + +void run_until(uint64_t target_us) { + assert(target_us >= now_us); + unsigned iterations = 0; + while (!timers.empty()) { + const auto it = std::min_element( + timers.begin(), timers.end(), + [](const auto* a, const auto* b) { return a->timeout_us < b->timeout_us; }); + btstack_timer_source_t* timer = *it; + if (timer->timeout_us > target_us) { + break; + } + now_us = std::max(now_us, timer->timeout_us); + timers.erase(it); + assert(++iterations < 3000 && "recursive or permanently polling timer"); + ++timer_calls; + timer->process(timer); + } + now_us = std::max(now_us, target_us); +} + +void reset(uint64_t at_us = 10000123) { + for (auto& device : devices) { + haptics_experiment_detach(&device); + } + if (snapshot().state == HapticsExperimentState::kPending) { + assert(haptics_experiment_request(0, snapshot().slot)); + haptics_experiment_poll(); + } + timers.clear(); + devices = {}; + pcm.clear(); + generic_sent.clear(); + generic_queue.clear(); + now_us = at_us; + delivery = Delivery::kImmediate; + permission = nullptr; + request_depth = max_request_depth = request_calls = send_calls = timer_calls = 0; + fail_requests = fail_sends = 0; + send_cost_us = request_cost_us = 0; + reenter_send = false; + for (unsigned slot = 0; slot < devices.size(); ++slot) { + auto& device = devices[slot]; + device.vendor_id = 0x054c; + device.product_id = 0x0ce6; + device.conn.handle = static_cast(slot); + // Bluepad32's Classic path does not initialize this cached field. + device.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE; + device.conn.connected = true; + device.conn.interrupt_cid = static_cast(0x40 + slot * 2); + device.conn.control_cid = device.conn.interrupt_cid + 1; + device.report_parser.play_dual_rumble = play_rumble; + haptics_experiment_attach(static_cast(slot), 100 + slot, &device); + } + assert(pcm.empty() && generic_sent.empty()); // No pairing/boot tone. +} + +uint64_t start(uint8_t slot = 0) { + const uint32_t previous_id = snapshot().run_id; + assert(haptics_experiment_request(1, slot)); + const auto pending = snapshot(); + assert(pending.run_id == previous_id + 1); + assert(pending.slot == slot && pending.state == HapticsExperimentState::kPending); + assert(!haptics_experiment_request(1, slot)); + assert(!haptics_experiment_request(1, static_cast((slot + 1) % 4))); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(haptics_experiment_owns(&devices[slot])); + assert(!haptics_experiment_owns(&devices[(slot + 1) % 4])); + return now_us; +} + +uint64_t due(uint64_t started, uint32_t packet) { + return started + (static_cast(packet) * 64000 + 2) / 3; +} + +void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) { + const auto& b = packet.bytes; + assert(b[0] == 0xa2 && b[1] == 0x32 && b[2] == 0); + unsigned sample_offset = 10; + unsigned frames = 64; + if (b[3] == 0x90) { + assert(b[4] == 63); + for (unsigned i = 5; i < 68; ++i) assert(b[i] == 0); + assert(b[68] == 0x92 && b[69] == 64); + sample_offset = 70; + frames = 32; + } else { + assert(b[3] == 0x91 && b[4] == 3 && b[5] == 0x62); + assert(b[6] == 16 && b[8] == 0xd2 && b[9] == 64); + } + for (unsigned i = sample_offset + frames * 2; i < 139; ++i) { + assert(b[i] == 0); + } + const bool pattern_tone = index >= 48 && index < 240 && + ((index - 48) / 12) % 2 == 0; + const bool tone = pattern_tone && !forced_silence; + const unsigned phase = tone ? ((index - 48) / 12) % 4 : 0; + const unsigned side = phase == 0 ? 0 : 1; + const double hz = phase == 0 ? 100.0 : 200.0; + for (unsigned frame = 0; frame < frames; ++frame) { + for (unsigned channel = 0; channel < 2; ++channel) { + const int value = static_cast(b[sample_offset + frame * 2 + channel]); + if (!tone || channel != side) { + assert(value == 0); + } else { + const unsigned sample = ((index - 48) % 12) * 64 + frame; + const int expected = static_cast(std::lround( + 32.0 * std::sin(2.0 * 3.14159265358979323846 * hz * sample / 3000.0))); + assert(std::abs(value - expected) <= 1); + assert(std::abs(value) <= 32); + } + } + } +} + +void nominal_run(const char* corpus_path) { + reset(); + const uint64_t started = start(); + // This is the BTstack synchronous reentry reproduction: the very first + // request sends one block before request_can_send_now() returns. + assert(pcm.size() == 1 && snapshot().synchronous_callbacks == 1); + assert(!devices[0].notification_pending); + const uint64_t early_tick = due(started, 1) / 1000 * 1000; + run_until(early_tick); + assert(pcm.size() == 1); // SDK early millisecond wake must not send early. + run_until(started + 6148000); + const auto done = snapshot(); + assert(done.state == HapticsExperimentState::kCompleted); + assert(done.sent_packets == 288 && done.generated_packets == 288); + assert(done.skipped_packets == 0 && done.send_failures == 0); + assert(done.can_send_requests == 288 && done.synchronous_callbacks == 288); + assert(max_request_depth == 1 && request_calls == 288); + assert(timer_calls < 1250); // No permanent 1 ms poll for this 6.144 s run. + assert(pcm.size() == 288); + assert(done.first_tone_due_us == static_cast(started + 1024000)); + assert(done.first_tone_sent_us == static_cast(pcm[48].at_us)); + assert(done.last_sent_us == static_cast(pcm.back().at_us)); + assert(done.max_send_gap_us <= 22000 && done.max_lateness_us < 1000); + assert(done.elapsed_us >= 6147000 && done.elapsed_us < 6148000); + assert(done.max_generate_us == 0); + assert(!haptics_experiment_owns(&devices[0]) && timers.empty()); + assert(generic_sent.size() == 2); // Forced compatibility, then parser off. + assert(generic_sent.front().at_us >= started + 6144000); + assert(!devices[0].parser_rumble_active); + for (const auto& report : generic_sent) { + assert(report.kind == GenericKind::kCompatibility); + assert(report.weak == 0 && report.strong == 0); + } + std::ofstream corpus(corpus_path, std::ios::binary); + assert(corpus.is_open()); + for (uint32_t i = 0; i < pcm.size(); ++i) { + assert(pcm[i].at_us >= due(started, i)); + assert(pcm[i].at_us - due(started, i) < 1000); + if (i != 0) assert(pcm[i].bytes[7] == static_cast(i * 2)); + verify_block(pcm[i], i); + corpus.write(reinterpret_cast(pcm[i].bytes.data()), 143); + } + // Reference-sized 0x10 native-mode state followed by a silent PCM block. + // Independent known answer computed with Python zlib over the A2 prefix. + assert(pcm[0].bytes[3] == 0x90); + assert(pcm[0].bytes[139] == 0x00 && pcm[0].bytes[140] == 0x41 && + pcm[0].bytes[141] == 0xfd && pcm[0].bytes[142] == 0x53); + corpus.close(); + run_until(now_us + 200000); + assert(snapshot().elapsed_us == done.elapsed_us && pcm.size() == 288); +} + +void stalled_deadlines() { + reset(); + const uint64_t started = start(); + run_until(due(started, 102) + 1000); + const unsigned before = static_cast(pcm.size()); + const uint32_t next = snapshot().sent_packets; + now_us += 250000; // The main loop did not run at all during this stall. + const uint32_t current = static_cast((now_us - started) * 3 / 64000); + run_until(now_us); + assert(pcm.size() == before + 1); // No catch-up replay burst. + assert(snapshot().skipped_packets == current - next); + verify_block(pcm.back(), current); + assert(snapshot().max_send_gap_us >= 250000); + run_until(started + 6148000); + const auto done = snapshot(); + assert(done.state == HapticsExperimentState::kCompleted); + assert(done.sent_packets + done.skipped_packets == 288); + assert(done.elapsed_us < 6148000); +} + +void deferred_and_missing_callbacks() { + reset(); + delivery = Delivery::kDeferred; + const uint64_t started = start(); + assert(pcm.empty() && devices[0].notification_pending); + run_until(started + 2300000); + assert(request_calls == 1 && timer_calls == 0); + assert(!dispatch(&devices[1], devices[1].conn.interrupt_cid)); + assert(dispatch(&devices[0], devices[0].conn.control_cid)); + assert(pcm.empty() && devices[0].notification_pending); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == 1); + assert(snapshot().max_request_wait_us == 2300000); + assert(snapshot().synchronous_callbacks == 0); + assert(snapshot().skipped_packets == 107); + verify_block(pcm.back(), 107); + delivery = Delivery::kImmediate; + run_until(started + 6148000); + assert(snapshot().state == HapticsExperimentState::kCompleted); + assert(snapshot().sent_packets + snapshot().skipped_packets == 288); + + reset(); + delivery = Delivery::kNever; + const uint64_t missing_start = start(); + run_until(missing_start + 6248000); + const auto missing = snapshot(); + assert(missing.state == HapticsExperimentState::kError); + assert(missing.last_error == 4 && missing.send_failures == 1); + assert(missing.sent_packets == 0 && missing.skipped_packets == 288); + assert(missing.max_request_wait_us >= 6244000); + assert(request_calls == 1 && timer_calls < 10 && timers.empty()); + assert(!haptics_experiment_owns(&devices[0])); + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.empty()); +} + +void stop_preemption_and_restore() { + reset(); + const uint64_t started = start(); + run_until(due(started, 98) + 1000); + delivery = Delivery::kDeferred; + run_until(due(started, 99) + 1000); + assert(devices[0].notification_pending); + const auto before = snapshot(); + assert(!haptics_experiment_request(0, 1)); + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + run_until(now_us + 10000); + const unsigned sent_before = static_cast(pcm.size()); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == sent_before + 1); + verify_block(pcm.back(), 99, true); // Pending tone permission now sends silence. + assert(!haptics_experiment_request(1, 0)); // Compatibility is still settling. + run_until(now_us + 4000); + assert(snapshot().state == HapticsExperimentState::kStopped); + assert(snapshot().run_id == before.run_id); + assert(!devices[0].parser_rumble_active && timers.empty()); + assert(generic_sent.size() == 2 && generic_sent[0].at_us >= pcm.back().at_us); + const auto stopped_elapsed = snapshot().elapsed_us; + run_until(now_us + 7000000); + assert(pcm.size() == sent_before + 1 && snapshot().elapsed_us == stopped_elapsed); + + reset(); + const uint32_t previous = snapshot().run_id; + assert(haptics_experiment_request(1, 0)); + const uint32_t pending_generation = snapshot().connection_generation; + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kStopped); + assert(snapshot().run_id == previous + 1 && pcm.empty() && timers.empty()); + assert(snapshot().connection_generation == pending_generation); + + // Disconnect in the ownership-settling window, immediately after restore. + // Bluepad32 deletes the instance without removing private parser timers. + reset(); + start(); + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + assert(haptics_experiment_owns(&devices[0])); + assert(!devices[0].parser_rumble_active); + haptics_experiment_detach(&devices[0]); + const auto compatibility_count = generic_sent.size(); + devices[0] = {}; // Simulate upstream zeroing/reusing the parser instance. + run_until(now_us + 10000); + assert(snapshot().state == HapticsExperimentState::kDisconnected); + assert(timers.empty() && generic_sent.size() == compatibility_count); + + reset(); + delivery = Delivery::kNever; + devices[0].credit = false; + start(); + const uint64_t stopped_at = now_us; + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + // Repeated stops must not perpetually extend the lifecycle watchdog. + run_until(stopped_at + 50000); + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + run_until(stopped_at + 202000); + assert(snapshot().state == HapticsExperimentState::kError); + assert(snapshot().last_error == 4 && snapshot().send_failures >= 1); + assert(snapshot().sent_packets == 0 && !haptics_experiment_owns(&devices[0])); + assert(timers.empty() && generic_sent.empty() && generic_queue.size() == 2); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(generic_queue.empty() && generic_sent.size() == 2 && pcm.empty()); +} + +void compatibility_queue_and_parser_timers() { + reset(); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 6); + assert(pcm.empty() && generic_queue.size() == 1 && !haptics_experiment_owns(&devices[0])); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.control_cid)); + assert(generic_sent.size() == 1 && generic_sent[0].kind == GenericKind::kLed); + start(); // Retry only after the unrelated report was delivered, not discarded. + assert(pcm.size() == 1); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + devices[0].credit = true; + const auto sent_count = pcm.size(); + assert(dispatch(&devices[0], devices[0].conn.control_cid)); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == sent_count && generic_queue.size() == 1); + + reset(); + play_rumble(&devices[0], 3000, 100, 17, 23); + const uint64_t started = start(); + run_until(started + 6148000); + assert(snapshot().state == HapticsExperimentState::kCompleted); + for (const auto& report : generic_sent) { + assert(report.weak == 0 && report.strong == 0); + assert(report.at_us <= started || report.at_us >= started + 6144000); + } + assert(!devices[0].parser_rumble_active && !devices[0].parser_rumble_delayed); + + // Canceling an already running parser can itself queue a stop report. + // Reject before the first native packet if that compatibility report blocks. + reset(); + play_rumble(&devices[0], 0, 3000, 17, 23); + devices[0].credit = false; + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().last_error == 6 && pcm.empty()); + assert(!devices[0].parser_rumble_active && timers.empty()); +} + +void reconnect_and_pending_generation() { + reset(); + delivery = Delivery::kDeferred; + start(); + const uint16_t old_cid = devices[0].conn.interrupt_cid; + haptics_experiment_detach(&devices[0]); + const auto detached = snapshot(); + assert(detached.state == HapticsExperimentState::kDisconnected); + assert(detached.last_error == 3 && timers.empty() && generic_sent.empty()); + assert(!dispatch(&devices[0], old_cid)); + assert(pcm.empty()); + devices[0].conn.interrupt_cid = 0x70; + haptics_experiment_attach(0, 101, &devices[0]); + start(); + assert(snapshot().connection_generation == 101); + assert(dispatch(&devices[0], old_cid)); // Never permission for the new CID. + assert(pcm.empty()); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == 1); + haptics_experiment_attach(0, 102, &devices[0]); + assert(snapshot().state == HapticsExperimentState::kDisconnected && timers.empty()); + + reset(); + assert(haptics_experiment_request(1, 0)); + const uint32_t requested_generation = snapshot().connection_generation; + haptics_experiment_detach(&devices[0]); + haptics_experiment_attach(0, requested_generation + 1, &devices[0]); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kDisconnected); + assert(snapshot().connection_generation == requested_generation && pcm.empty()); + // The rejected request did not reserve the slot permanently. + start(); + assert(snapshot().connection_generation == requested_generation + 1); +} + +void support_and_transport_errors() { + reset(); + assert(!haptics_experiment_request(2, 0)); + assert(!haptics_experiment_request(1, 4)); + devices[0].remote_mtu = 142; + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kUnsupported); + assert(snapshot().last_error == 2 && pcm.empty() && !haptics_experiment_owns(&devices[0])); + devices[0].remote_mtu = 143; + devices[0].connection_type = GAP_CONNECTION_LE; + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().last_error == 1 && pcm.empty()); + devices[0].connection_type = GAP_CONNECTION_ACL; + devices[0].product_id = 0x0df2; + start(); // Exact MTU boundary and DualSense Edge. + assert(pcm.size() == 1); + + reset(); + start(3); + assert(pcm.size() == 1 && pcm.front().cid == devices[3].conn.interrupt_cid); + assert(snapshot().connection_generation == 103); + + reset(); + fail_requests = 1; + start(); + assert(pcm.empty()); + run_until(now_us + 24000); + assert(pcm.size() == 1 && snapshot().send_failures == 1); + assert(snapshot().skipped_packets == 1); + fail_sends = 1; + run_until(now_us + 24000); + assert(snapshot().send_failures == 2); + run_until(static_cast(snapshot().start_us) + 6148000); + assert(snapshot().state == HapticsExperimentState::kError); + assert(snapshot().last_error == 5); + assert(snapshot().generated_packets == snapshot().sent_packets + 1); + assert(max_request_depth == 1 && timers.empty()); +} + +void timing_cost_reentrancy_and_wrap() { + reset(); + send_cost_us = 500; + request_cost_us = 200; + reenter_send = true; + start(); + const uint64_t started = snapshot().start_us; + run_until(started + 6148000); + const auto done = snapshot(); + assert(done.state == HapticsExperimentState::kCompleted); + assert(done.sent_packets == 288 && send_calls == 288); + assert(done.max_generate_us == 0); // Neither request nor send is generation. + assert(done.max_request_wait_us == 200 && max_request_depth == 1); + for (unsigned i = 0; i < pcm.size(); ++i) { + assert(pcm[i].at_us >= due(started, i)); + assert(pcm[i].at_us - due(started, i) < 1200); + } + + reset((uint64_t{1} << 32) - 1000123); + const uint64_t wrap_start = start(); + run_until(wrap_start + 6148000); + const auto wrapped = snapshot(); + assert(wrapped.state == HapticsExperimentState::kCompleted); + assert(wrapped.start_us == static_cast(wrap_start)); + assert(wrapped.first_tone_due_us == static_cast(wrap_start + 1024000)); + assert(wrapped.first_tone_sent_us == static_cast(pcm[48].at_us)); + assert(wrapped.last_sent_us == static_cast(pcm.back().at_us)); + assert(wrapped.elapsed_us >= 6147000 && wrapped.elapsed_us < 6148000); + assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288); +} + +} // namespace + +// Transport attribution has its own native fixture; this fixture isolates PCM +// scheduling and packet content from the optional measurement backend. +void haptics_transport_probe_prepare() {} +void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {} +void haptics_transport_probe_end() {} +void haptics_transport_probe_timer(uint32_t) {} +void haptics_transport_probe_permission(uint32_t) {} +void haptics_transport_probe_send(uint32_t, uint32_t, bool) {} + +uint64_t time_us_64() { + return now_us; +} + +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { + no_lock(); + timer->process = handler; +} + +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms) { + no_lock(); + // Exactly pico_btstack/btstack_run_loop_async_context.c, including +1. + timer->timeout_us = (now_us / 1000 + timeout_ms + 1) * 1000; +} + +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + no_lock(); + assert(timer->process != nullptr); + assert(std::find(timers.begin(), timers.end(), timer) == timers.end()); + timers.push_back(timer); +} + +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + no_lock(); + const auto it = std::find(timers.begin(), timers.end(), timer); + if (it == timers.end()) { + return 0; + } + timers.erase(it); + return 1; +} + +gap_connection_type_t gap_get_connection_type(uint16_t handle) { + no_lock(); + return handle < devices.size() ? devices[handle].connection_type + : GAP_CONNECTION_INVALID; +} + +uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid) { + no_lock(); + return device_for_cid(cid)->remote_mtu; +} + +uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) { + return buffer->queued == 0; +} + +uint8_t l2cap_request_can_send_now_event(uint16_t cid) { + no_lock(); + ++request_calls; + if (fail_requests != 0) { + --fail_requests; + return 0x44; + } + auto* device = device_for_cid(cid); + assert(!device->notification_pending); + device->notification_pending = true; + ++request_depth; + max_request_depth = std::max(max_request_depth, request_depth); + now_us += request_cost_us; + if (delivery == Delivery::kImmediate && device->credit) { + dispatch(device, cid); // Real BTstack can call here, BEFORE return. + } + --request_depth; + return ERROR_CODE_SUCCESS; +} + +uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) { + no_lock(); + auto* device = device_for_cid(cid); + assert(permission == device && device->credit); + assert(size == 143); + ++send_calls; + const uint64_t submitted_us = now_us; + now_us += send_cost_us; + if (reenter_send) { + assert(haptics_experiment_on_can_send_now(device, cid)); + const auto concurrent_snapshot = snapshot(); + assert(concurrent_snapshot.state == HapticsExperimentState::kPending || + concurrent_snapshot.state == HapticsExperimentState::kRunning); + } + if (fail_sends != 0) { + --fail_sends; + return 0x55; + } + Pcm packet{submitted_us, cid, {}}; + std::copy(data, data + size, packet.bytes.begin()); + pcm.push_back(packet); + return ERROR_CODE_SUCCESS; +} + +int main(int argc, char** argv) { + assert(argc == 2); + haptics_experiment_prepare(); + assert(snapshot().state == HapticsExperimentState::kIdle); + nominal_run(argv[1]); + stalled_deadlines(); + deferred_and_missing_callbacks(); + stop_preemption_and_restore(); + compatibility_queue_and_parser_timers(); + reconnect_and_pending_generation(); + support_and_transport_errors(); + timing_cost_reentrancy_and_wrap(); + std::cout << "haptics experiment behavioral regressions passed\n"; +} diff --git a/tests/haptics_transport_probe_native_stubs/btstack.h b/tests/haptics_transport_probe_native_stubs/btstack.h new file mode 100644 index 0000000..09b9505 --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/btstack.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +constexpr uint8_t HCI_EVENT_PACKET = 4; +constexpr uint8_t HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS = 0x13; +using hci_con_handle_t = uint16_t; +struct hci_connection_t { + uint8_t num_packets_sent = 0; +}; +struct btstack_packet_callback_registration_t { + void* item = nullptr; + void (*callback)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr; +}; +extern "C" { +void btstack_run_loop_base_poll_data_sources(); +void btstack_run_loop_poll_data_sources_from_irq(); +void hci_add_event_handler(btstack_packet_callback_registration_t* registration); +hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle); +int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle); +} diff --git a/tests/haptics_transport_probe_native_stubs/cyw43.h b/tests/haptics_transport_probe_native_stubs/cyw43.h new file mode 100644 index 0000000..aeeb140 --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/cyw43.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif +int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length); +int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, uint32_t* length); +#ifdef __cplusplus +} +#endif diff --git a/tests/haptics_transport_probe_native_stubs/pico/critical_section.h b/tests/haptics_transport_probe_native_stubs/pico/critical_section.h new file mode 100644 index 0000000..b8e70ed --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/pico/critical_section.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +struct critical_section_t { + bool initialized = false; +}; +inline unsigned native_probe_lock_depth = 0; +inline void critical_section_init(critical_section_t* section) { + assert(!section->initialized); + section->initialized = true; +} +inline void critical_section_enter_blocking(critical_section_t* section) { + assert(section->initialized && native_probe_lock_depth == 0); + ++native_probe_lock_depth; +} +inline void critical_section_exit(critical_section_t*) { + assert(native_probe_lock_depth == 1); + --native_probe_lock_depth; +} diff --git a/tests/haptics_transport_probe_native_stubs/pico/stdlib.h b/tests/haptics_transport_probe_native_stubs/pico/stdlib.h new file mode 100644 index 0000000..8aff95e --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/pico/stdlib.h @@ -0,0 +1,4 @@ +#pragma once + +#include +uint64_t time_us_64(); diff --git a/tests/haptics_transport_probe_native_stubs/sdk.cpp b/tests/haptics_transport_probe_native_stubs/sdk.cpp new file mode 100644 index 0000000..e4f55ef --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/sdk.cpp @@ -0,0 +1,19 @@ +#include +#include + +extern int (*native_write)(uint8_t*, size_t); +extern int (*native_read)(uint8_t*, uint32_t, uint32_t*); +extern void (*native_poll)(); + +// Keep definitions separate from test callers, just like the real SDK. Tests +// exercise GNU ld --wrap, not direct calls to the __wrap_ implementation. +extern "C" int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) { + return native_write(buffer, length); +} +extern "C" int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, + uint32_t* length) { + return native_read(buffer, capacity, length); +} +extern "C" void btstack_run_loop_base_poll_data_sources() { + native_poll(); +} diff --git a/tests/haptics_transport_probe_test.cpp b/tests/haptics_transport_probe_test.cpp new file mode 100644 index 0000000..613973d --- /dev/null +++ b/tests/haptics_transport_probe_test.cpp @@ -0,0 +1,470 @@ +#include "input/haptics_transport_probe.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr uint16_t kHandle = 0x123; +uint64_t clock_us = 1000; +hci_connection_t connection{2}; +bool connected = true; +int free_slots = 7; +unsigned registrations = 0; +void (*event_handler)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr; +unsigned writes = 0, reads = 0, polls = 0; +uint32_t write_delay = 0, read_delay = 0, poll_delay = 0; +int write_result = 0, read_result = 0; +uint32_t read_length = 8; +uint8_t* last_buffer = nullptr; +size_t last_write_length = 0; +uint32_t last_capacity = 0; +uint32_t* last_length = nullptr; +void (*write_action)() = nullptr; +void (*read_action)() = nullptr; +void (*poll_action)() = nullptr; +uint8_t buffer[16]{}; +bool poll_requested = false; +unsigned queued_input = 0; +unsigned serviced_input = 0; + +HapticsTransportProbe snapshot() { + HapticsTransportProbe result; + haptics_transport_probe_snapshot(&result); + return result; +} + +void unchanged(const HapticsTransportProbe& expected) { + const auto actual = snapshot(); + static_assert(sizeof(actual) == 128); + assert(std::memcmp(&actual, &expected, sizeof(actual)) == 0); +} + +void run_action(void (*&action)()) { + const auto callback = action; + action = nullptr; + if (callback != nullptr) callback(); +} + +int fake_write(uint8_t* data, size_t length) { + assert(native_probe_lock_depth == 0); + (void)snapshot(); // Real calls may synchronously reenter snapshot readers. + ++writes; + last_buffer = data; + last_write_length = length; + clock_us += write_delay; + run_action(write_action); + if (data != nullptr && length != 0) data[0] = 0xa5; + return write_result; +} + +int fake_read(uint8_t* data, uint32_t capacity, uint32_t* length) { + assert(native_probe_lock_depth == 0); + (void)snapshot(); + ++reads; + last_buffer = data; + last_capacity = capacity; + last_length = length; + clock_us += read_delay; + run_action(read_action); + if (data != nullptr && capacity != 0) data[0] = 0x5a; + if (read_result == 0 && length != nullptr) *length = read_length; + return read_result; +} + +void fake_poll() { + assert(native_probe_lock_depth == 0); + (void)snapshot(); + ++polls; + clock_us += poll_delay; + run_action(poll_action); +} + +void begin(uint32_t run = 1, uint16_t handle = kHandle) { + haptics_transport_probe_end(); + clock_us = 1000; + connected = true; + connection.num_packets_sent = 2; + free_slots = 7; + writes = reads = polls = 0; + write_delay = read_delay = poll_delay = 0; + write_result = read_result = 0; + read_length = 8; + write_action = read_action = poll_action = nullptr; + poll_requested = false; + haptics_transport_probe_begin(run, 9, handle); + assert(registrations == 1); +} + +void complete(uint16_t count) { + uint8_t event[] = {0x13, 5, 1, 0x23, 0x01, + static_cast(count), static_cast(count >> 8)}; + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); +} + +void test_inactive() { + haptics_transport_probe_prepare(); + haptics_transport_probe_prepare(); + const auto before = snapshot(); + write_result = -7; + read_result = 17; + uint32_t length = 99; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -7); + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 17); + assert(length == 99); + btstack_run_loop_base_poll_data_sources(); + haptics_transport_probe_timer(123); + haptics_transport_probe_permission(456); + haptics_transport_probe_send(789, 1000, true); + unchanged(before); + assert(writes == 1 && reads == 1 && polls == 1 && registrations == 0); +} + +void test_delay_attribution() { + begin(); + poll_action = [] { + clock_us += 50; // Generation is outside the caller-supplied send time. + const uint32_t send_start = static_cast(clock_us); + clock_us += 7; + write_delay = 3000; + write_action = [] { connection.num_packets_sent = 5; free_slots = 1; }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + clock_us += 11; + haptics_transport_probe_send(static_cast(clock_us) - send_start, + static_cast(clock_us), true); + clock_us += 9; + uint32_t length = 0; + read_delay = 80; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + read_delay = 90; + read_length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + read_delay = 70; + read_result = -9; + length = 99; // Stale output length on failure is not a packet. + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == -9); + clock_us += 30; + }; + btstack_run_loop_base_poll_data_sources(); + auto result = snapshot(); + assert(result.send_calls == 1 && result.total_send_us == 3018); + assert(result.max_send_us == 3018 && result.first_tone_send_return_us == 4068); + assert(result.write_calls == 1 && result.total_write_us == 3000); + assert(result.max_write_us == 3000); + assert(result.read_calls == 3 && result.read_packets == 1); + assert(result.total_read_us == 240 && result.max_read_us == 90); + assert(result.poll_calls == 1 && result.total_poll_us == 3347); + assert(result.max_poll_us == 3347 && result.max_poll_gap_us == 0); + assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1); + clock_us += 600; + poll_delay = 100; + btstack_run_loop_base_poll_data_sources(); + result = snapshot(); + assert(result.poll_calls == 2 && result.total_poll_us == 3447); + assert(result.max_poll_gap_us == 3947); +} + +void test_nested_boundaries() { + begin(); + write_result = -23; + write_delay = 10; + write_action = [] { + write_delay = 30; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23); + clock_us += 7; + }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23); + assert(writes == 2 && snapshot().write_calls == 1); + assert(snapshot().total_write_us == 47); + read_delay = 11; + read_action = [] { + read_delay = 19; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + clock_us += 3; + }; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + assert(reads == 2 && snapshot().read_calls == 1); + assert(snapshot().read_packets == 1 && snapshot().total_read_us == 33); + poll_delay = 5; + poll_action = [] { + poll_delay = 13; + btstack_run_loop_base_poll_data_sources(); + write_delay = 10; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23); + read_delay = 7; + read_length = 0; + uint32_t count = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &count) == 0); + clock_us += 2; + }; + btstack_run_loop_base_poll_data_sources(); + const auto result = snapshot(); + assert(polls == 2 && result.poll_calls == 1 && result.total_poll_us == 37); + assert(result.write_calls == 2 && result.total_write_us == 57); + assert(result.read_calls == 2 && result.total_read_us == 40 && result.read_packets == 1); +} + +void test_returns_and_outputs() { + begin(); + write_delay = 2; + read_delay = 3; + const int results[] = {0, -5, 17, INT_MIN, INT_MAX}; + for (int status : results) { + write_result = read_result = status; + buffer[0] = 0; + assert(cyw43_bluetooth_hci_write(buffer, SIZE_MAX) == status); + assert(last_buffer == buffer && last_write_length == SIZE_MAX && buffer[0] == 0xa5); + uint32_t length = 0xfeed; + assert(cyw43_bluetooth_hci_read(buffer, UINT32_MAX, &length) == status); + assert(last_buffer == buffer && last_capacity == UINT32_MAX && last_length == &length); + assert(buffer[0] == 0x5a && length == (status == 0 ? read_length : 0xfeed)); + } + auto result = snapshot(); + assert(writes == 5 && reads == 5 && result.write_calls == 5 && result.read_calls == 5); + assert(result.total_write_us == 10 && result.total_read_us == 15 && result.read_packets == 1); + haptics_transport_probe_end(); + result = snapshot(); + assert(cyw43_bluetooth_hci_write(nullptr, 0) == INT_MAX); + assert(cyw43_bluetooth_hci_read(nullptr, 0, nullptr) == INT_MAX); + btstack_run_loop_base_poll_data_sources(); + complete(8); + unchanged(result); +} + +void test_selected_completions() { + begin(); + uint8_t event[] = {0x13, 9, 2, 0x22, 0, 4, 0, 0x23, 0x01, 3, 0}; + clock_us = 6000; + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); + assert(snapshot().completion_events == 1 && snapshot().completed_packets == 3); + assert(snapshot().max_completion_gap_us == 0); + clock_us = 16000; + uint8_t other[] = {0x13, 5, 1, 0x22, 0, 7, 0}; + event_handler(HCI_EVENT_PACKET, 0, other, sizeof(other)); + clock_us = 30000; + complete(2); + const auto before = snapshot(); + assert(before.completion_events == 2 && before.completed_packets == 5); + assert(before.max_completion_gap_us == 24000); + event_handler(HCI_EVENT_PACKET, 0, nullptr, 7); + event_handler(HCI_EVENT_PACKET, 0, event, 2); + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event) - 1); + event_handler(2, 0, event, sizeof(event)); + event[1] = 8; // Inconsistent event parameter length. + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); + event[1] = 9; + event[2] = 255; // Handle count exceeds bounded packet storage. + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); + unchanged(before); + + begin(2, 0xffff); + complete(3); + assert(snapshot().completion_events == 0); + assert(snapshot().max_outstanding_acl == 0 && snapshot().min_free_acl == 0); +} + +void test_delayed_completion_and_disconnect() { + begin(); + connection.num_packets_sent = 5; + free_slots = 1; + write_delay = 7; + write_action = [] { + clock_us += 80000; + connection.num_packets_sent = 0; + free_slots = 6; + complete(5); // Completion delivery reenters an in-flight write. + connected = false; + connection.num_packets_sent = 255; // The former object is now invalid. + }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + haptics_transport_probe_end(); + const auto result = snapshot(); + assert(result.write_calls == 1 && result.total_write_us == 80007); + assert(result.completion_events == 1 && result.completed_packets == 5); + assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1); + complete(7); + unchanged(result); +} + +void test_run_changes_during_calls() { + begin(7); + write_delay = 100; + write_action = [] { haptics_transport_probe_end(); }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + assert(snapshot().active == 0 && snapshot().write_calls == 0); + + begin(7); + read_delay = 100; + read_action = [] { + haptics_transport_probe_end(); + haptics_transport_probe_begin(7, 9, kHandle); // Even identical public IDs. + haptics_transport_probe_permission(23); + }; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + auto result = snapshot(); + assert(result.active == 1 && result.run_id == 7 && result.connection_generation == 9); + assert(result.connection_handle == kHandle && result.read_calls == 0 && result.read_packets == 0); + assert(result.permission_callbacks == 1 && result.total_permission_wait_us == 23); + + poll_delay = 100; + poll_action = [] { + haptics_transport_probe_begin(8, 10, kHandle); + haptics_transport_probe_timer(17); + }; + btstack_run_loop_base_poll_data_sources(); + result = snapshot(); + assert(result.run_id == 8 && result.connection_generation == 10 && result.poll_calls == 0); + assert(result.timer_wakes == 1 && result.max_timer_lateness_us == 17); + assert(result.permission_callbacks == 0 && result.total_permission_wait_us == 0); + clock_us += 500; + poll_delay = 20; + btstack_run_loop_base_poll_data_sources(); + assert(snapshot().poll_calls == 1 && snapshot().total_poll_us == 20); + assert(snapshot().max_poll_gap_us == 0); + + haptics_transport_probe_end(); + poll_action = [] { haptics_transport_probe_begin(9, 11, kHandle); }; + btstack_run_loop_base_poll_data_sources(); + assert(snapshot().run_id == 9 && snapshot().poll_calls == 0); + assert(registrations == 1); +} + +void test_saturation_and_clock_wrap() { + begin(); + clock_us = uint64_t{UINT32_MAX} - 20; + write_delay = 40; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + assert(snapshot().total_write_us == 40); + haptics_transport_probe_timer(UINT32_MAX - 2); + haptics_transport_probe_timer(10); + haptics_transport_probe_permission(UINT32_MAX - 3); + haptics_transport_probe_permission(10); + haptics_transport_probe_send(UINT32_MAX - 5, 0, true); + haptics_transport_probe_send(10, 123, true); + const auto result = snapshot(); + assert(result.timer_wakes == 2 && result.total_timer_lateness_us == UINT32_MAX); + assert(result.max_timer_lateness_us == UINT32_MAX - 2); + assert(result.permission_callbacks == 2 && result.total_permission_wait_us == UINT32_MAX); + assert(result.max_permission_wait_us == UINT32_MAX - 3); + assert(result.send_calls == 2 && result.total_send_us == UINT32_MAX); + assert(result.max_send_us == UINT32_MAX - 5 && result.first_tone_send_return_us == 0); + clock_us = uint64_t{UINT32_MAX} - 5; + complete(1); + clock_us += 20; + complete(1); + assert(snapshot().max_completion_gap_us == 20); + clock_us = uint64_t{UINT32_MAX} - 5; + poll_delay = 5; + btstack_run_loop_base_poll_data_sources(); + clock_us += 40; + btstack_run_loop_base_poll_data_sources(); + assert(snapshot().max_poll_gap_us == 45); +} + +void receive_one() { + read_length = queued_input != 0 ? 8 : 0; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + if (length != 0) { + --queued_input; + ++serviced_input; + } + poll_action = receive_one; +} + +void test_bounded_receive_progress() { + begin(); + haptics_transport_probe_end(); // Input must progress outside an active run. + const auto before = snapshot(); + queued_input = 3; + serviced_input = 0; + poll_action = receive_one; + poll_requested = true; + unsigned timer_opportunities = 0; + while (poll_requested && timer_opportunities < 10) { + poll_requested = false; + const unsigned handled_before = serviced_input; + btstack_run_loop_base_poll_data_sources(); + assert(serviced_input - handled_before <= 1); + ++timer_opportunities; // SDK services timers before the next poll. + } + assert(queued_input == 0 && serviced_input == 3); + assert(timer_opportunities == 4 && !poll_requested); + unchanged(before); +} + +void test_advertised_capacity_capture() { + begin(); + haptics_transport_probe_end(); + uint8_t response[] = { + 0, 0, 0, HCI_EVENT_PACKET, 0x0e, 11, 1, 0x05, 0x10, + 0, 0xfd, 3, 0, 10, 0, 0, 0, + }; + const auto before = snapshot(); + uint32_t length = 0; + read_length = sizeof(response) - 1; + assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0); + unchanged(before); + read_length = sizeof(response); + response[9] = 1; // Failed command must not replace advertised capacity. + assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0); + unchanged(before); + response[9] = 0; + assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0); + assert(snapshot().controller_acl_packet_bytes == 1021); + assert(snapshot().controller_acl_packet_count == 10); + begin(45); + assert(snapshot().controller_acl_packet_count == 10); + assert(snapshot().controller_acl_packet_bytes == 1021); +} + +} // namespace + +int (*native_write)(uint8_t*, size_t) = fake_write; +int (*native_read)(uint8_t*, uint32_t, uint32_t*) = fake_read; +void (*native_poll)() = fake_poll; + +uint64_t time_us_64() { return clock_us; } + +extern "C" void btstack_run_loop_poll_data_sources_from_irq() { + assert(native_probe_lock_depth == 0); + poll_requested = true; +} + +extern "C" void hci_add_event_handler(btstack_packet_callback_registration_t* registration) { + assert(native_probe_lock_depth == 0 && ++registrations == 1); + event_handler = registration->callback; +} + +extern "C" hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle) { + assert(native_probe_lock_depth == 0); + return connected && handle == kHandle ? &connection : nullptr; +} + +extern "C" int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle) { + assert(native_probe_lock_depth == 0 && connected && handle == kHandle); + return free_slots; +} + +int main() { + test_inactive(); + test_delay_attribution(); + test_nested_boundaries(); + test_returns_and_outputs(); + test_selected_completions(); + test_delayed_completion_and_disconnect(); + test_run_changes_during_calls(); + test_saturation_and_clock_wrap(); + test_bounded_receive_progress(); + test_advertised_capacity_capture(); + std::puts("haptics transport probe tests passed"); +} diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 345254c..566b0cc 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -2115,3 +2115,604 @@ def test_find_requires_selector_for_multiple_picos( ): config_manager.find_pico(None, None) assert config_manager.find_pico(1, 8) is second + + +def haptics_response( + state: int = 0, *, run_id: int = 0, slot: int = 0xFF, + last_error: int = 0, sent_packets: int = 0, + first_tone_due_us: int = 0, first_tone_sent_us: int = 0, + elapsed_us: int = 0, connection_generation: int = 9, +) -> bytes: + return make_response( + config_manager.OP_HAPTICS_EXPERIMENT, + struct.pack( + "<17I4B", run_id, connection_generation, 100, 105, + sent_packets, 2, 3, 109, 4, + 123, 22000, 11001, 9876, first_tone_due_us, first_tone_sent_us, + 0x76543210, elapsed_us, state, slot, last_error, 0, + ), + schema=2, generation=run_id, + ) + + +class HapticsDevice(FakeDevice): + def __init__( + self, responses: list[bytes | Exception], *, + transport_response: bytes | Exception | None = None, + ) -> None: + super().__init__() + self.haptics_responses = responses + self.haptics_reads = 0 + self.haptics_reads_at_out: list[int] = [] + self.transport_response = transport_response + + def ctrl_transfer( + self, bm_request_type: int, request: int, value: int, index: int, + data_or_w_length: object, timeout: int, + ) -> bytes | int: + if request == config_manager.OP_HAPTICS_TRANSPORT_PROBE: + assert bm_request_type == 0xC0 + assert value == config_manager.REQUEST_VALUE + assert index == config_manager.REQUEST_INDEX + self.requests.append(request) + assert self.transport_response is not None + if isinstance(self.transport_response, Exception): + raise self.transport_response + return self.transport_response + if request != config_manager.OP_HAPTICS_EXPERIMENT: + return super().ctrl_transfer( + bm_request_type, request, value, index, data_or_w_length, timeout + ) + assert value == config_manager.REQUEST_VALUE + assert index == config_manager.REQUEST_INDEX + self.requests.append(request) + if bm_request_type == 0xC0: + self.haptics_reads += 1 + response = self.haptics_responses[0] + if len(self.haptics_responses) > 1: + self.haptics_responses.pop(0) + if isinstance(response, Exception): + raise response + return response + assert bm_request_type == 0x40 + encoded = bytes(data_or_w_length) + magic, version, operation, flags, reserved, size, schema, crc = ( + struct.unpack_from("<4sBBBBHHI", encoded) + ) + payload = encoded[16:] + assert (magic, version, operation, flags, reserved, size, schema) == ( + b"SPMG", 1, 0x40, 0, 0, 2, 0, + ) + assert crc == zlib.crc32(payload) & 0xFFFFFFFF + self.out_requests.append((request, payload, encoded)) + self.haptics_reads_at_out.append(self.haptics_reads) + return len(encoded) + + +@pytest.fixture +def haptics_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + monkeypatch.setattr(config_manager.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(config_manager.time, "sleep", sleep) + return clock + + +def test_haptics_schema_timing_and_wraparound() -> None: + device = HapticsDevice([ + haptics_response( + 2, run_id=17, slot=2, sent_packets=101, + first_tone_due_us=0xFFFFFFF0, first_tone_sent_us=0x30, + elapsed_us=1100000, + ), + ]) + snapshot = config_manager.read_haptics_experiment(device) + assert snapshot.state_name == "running" + assert snapshot.run_id == 17 and snapshot.slot == 2 + assert snapshot.sent_packets == 101 + assert snapshot.generated_packets == 105 and snapshot.skipped_packets == 2 + assert snapshot.send_failures == 3 and snapshot.can_send_requests == 109 + assert snapshot.synchronous_callbacks == 4 + assert snapshot.max_generate_us == 123 + assert snapshot.max_send_gap_us == 22000 + assert snapshot.max_lateness_us == 11001 + assert snapshot.max_request_wait_us == 9876 + assert snapshot.first_tone_submission_delay_us == 64 + assert snapshot.last_sent_us == 0x76543210 + assert snapshot.elapsed_us == 1100000 + assert snapshot.to_json_object()["first_tone_submission_delay_us"] == 64 + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("schema", "unsupported haptics experiment schema"), + ("size", "payload size"), + ("state", "state"), + ("slot", "slot"), + ("active_without_slot", "slot"), + ("reserved", "reserved"), + ("flags", "reserved"), + ], +) +def test_haptics_rejects_malformed_diagnostics(mutation: str, message: str) -> None: + payload = bytearray(haptics_response(2, slot=0)[20:]) + schema, flags = 2, 0 + if mutation == "schema": + schema = 1 + elif mutation == "size": + payload.pop() + elif mutation == "state": + payload[68] = 8 + elif mutation == "slot": + payload[69] = 4 + elif mutation == "active_without_slot": + payload[69] = 0xFF + elif mutation == "reserved": + payload[71] = 1 + else: + flags = 1 + response = make_response( + 0x40, bytes(payload), schema=schema, flags=flags, + ) + with pytest.raises(config_manager.ConfigManagerError, match=message): + config_manager.read_haptics_experiment(HapticsDevice([response])) + + +def test_haptics_disabled_firmware_is_readable_but_cannot_start( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([haptics_response(6)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "status", "--json"]) == 0 + captured = capsys.readouterr() + status = json.loads(captured.out) + assert status["state_name"] == "unsupported" + assert status["firmware_supported"] is False + assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "unsupported" in capsys.readouterr().err + assert device.out_requests == [] + + +def test_haptics_old_firmware_stall_is_actionable_without_hiding_disconnect( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([ + config_manager.usb.core.USBError("Pipe error", error_code=-9, errno=32), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in capsys.readouterr().err + assert device.out_requests == [] + disconnected = config_manager.usb.core.USBError( + "No such device", error_code=-4, errno=19, + ) + with pytest.raises(config_manager.usb.core.USBError) as raised: + config_manager.read_haptics_experiment(HapticsDevice([disconnected])) + assert raised.value is disconnected + + +def test_haptics_start_waits_for_firmware_not_usb_ack( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([ + haptics_response(3, run_id=40, slot=0), + haptics_response(1, run_id=41, slot=0), + haptics_response(2, run_id=41, slot=0, sent_packets=1), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start"]) == 0 + captured = capsys.readouterr() + assert device.haptics_reads_at_out == [1] + assert device.out_requests[0][1] == b"\x01\x00" + assert device.haptics_reads == 3 + assert haptics_clock[0] >= 0.1 + assert "pending firmware confirmation" in captured.out + assert "Haptics experiment: running" in captured.out + assert "not physical actuator" in captured.out + assert "1.024 s" in captured.out and "6.144 s" in captured.out + + +def test_haptics_start_watch_captures_correlated_measurement_series( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([ + haptics_response(), + haptics_response(1, run_id=1, slot=3), + haptics_response( + 2, run_id=1, slot=3, sent_packets=101, + first_tone_due_us=0xFFFFFFF0, first_tone_sent_us=0x30, + elapsed_us=1100000, + ), + haptics_response(3, run_id=1, slot=3, sent_packets=574, elapsed_us=6144000), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "haptics-experiment", "start", "--slot", "3", "--watch", "--json", + ]) == 0 + captured = capsys.readouterr() + series = [json.loads(line) for line in captured.out.splitlines()] + assert [row["state_name"] for row in series] == [ + "pending", "running", "completed", + ] + assert [row["sent_packets"] for row in series] == [0, 101, 574] + assert series[1]["first_tone_submission_delay_us"] == 64 + assert series[-1]["elapsed_us"] == 6144000 + assert series[-1]["pattern"]["duration_us"] == 6144000 + assert series[0]["host_monotonic_s"] < series[-1]["host_monotonic_s"] + assert "pending firmware confirmation" in captured.err + + +@pytest.mark.parametrize( + ("state", "error", "description"), + [ + (6, 1, "unsupported"), + (6, 2, "MTU"), + (5, 3, "connection"), + (7, 4, "timed out"), + (7, 5, "send failed"), + (7, 6, "wait for prior output to drain"), + ], +) +def test_haptics_start_reports_asynchronous_rejection( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], state: int, error: int, description: str, +) -> None: + device = HapticsDevice([ + haptics_response(), + haptics_response(1, run_id=1, slot=0), + haptics_response(state, run_id=1, slot=0, last_error=error), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start"]) == 1 + captured = capsys.readouterr() + assert description in captured.err + assert f"last_error={error}" in captured.err + assert "Haptics experiment: running" not in captured.out + + +def test_haptics_status_watch_reports_connection_loss_after_running( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([ + haptics_response(2, run_id=7, slot=0, sent_packets=11), + haptics_response(5, run_id=7, slot=0, sent_packets=13, last_error=3), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "haptics-experiment", "status", "--watch", "--json", + ]) == 1 + captured = capsys.readouterr() + assert [json.loads(line)["state_name"] for line in captured.out.splitlines()] == [ + "running", "disconnected", + ] + assert "connection missing or lost" in captured.err + + +def test_haptics_start_does_not_mistake_stale_completion_for_new_run( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([haptics_response(3, run_id=8, slot=0)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "--timeout", "0.2", "haptics-experiment", "start", + ]) == 1 + assert "not confirmed before --timeout" in capsys.readouterr().err + assert haptics_clock[0] == pytest.approx(0.2) + + +def test_haptics_watch_is_bounded_and_rejects_run_replacement( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([haptics_response(2, run_id=1, slot=0)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "--timeout", "0.2", "haptics-experiment", "status", "--watch", + ]) == 1 + assert "did not reach a terminal state" in capsys.readouterr().err + device.haptics_responses = [ + haptics_response(2, run_id=1, slot=0), + haptics_response(3, run_id=2, slot=0), + ] + assert config_manager.main([ + "haptics-experiment", "status", "--watch", + ]) == 1 + assert "run changed" in capsys.readouterr().err + + +def test_haptics_busy_start_and_wrong_slot_stop_do_not_mutate_active_run( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([haptics_response(2, run_id=1, slot=3)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "already running" in capsys.readouterr().err + assert config_manager.main(["haptics-experiment", "stop"]) == 1 + assert "not requested slot 0" in capsys.readouterr().err + assert device.out_requests == [] + + +def test_haptics_stop_waits_for_service_completion( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([ + haptics_response(2, run_id=1, slot=2), + haptics_response(2, run_id=1, slot=2), + haptics_response(4, run_id=1, slot=2), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "haptics-experiment", "stop", "--slot", "2", "--json", + ]) == 0 + captured = capsys.readouterr() + assert json.loads(captured.out)["state_name"] == "stopped" + assert device.out_requests[0][1] == b"\x00\x02" + assert device.haptics_reads == 3 + + +def test_haptics_invalid_slot_is_rejected_before_discovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_discovery(*args: object) -> None: + pytest.fail("invalid haptics slot reached USB discovery") + + monkeypatch.setattr(config_manager, "find_pico", unexpected_discovery) + with pytest.raises(SystemExit) as raised: + config_manager.main(["haptics-experiment", "start", "--slot", "4"]) + assert raised.value.code == 2 + + +def test_haptics_retry_after_unsupported_controller_is_not_disabled_firmware( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([ + haptics_response(6, run_id=7, slot=0, last_error=1), + haptics_response(2, run_id=8, slot=0), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == 8 + assert len(device.out_requests) == 1 + + +def test_haptics_start_correlates_rollover_and_rejects_superseded_run( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([ + haptics_response(3, run_id=0xFFFFFFFF, slot=0), + haptics_response(2, run_id=0, slot=0), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == 0 + device.haptics_responses = [ + haptics_response(3, run_id=10, slot=0), + haptics_response(3, run_id=12, slot=0), + ] + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "run changed" in capsys.readouterr().err + + +@pytest.mark.parametrize("timeout", ["nan", "inf"]) +def test_haptics_timeout_must_be_bounded_before_discovery( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + timeout: str, +) -> None: + def unexpected_discovery(*args: object) -> None: + pytest.fail("unbounded timeout reached USB discovery") + + monkeypatch.setattr(config_manager, "find_pico", unexpected_discovery) + assert config_manager.main([ + "--timeout", timeout, "haptics-experiment", "status", "--watch", + ]) == 2 + assert "must be finite" in capsys.readouterr().err + + +def transport_response( + *, run_id: int = 17, connection_generation: int = 9, + connection_handle: int = 0x1234, active: int = 0, +) -> bytes: + return make_response( + config_manager.OP_HAPTICS_TRANSPORT_PROBE, + struct.pack( + "<32I", run_id, connection_generation, connection_handle, + 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 0xFFFFFFF0, active, 27, 0xFFFFFFFF, 29, 30, + 1021, 10, + ), + schema=2, generation=run_id, + ) + + +def test_haptics_profile_decodes_exact_wire_order_and_correlates_live_run( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([ + haptics_response(2, run_id=17, slot=0, sent_packets=10), + haptics_response(2, run_id=17, slot=0, sent_packets=11), + ], transport_response=transport_response(active=1)) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0 + row = json.loads(capsys.readouterr().out) + assert row["run_id"] == 17 and row["connection_generation"] == 9 + assert row["sent_packets"] == 11 + transport = row["transport"] + transport.pop("evidence_note") + assert transport == { + "schema_version": 2, + "run_id": 17, + "connection_generation": 9, + "connection_handle": 0x1234, + "timer_wakes": 4, + "max_timer_lateness_us": 5, + "total_timer_lateness_us": 6, + "send_calls": 7, + "max_send_us": 8, + "total_send_us": 9, + "write_calls": 10, + "max_write_us": 11, + "total_write_us": 12, + "read_calls": 13, + "read_packets": 14, + "max_read_us": 15, + "total_read_us": 16, + "poll_calls": 17, + "max_poll_us": 18, + "total_poll_us": 19, + "completion_events": 20, + "completed_packets": 21, + "max_completion_gap_us": 22, + "max_outstanding_acl": 23, + "min_free_acl": 24, + "first_tone_send_return_us": 0xFFFFFFF0, + "active": True, + "max_permission_wait_us": 27, + "total_permission_wait_us": 0xFFFFFFFF, + "permission_callbacks": 29, + "max_poll_gap_us": 30, + "controller_acl_packet_bytes": 1021, + "controller_acl_packet_count": 10, + } + assert transport["active"] is True + assert device.out_requests == [] + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("schema", "unsupported haptics transport probe schema"), + ("short", "payload size"), + ("long", "payload size"), + ("flags", "reserved flags"), + ("active", "active boolean"), + ("handle", "connection handle"), + ("envelope_run", "envelope run ID mismatch"), + ("status", "device busy"), + ("crc", "CRC mismatch"), + ], +) +def test_haptics_profile_rejects_malformed_transport( + mutation: str, message: str, +) -> None: + payload = bytearray(transport_response()[20:]) + schema, flags, generation, status = 2, 0, 17, config_manager.STATUS_OK + if mutation == "schema": + schema = 1 + elif mutation == "short": + payload.pop() + elif mutation == "long": + payload.extend(b"\0\0\0\0") + elif mutation == "flags": + flags = 1 + elif mutation == "active": + struct.pack_into(" None: + device = HapticsDevice( + [haptics_response(5, run_id=17, slot=0, last_error=3)], + transport_response=transport_response(connection_handle=0xFFFF), + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0 + row = json.loads(capsys.readouterr().out) + assert row["state_name"] == "disconnected" and row["last_error"] == 3 + assert row["transport"]["connection_handle"] == 0xFFFF + assert row["transport"]["active"] is False + + +@pytest.mark.parametrize("unsupported", ["status", "stall"]) +def test_haptics_profile_unsupported_keeps_legacy_status_readable( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + unsupported: str, +) -> None: + response: bytes | Exception = make_response( + 0x41, status=config_manager.STATUS_UNSUPPORTED_SCHEMA, schema=1, + ) + if unsupported == "stall": + response = config_manager.usb.core.USBError( + "Pipe error", error_code=-9, errno=32, + ) + device = HapticsDevice( + [haptics_response(3, run_id=17, slot=0)], transport_response=response, + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "0x41" in captured.err + assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err + assert config_manager.main(["haptics-experiment", "status", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == 17 + assert device.out_requests == [] + + +def test_haptics_profile_does_not_hide_usb_disconnect_as_unsupported() -> None: + disconnected = config_manager.usb.core.USBError( + "No such device", error_code=-4, errno=19, + ) + device = HapticsDevice( + [haptics_response(3, run_id=17, slot=0)], transport_response=disconnected, + ) + with pytest.raises(config_manager.usb.core.USBError) as raised: + config_manager.read_haptics_experiment_profile(device) + assert raised.value is disconnected + + +@pytest.mark.parametrize( + ("after_run", "after_generation", "probe_run", "probe_generation"), + [ + (17, 9, 16, 9), # A stale probe must not attach to the current run. + (18, 9, 17, 9), # A new run starts after reading the probe. + (18, 9, 18, 9), # A new run starts before reading the probe. + (17, 10, 17, 9), # A connection changes after reading the probe. + (17, 10, 17, 10), # A connection changes before reading the probe. + (17, 9, 17, 8), # Matching run IDs cannot mask stale connection data. + ], +) +def test_haptics_profile_never_publishes_cross_run_metrics( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + after_run: int, after_generation: int, probe_run: int, probe_generation: int, +) -> None: + device = HapticsDevice([ + haptics_response(2, run_id=17, slot=0), + haptics_response( + 2, run_id=after_run, slot=0, connection_generation=after_generation, + ), + ], transport_response=transport_response( + run_id=probe_run, connection_generation=probe_generation, + )) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "cannot attribute measurements" in captured.err diff --git a/tests/test_haptics_experiment_native.py b/tests/test_haptics_experiment_native.py new file mode 100644 index 0000000..9bc37ec --- /dev/null +++ b/tests/test_haptics_experiment_native.py @@ -0,0 +1,44 @@ +import shutil +import subprocess +import zlib +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"]) +def test_haptics_experiment_native(tmp_path: Path, ram: int) -> 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 / "haptics_experiment_test" + corpus = tmp_path / "native_reports.bin" + subprocess.run( + [ + compiler, + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_HAPTICS_EXPERIMENT=1", + f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}", + f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "haptics_experiment_test.cpp"), + str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable), str(corpus)], check=True, cwd=root) + reports = corpus.read_bytes() + assert len(reports) == 288 * 143 + # Independent standard-library CRC across real module-generated packets: + # A2 is covered once, CRC itself excluded, and stored little-endian. + for offset in range(0, len(reports), 143): + report = reports[offset : offset + 143] + assert int.from_bytes(report[-4:], "little") == zlib.crc32(report[:-4]) diff --git a/tests/test_haptics_transport_probe_native.py b/tests/test_haptics_transport_probe_native.py new file mode 100644 index 0000000..4375432 --- /dev/null +++ b/tests/test_haptics_transport_probe_native.py @@ -0,0 +1,35 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_haptics_transport_probe_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 / "haptics_transport_probe_test" + stubs = root / "tests" / "haptics_transport_probe_native_stubs" + subprocess.run( + [ + compiler, + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{stubs}", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "haptics_transport_probe_test.cpp"), + str(stubs / "sdk.cpp"), + str(root / "src" / "firmware" / "input" / "haptics_transport_probe.cpp"), + "-Wl,--wrap=cyw43_bluetooth_hci_write", + "-Wl,--wrap=cyw43_bluetooth_hci_read", + "-Wl,--wrap=btstack_run_loop_base_poll_data_sources", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_usb_configuration_management_native.py b/tests/test_usb_configuration_management_native.py index 5d279f0..6f1500b 100644 --- a/tests/test_usb_configuration_management_native.py +++ b/tests/test_usb_configuration_management_native.py @@ -2,9 +2,14 @@ import shutil import subprocess from pathlib import Path +import pytest -def test_usb_configuration_management_native(tmp_path: Path) -> None: + +@pytest.mark.parametrize("experiment_enabled", [False, True]) +def test_usb_configuration_management_native( + tmp_path: Path, experiment_enabled: bool, +) -> 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" @@ -18,6 +23,7 @@ def test_usb_configuration_management_native(tmp_path: Path) -> None: "-Wextra", "-Werror", "-pedantic", + *(["-DSWITCH_PICO_HAPTICS_EXPERIMENT=1"] if experiment_enabled else []), f"-I{root / 'tests' / 'usb_management_native_stubs'}", f"-I{root / 'src' / 'firmware'}", str(root / "tests" / "usb_configuration_management_test.cpp"), diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 670a9c3..c06f957 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -6,6 +6,10 @@ #include #include "usb/usb_output_driver.h" +#include "input/haptics_experiment.h" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/haptics_transport_probe.h" +#endif namespace { @@ -58,6 +62,11 @@ uint32_t profile_metadata_transaction_id = 0; uint8_t profile_metadata_index = 0; std::string profile_metadata_value; bool identify_requested = false; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +HapticsExperimentDiagnostics current_haptics{}; +uint32_t haptics_request_count = 0; +HapticsTransportProbe current_transport{}; +#endif void require(bool condition, const char* message) { if (!condition) { @@ -712,6 +721,258 @@ void test_profile_vendor_requests() { "short profile selection request was accepted"); } +std::vector read_haptics_payload() { + using namespace UsbConfigurationManagement; + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_IN, kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "experiment diagnostics IN was rejected"); + require(control_payload.size() == kResponseHeaderSize + 72 && + control_payload[5] == 0x40 && + control_payload[6] == static_cast(Status::kOk) && + control_payload[7] == 0 && + read_u16(control_payload, 8) == 72 && + read_u16(control_payload, 10) == 2, + "experiment schema-2 envelope is invalid"); + std::vector payload( + control_payload.begin() + kResponseHeaderSize, control_payload.end()); + require(read_u32(control_payload, 16) == + configuration_crc32(payload.data(), payload.size()), + "experiment response CRC is invalid"); + return payload; +} + +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +void perform_haptics_out(uint8_t action, uint8_t slot, bool accepted = true) { + using namespace UsbConfigurationManagement; + next_out_payload = make_request(Operation::kHapticsExperiment, {action, slot}); + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "experiment OUT setup was rejected"); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request) == accepted, + "experiment must reject invalid or busy requests before status ACK"); + const uint32_t requests_after_data = haptics_request_count; + if (accepted) { + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request) && + haptics_request_count == requests_after_data, + "duplicate DATA replayed experiment control"); + } + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request) == accepted, + "experiment ACK did not preserve DATA-stage result"); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request) && + haptics_request_count == requests_after_data, + "ACK replayed experiment control"); +} +#endif + +void test_haptics_experiment_requests() { + using namespace UsbConfigurationManagement; + for (uint16_t payload_size : {0, 1, 3}) { + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + kRequestHeaderSize + payload_size); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "malformed experiment control size was accepted"); + } + std::vector expected(72, 0); + expected[69] = 0xff; +#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT + expected[68] = 6; + require(read_haptics_payload() == expected, + "disabled firmware must expose only the unsupported snapshot"); + for (uint8_t action : {0, 1}) { + next_out_payload = make_request(Operation::kHapticsExperiment, {action, 0}); + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "disabled firmware accepted experiment control"); + } +#else + require(read_haptics_payload() == expected, + "enabled firmware must initially be idle without a selected slot"); + perform_haptics_out(2, 0, false); + perform_haptics_out(1, 4, false); + perform_haptics_out(0, 0xff, false); + require(haptics_request_count == 0, + "malformed control reached the experiment service"); + + next_out_payload = make_request(Operation::kHapticsExperiment, {1, 2}); + next_out_payload.back() ^= 1; + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + !usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request), + "bad experiment request CRC was accepted"); + require(haptics_request_count == 0, + "bad CRC control reached the experiment service"); + + perform_haptics_out(1, 2); + auto payload = read_haptics_payload(); + require(payload[68] == 1 && payload[69] == 2 && + read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0 && + haptics_request_count == 1, + "USB acceptance must remain pending until the service starts"); + perform_haptics_out(1, 1, false); + payload = read_haptics_payload(); + require(payload[68] == 1 && payload[69] == 2 && + read_u32(payload, 0) == 1, + "busy start overwrote the accepted run"); + + // Model the independently progressing Core 1 service, not a USB echo. + current_haptics = { + 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, + 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, + 1100000, HapticsExperimentState::kRunning, 2, 0, + }; + const uint32_t fields[] = { + 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, + 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, 1100000, + }; + for (size_t index = 0; index < 17; ++index) { + write_u32(&expected, index * 4, fields[index]); + } + expected[68] = 2; + expected[69] = 2; + require(read_haptics_payload() == expected, + "schema-2 timing fields are not in little-endian wire order"); + + perform_haptics_out(0, 2); + payload = read_haptics_payload(); + require(payload[68] == 2 && read_u32(payload, 0) == 1, + "USB stop ACK must not fabricate terminal completion"); + current_haptics.state = HapticsExperimentState::kStopped; + require(read_haptics_payload()[68] == 4, + "service stop transition was not observable"); + + next_out_payload = make_request(Operation::kHapticsExperiment, {1, 3}); + request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + const uint32_t requests_before_cancel = haptics_request_count; + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "canceled experiment setup was rejected"); + read_haptics_payload(); // A new SETUP cancels the previous OUT transfer. + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request) && + !usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request) && + haptics_request_count == requests_before_cancel, + "canceled experiment request reused stale payload"); + + perform_haptics_out(1, 3); + current_haptics.state = HapticsExperimentState::kDisconnected; + current_haptics.last_error = 3; + payload = read_haptics_payload(); + require(payload[68] == 5 && payload[69] == 3 && payload[70] == 3 && + read_u32(payload, 0) == 2, + "asynchronous connection failure lost request correlation"); +#endif +} + +void test_haptics_transport_probe_requests() { + using namespace UsbConfigurationManagement; + for (uint16_t length : {0, 16, 18, 120}) { + tusb_control_request_t request = setup_request( + Operation::kHapticsTransportProbe, TUSB_DIR_OUT, length); + for (uint8_t stage : { + CONTROL_STAGE_SETUP, CONTROL_STAGE_DATA, CONTROL_STAGE_ACK}) { + require(!usb_configuration_management_vendor_control( + 0, stage, &request), + "IN-only transport probe accepted an OUT transfer"); + } + } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + current_transport.run_id = 0x10203040; + current_transport.connection_generation = 0x50607080; + current_transport.connection_handle = 0xffff; + current_transport.timer_wakes = 4; + current_transport.max_timer_lateness_us = 5; + current_transport.total_timer_lateness_us = 6; + current_transport.send_calls = 7; + current_transport.max_send_us = 8; + current_transport.total_send_us = 9; + current_transport.write_calls = 10; + current_transport.max_write_us = 11; + current_transport.total_write_us = 12; + current_transport.read_calls = 13; + current_transport.read_packets = 14; + current_transport.max_read_us = 15; + current_transport.total_read_us = 16; + current_transport.poll_calls = 17; + current_transport.max_poll_us = 18; + current_transport.total_poll_us = 19; + current_transport.completion_events = 20; + current_transport.completed_packets = 21; + current_transport.max_completion_gap_us = 22; + current_transport.max_outstanding_acl = 23; + current_transport.min_free_acl = 24; + current_transport.first_tone_send_return_us = 0xfffffff0; + current_transport.active = 1; + current_transport.max_permission_wait_us = 27; + current_transport.total_permission_wait_us = 0xffffffff; + current_transport.permission_callbacks = 29; + current_transport.max_poll_gap_us = 30; + current_transport.controller_acl_packet_bytes = 1021; + current_transport.controller_acl_packet_count = 10; +#endif + tusb_control_request_t request = setup_request( + Operation::kHapticsTransportProbe, TUSB_DIR_IN, kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "transport probe IN was rejected"); + require(control_payload.size() >= kResponseHeaderSize, + "transport probe response header is truncated"); + require(control_payload[5] == 0x41 && control_payload[7] == 0 && + read_u16(control_payload, 10) == 2, + "transport probe operation, flags, or schema are invalid"); + const std::vector payload( + control_payload.begin() + kResponseHeaderSize, control_payload.end()); + require(read_u32(control_payload, 16) == + configuration_crc32(payload.data(), payload.size()), + "transport probe response CRC is invalid"); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + require(control_payload.size() == kResponseHeaderSize + 128 && + read_u16(control_payload, 8) == 128 && + control_payload[6] == static_cast(Status::kOk) && + read_u32(control_payload, 12) == 0x10203040, + "transport probe schema-2 envelope is invalid"); + const uint32_t fields[] = { + 0x10203040, 0x50607080, 0xffff, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 0xfffffff0, 1, 27, 0xffffffff, 29, 30, + 1021, 10, + }; + std::vector expected(128); + for (size_t index = 0; index < 32; ++index) { + write_u32(&expected, index * 4, fields[index]); + } + require(payload == expected, + "transport probe fields are not in explicit little-endian wire order"); +#else + require(control_payload.size() == kResponseHeaderSize && + read_u16(control_payload, 8) == 0 && + read_u32(control_payload, 12) == 0 && + control_payload[6] == + static_cast(Status::kUnsupportedSchema), + "disabled firmware must report the transport probe as unsupported"); +#endif +} + } // namespace uint32_t configuration_crc32(const uint8_t* data, size_t size) { @@ -899,6 +1160,33 @@ void bluepad32_input_backend_diagnostics( *out = current_diagnostics; } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +bool haptics_experiment_request(uint8_t action, uint8_t slot) { + ++haptics_request_count; + if (action == 1 && + (current_haptics.state == HapticsExperimentState::kPending || + current_haptics.state == HapticsExperimentState::kRunning)) { + return false; + } + if (action == 1) { + const uint32_t run_id = current_haptics.run_id + 1; + current_haptics = {}; + current_haptics.run_id = run_id; + current_haptics.slot = slot; + current_haptics.state = HapticsExperimentState::kPending; + } + return true; +} + +void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) { + *output = current_haptics; +} + +void haptics_transport_probe_snapshot(HapticsTransportProbe* output) { + *output = current_transport; +} +#endif + bool adapter_reboot_to_bootsel() { bootsel_reboot_requested = true; return true; @@ -941,5 +1229,7 @@ int main() { test_vendor_requests(); test_mode_vendor_requests(); test_profile_vendor_requests(); + test_haptics_experiment_requests(); + test_haptics_transport_probe_requests(); return 0; }