#include "led_controller.h" #include #include // ---------------------------------------------------------------------------- // LedController — writer-thread + staged-buffer implementation. // ---------------------------------------------------------------------------- // This module is deliberately driver-log-free: DriverLog calls live in the // DeviceProvider layer (Plan 14-03). Keeping the controller independent of // openvr_driver.h makes it trivially unit-testable against a MockLedTransport // and keeps the Phase 14-02 blast radius minimal. // // Thread model: // - Caller threads (RunFrame / HandlePipeCommand / OSC bridge) stage data // under m_bufMutex and notify m_cv. No I/O on the caller thread. // - Writer thread waits on m_cv with a 33 ms timeout (D-11). On wake it // drains the pending state under the lock, then performs I/O outside. // - 20 ms inter-command gap between any two writes (D-09) enforced in the // writer via enforceGap(). // - Reopen attempts gated to at most once per 1000 ms (spike carry-forward; // reopen attempt 6 succeeded in <6 s during the unplug/replug UAT). // // ShutdownAllOff deviation from RESEARCH.md Pattern 6 recommendation: // RESEARCH §Code Examples 6 suggests "final write first, then join writer" // so the writer's cv state is unchanged during the final write. We flip it: // stop + join the writer, then do the synchronous final writes on the // calling thread. Rationale: simpler state machine, no race between the // writer looping back to wait_for and the caller's final frame. At worst we // skip one queued frame during shutdown, which is the desired outcome (LEDs // go black anyway). Noted in SUMMARY. LedController::LedController(std::unique_ptr transport, uint8_t ceiling) : m_transport(std::move(transport)) { if (ceiling < 1) ceiling = 1; m_ceiling.store(ceiling); std::memset(m_staged, 0, sizeof(m_staged)); // D-16: LEDs default OFF } LedController::~LedController() { // Defensive — normal shutdown goes through ShutdownAllOff. Destructor only // joins the writer if the caller forgot to. if (m_writer.joinable()) { m_bStop.store(true); m_cv.notify_all(); m_writer.join(); } } void LedController::Start(const std::string& portName) { m_portName = portName; if (!m_portName.empty() && m_transport && m_transport->Open(m_portName)) { m_connState.store(1); } else { // D-15 degraded mode: driver loads, backglow disabled. Writer thread // still starts so Plan 14-03's hotplug arrival can re-open via the // same TryReopen() path without special casing. m_connState.store(0); } if (!m_writer.joinable()) { m_bStop.store(false); m_writer = std::thread(&LedController::WriterThreadFunc, this); } } void LedController::WriterThreadFunc() { while (!m_bStop.load()) { uint8_t frame[kBackglowMaxLeds * 3]; bool hasFrame = false; bool hasBri = false; uint8_t briVal = 0; bool hasPower = false; bool powerVal = false; { std::unique_lock lk(m_bufMutex); m_cv.wait_for(lk, std::chrono::milliseconds(11), [&] { // Phase 16 D-26: 90 Hz return m_bStop.load() || m_bDirty || m_pendingBriSet || m_pendingPowerSet; }); if (m_bStop.load()) break; if (m_bDirty) { std::memcpy(frame, m_staged, sizeof(frame)); m_bDirty = false; hasFrame = true; } if (m_pendingBriSet) { briVal = m_pendingBri; m_pendingBriSet = false; hasBri = true; } if (m_pendingPowerSet) { powerVal = m_pendingPower; m_pendingPowerSet = false; hasPower = true; } } // Reconnect gate: if transport is closed (either never opened, or a // previous write failed and invalidated it), try to reopen at most // once per second. Skip the rest of this iteration — the pending // state we drained above is preserved in local variables and will be // lost, which is intentional: stale queued commands are not worth // sending after a cable drop. if (!m_transport || !m_transport->IsOpen()) { if (TryReopen()) { m_connState.store(1); } else { m_connState.store(2); } continue; } auto enforceGap = [&]() { auto now = std::chrono::steady_clock::now(); auto since = std::chrono::duration_cast( now - m_lastWrite).count(); if (since < 20) { std::this_thread::sleep_for( std::chrono::milliseconds(20 - since)); } m_lastWrite = std::chrono::steady_clock::now(); }; // Power first — if caller just queued {"on":false}, we want that to // land before any brightness or frame that might also be pending. if (hasPower) { enforceGap(); if (m_transport->SendPower(powerVal)) { m_bPoweredOn.store(powerVal); } else { m_connState.store(2); continue; } } if (hasBri) { const uint8_t c = m_ceiling.load(); const uint8_t clamped = static_cast( (static_cast(briVal) * static_cast(c)) / 255u); enforceGap(); if (!m_transport->SendBrightness(clamped)) { m_connState.store(2); continue; } } if (hasFrame) { // Per Open Question 4: if caller queued a frame while the strip // was logically off, auto-send {"on":true} first so realtime // pixels actually render. if (!m_bPoweredOn.load()) { enforceGap(); if (m_transport->SendPower(true)) { m_bPoweredOn.store(true); } else { m_connState.store(2); continue; } } // SAFETY: ceiling applied on the local copy before transport write. // PITFALLS §5 / LHWD-02 defense-in-depth. ApplyCeilingInPlace(frame, kBackglowMaxLeds * 3, m_ceiling.load()); enforceGap(); if (!m_transport->SendRgbFrame(frame, kBackglowMaxLeds)) { m_connState.store(2); } else { m_connState.store(1); } } } } void LedController::ApplyCeilingInPlace(uint8_t* rgb, int numChannels, uint8_t ceiling) { for (int i = 0; i < numChannels; ++i) { const unsigned v = rgb[i]; rgb[i] = static_cast( (v * static_cast(ceiling)) / 255u); } } bool LedController::TryReopen() { auto now = std::chrono::steady_clock::now(); if (m_lastReopenAttempt.time_since_epoch().count() != 0) { auto since = std::chrono::duration_cast( now - m_lastReopenAttempt).count(); if (since < 1000) return false; } m_lastReopenAttempt = now; if (m_portName.empty() || !m_transport) return false; return m_transport->Open(m_portName); } bool LedController::QueueFrame(const uint8_t* rgb, int numLeds) { if (!rgb || numLeds != kBackglowMaxLeds) return false; { std::lock_guard lk(m_bufMutex); std::memcpy(m_staged, rgb, sizeof(m_staged)); m_bDirty = true; } m_cv.notify_one(); return true; } bool LedController::QueueLedSet(int index, uint8_t r, uint8_t g, uint8_t b) { if (index < 0 || index >= kBackglowMaxLeds) return false; { std::lock_guard lk(m_bufMutex); m_staged[index * 3 + 0] = r; m_staged[index * 3 + 1] = g; m_staged[index * 3 + 2] = b; m_bDirty = true; } m_cv.notify_one(); return true; } bool LedController::QueueBrightness(uint8_t bri) { { std::lock_guard lk(m_bufMutex); m_pendingBri = bri; m_pendingBriSet = true; } m_cv.notify_one(); return true; } bool LedController::QueuePower(bool on) { { std::lock_guard lk(m_bufMutex); m_pendingPower = on; m_pendingPowerSet = true; } m_cv.notify_one(); return true; } void LedController::SetCeiling(uint8_t ceiling) { if (ceiling < 1) ceiling = 1; m_ceiling.store(ceiling); } uint8_t LedController::GetCeiling() const { return m_ceiling.load(); } int LedController::GetConnectionState() const { return m_connState.load(); } // ---------------------------------------------------------------------------- // ShutdownAllOff — LHWD-03 implementation. // // Deviation from RESEARCH.md Pattern 6 recommendation documented at top of // file. We stop + join the writer first, then send the final black frame + // {"on":false} on the calling thread. Simpler and avoids a race with the // writer's cv wait. Idempotent: subsequent calls are no-ops because // m_transport->IsOpen() returns false after Close(). // ---------------------------------------------------------------------------- void LedController::ShutdownAllOff() { // Stop and join the writer first. m_bStop.store(true); m_cv.notify_all(); if (m_writer.joinable()) { m_writer.join(); } // Final synchronous writes (no ceiling math needed — black is black). if (m_transport && m_transport->IsOpen()) { uint8_t black[kBackglowMaxLeds * 3] = {0}; m_transport->SendRgbFrame(black, kBackglowMaxLeds); m_transport->SendPower(false); } if (m_transport) { m_transport->Close(); } m_connState.store(0); m_bPoweredOn.store(false); }