// Beyond MCU proximity reader — control HID 35BD:0101. // // Protocol (ported from sibling project bey-closer-t1, verified against the // Beyond firmware there): // - Feature report id 0, cmd 'R' + uint16 BE period(ms) starts periodic // telemetry; firmware replies '$' (ok) / 'E' on the interrupt pipe. // - Periodic input reports start with '#' (0x23); bytes 4-5 = prox distance, // big-endian uint16, already calibration-subtracted by firmware. // - Worn detection: 8-sample moving average with hysteresis around a // threshold (firmware default 1500, hysteresis 100; per-unit overrides // live in the MCU user-signature flash — flash read is M3 scope, defaults // used here). // // Wearing state drives doff safety: panels sleep when doffed (prox kills // vsync — the free-run hazard), so the compositor wants the explicit signal, // not just the free-run symptom. #pragma once #include #include #include #include #include namespace sauna { class McuProx { public: McuProx() = default; ~McuProx() { stop(); } McuProx(const McuProx&) = delete; McuProx& operator=(const McuProx&) = delete; // Opens 35BD:0101, starts periodic telemetry at periodMs, starts the read // thread. False (message on stderr) if absent or the rate command fails. bool start(uint16_t periodMs = 50); void stop(); // SteamVR coexistence: SteamVR's shipped BeyondProximity driver opens the // SAME 0101 MCU interface this class uses. Two concurrent openers wedge the // MCU app (telemetry stops, panels stick mid-doze, needs a power-cycle). // suspend() releases our 0101 handle so BeyondProximity gets exclusive // access; resume() lets the read thread reopen + re-probe once the headset // is handed back. Call suspend() on the SteamVR release trigger (before the // compositor loads its driver) and resume() after the compositor exits, then // wait for reports() to advance before commanding the MCU again. void suspend() { suspended_.store(true, std::memory_order_relaxed); } void resume() { suspended_.store(false, std::memory_order_relaxed); } bool suspended() const { return suspended_.load(std::memory_order_relaxed); } bool running() const { return running_.load(std::memory_order_relaxed); } // True when the averaged prox distance says the headset is worn. bool worn() const { return worn_.load(std::memory_order_relaxed); } // Latest raw prox distance + count of '#' reports seen. uint16_t proxDistance() const { return prox_.load(std::memory_order_relaxed); } uint64_t reports() const { return reports_.load(std::memory_order_relaxed); } // Latest display-status word from telemetry bytes 24-25 (firmware // video_get_display_status): bit0 = panels emitting (DISPLAYS_ON), // bit1 = prox says on (PROX_ON), bit3 = DSC. 0 until first report. uint16_t displayStatus() const { return displayStatus_.load(std::memory_order_relaxed); } bool displaysOn() const { return (displayStatus() & 1u) != 0; } // Prox gating bypass, firmware cmds 'p' (disable: firmware believes worn, // displays on regardless) / '[' (re-enable normal gating). Disable persists // until '[' or MCU power cycle — callers MUST restore on exit. Bench-rig // use (headset on a stool); never ship enabled. bool setProxBypass(bool bypass); // Doze warm shallow-sleep (firmware >= 0.4.0, ADR-0005): 'H' = display-sleep // (firmware sweeps panels dark + OLED display-off, fan->idle, LED->breathing, // keeping video_enabled true), 'h' = display-wake. Unlike POWER_OFF the video // pipeline stays warm, so wake skips the DSC/link retrain. False if the device // is gone or the report fails. bool setDoze(bool sleep); // Firmware version string (e.g. "0.4.1"), queried once during start() over // HID_CODE_FOR_SW_VER ('*'). Empty if the query failed (very old firmware or // a flaky probe). Read-only after start() returns. const std::string& firmwareVersion() const { return fwVersion_; } // True when the firmware advertises >= 0.4.0 — the doze command contract // (ADR-0005). The host capability-gates every setDoze() on this; false means // fall back to parked. Resolved in start() from firmwareVersion(). bool dozeCapable() const { return dozeCapable_.load(std::memory_order_relaxed); } // OLED brightness, 0–1023 (clamped), firmware cmd 'I' (16-bit BE). // Firmware default is 266 (~26%); brightness here is the panel's emission // duty (blanking-period adjustment) — i.e. the persistence/burn-in knob // (M3 step 4). False if the device is gone or the report fails. bool setBrightness(uint16_t value); private: void readLoop(); bool sendFeature(const uint8_t* payload, size_t len); uint16_t periodMs_ = 50; // kept for reconnect re-probe void* dev_ = nullptr; // HANDLE void* readEvent_ = nullptr; // HANDLE std::thread thread_; std::atomic running_{false}; std::atomic stopRequested_{false}; std::atomic worn_{false}; std::atomic suspended_{false}; // SteamVR coexistence: 0101 released std::atomic prox_{0}; std::atomic displayStatus_{0}; std::atomic reports_{0}; std::string fwVersion_; // written once in start(), then RO std::atomic dozeCapable_{false}; // fwVersion_ >= 0.4.0 // Detection params (defaults = firmware constants; flash overrides in M3). uint16_t threshold_ = 1500; uint16_t hysteresis_ = 100; }; } // namespace sauna