// Tundra IMU reader — Watchman HID 28DE:2300 MI_00, report 0x20. // // Facts this implementation encodes (spike S1, spikes/s1-imu-no-steam/FINDINGS.md): // - Streams unconditionally from the first cold ReadFile; no wake sequence. // - Only report ID 0x20, 52 bytes: 3 samples x {int16 accel[3], int16 gyro[3], // uint32 timecode, uint8 seq} at offsets 1 + 17*i. // - Reports carry a SLIDING 3-sample window: consecutive reports overlap, so // samples must be deduped by (timecode, seq). Unique rate ~993.8 Hz. // - timecode is a 48 MHz device clock, uint32, wraps every ~89.5 s. // - seq is per-sample uint8, +1 per sample, wraps at 256. // - HidD_GetInputReport fails (error 87) — interrupt reads only. // - Accel scale 4096 LSB/g (±8 g). Gyro ±2000 °/s over int16, i.e. // 0.00106526 rad/s/LSB (M2 gate 2, measured vs SteamVR, +0.07% off). #pragma once #include #include #include #include #include #include #include namespace sauna { // Opens the Beyond's Watchman 28DE:2300 MI_00 with the same MCU-pairing // disambiguation the IMU reader uses (explicit LHR serial overrides; never // latches a desk Index controller). Returns a Win32 HANDLE as void*, or // nullptr. outSerial (optional) receives the chosen unit's LHR serial. // Caller owns the handle (CloseHandle). Used by calibration tooling for // HID feature-report config reads (M3 step 6). void* OpenWatchmanHid(const char* serial = "", std::string* outSerial = nullptr); struct ImuSample { int16_t accel[3]; int16_t gyro[3]; uint32_t timecode; // 48 MHz device clock, wraps ~89.5 s uint8_t seq; }; struct ImuStats { uint64_t reports = 0; // 0x20 reports received uint64_t samples = 0; // unique samples after window dedupe uint64_t seq_gaps = 0; // unique-sample seq discontinuities (dropped data) uint64_t read_timeouts = 0; // 1 s interrupt-read timeouts (stream stalled) uint64_t other_reports = 0; // non-0x20 report IDs seen }; // Background interrupt-read thread over the Watchman HID. Latest unique // sample + counters published under a mutex (1 kHz — contention irrelevant). class TundraImu { public: TundraImu() = default; ~TundraImu() { stop(); } TundraImu(const TundraImu&) = delete; TundraImu& operator=(const TundraImu&) = delete; // Opens the Beyond's 28DE:2300 MI_00 and starts the read thread. // Any Watchman device (wired Index controller, Tundra tracker) exposes the // same VID/PID — field finding: a desk controller enumerates ahead of the // Beyond and idles at 250 Hz. Auto-disambiguation pairs the Tundra with // the Beyond MCU (35BD:0101) by USB location-path hub prefix; a non-empty // LHR serial overrides. Fails loudly (listing serials) when ambiguous. bool start(const char* serial = ""); void stop(); bool running() const { return running_.load(std::memory_order_relaxed); } // Latest unique sample; false until the first sample lands. ageSec (if // given) = host seconds since that sample arrived — stale-stream detector // (a sleeping Tundra leaves the last sample frozen while reads time out). bool latest(ImuSample* out, double* ageSec = nullptr) const; ImuStats stats() const; // Max wall-clock gap between sample deliveries since the last call (ms), // then resets. Rubber-band diagnosis (M3): the HID stack can buffer // reports and flush them in bursts — device timecodes stay clean (the // AHRS integrates correctly) but pose() serves a stale orientation // between bursts and fast-forwards on the flush. That delivery jitter is // invisible to every device-side counter; it shows here. uint32_t maxDeliveryGapMs() { return maxGapMs_.exchange(0); } // LHR serial of the unit currently latched (empty before first connect). // Updated on reconnect — a different Beyond may be swapped in mid-session, // so calibration consumers must re-check after any reconnect. std::string connectedSerial() const; // Per-unique-sample callback, invoked from the read thread in stream order // (~994 Hz) — for consumers that must not miss samples (integration: AHRS, // gyro calibration). Set BEFORE start(); not synchronized against a running // read thread. Keep it fast — it delays the next HID read. void setSampleSink(std::function sink) { sink_ = std::move(sink); } private: void readLoop(); void* dev_ = nullptr; // HANDLE void* readEvent_ = nullptr; // HANDLE for overlapped reads std::wstring serial_; // user's selection filter, reused on reconnects std::thread thread_; std::function sink_; std::atomic running_{false}; std::atomic stopRequested_{false}; std::atomic maxGapMs_{0}; mutable std::mutex mu_; std::string connectedSerial_; ImuSample latest_{}; bool haveSample_ = false; std::chrono::steady_clock::time_point latestAt_{}; ImuStats stats_{}; }; } // namespace sauna