#include "wled_serial.h" #include #include #include #include #include // ---------------------------------------------------------------------------- // Implementation notes // ---------------------------------------------------------------------------- // This class is the production-grade lift of the validated spike pattern in // src/spike/backglow_spike/serial_spike.cpp. All five MUST constraints from // Plan 14-01's spike carry-forward are honored in this file: // // 1. Windows error 22 (ERROR_BAD_COMMAND) is handled transparently — any // WriteFile failure closes the handle and lets LedController retry Open. // 2. Adalight count field = N-1, checksum = hi ^ lo ^ 0x55. // 3. Post-open CDC settle of 150 ms before we return from Open(), matching // the spike's observed-required delay (and Assumption A2 / Open Q2). // 4. Per-write WaitForSingleObject(100 ms) + CancelIoEx on timeout. // 5. DCB: CBR_115200 / 8N1 / fBinary / DTR_CONTROL_DISABLE / RTS_CONTROL_DISABLE. // COMMTIMEOUTS: WriteTotalTimeoutConstant=50 + MAXDWORD read-poll pattern. // // Threat-model mitigations (14-02-PLAN ): // T-14.2-01: port name validated against ^COM[0-9]{1,3}$ before concatenation // T-14.2-02: every WriteFile is bounded by OVERLAPPED + 100 ms wait WledSerialTransport::WledSerialTransport() : m_hPort(INVALID_HANDLE_VALUE), m_bOpen(false), m_lastError(0) {} WledSerialTransport::~WledSerialTransport() { Close(); } // ---------------------------------------------------------------------------- // Port-name validation — T-14.2-01 mitigation. // Accepts "COM" followed by 1..3 ASCII digits (total length 4..6). Rejects // anything else: empty, "COM", "COM0...", path-traversal, embedded whitespace, // etc. Mirrors spike helper `IsValidComPortName`. // ---------------------------------------------------------------------------- static bool IsValidComName(const std::string& name) { const size_t len = name.size(); if (len < 4 || len > 6) return false; if (name.compare(0, 3, "COM") != 0) return false; for (size_t i = 3; i < len; ++i) { const unsigned char c = static_cast(name[i]); if (!std::isdigit(c)) return false; } return true; } bool WledSerialTransport::Open(const std::string& portName) { Close(); if (!IsValidComName(portName)) { m_lastError.store(ERROR_INVALID_NAME); // 123 — T-14.2-01 return false; } // Windows requires the \\.\ prefix for COM10+ and it is safe for COM1..9 // too (Pitfall 3 / MS support article KB115831). const std::string path = "\\\\.\\" + portName; HANDLE h = CreateFileA( path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, // exclusive access nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr); if (h == INVALID_HANDLE_VALUE) { m_lastError.store(GetLastError()); return false; } DCB dcb{}; dcb.DCBlength = sizeof(dcb); if (!GetCommState(h, &dcb)) { m_lastError.store(GetLastError()); CloseHandle(h); return false; } dcb.BaudRate = CBR_115200; // D-10 dcb.ByteSize = 8; dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; dcb.fBinary = TRUE; dcb.fDtrControl = DTR_CONTROL_DISABLE; // avoid ESP32-C3 reset on open dcb.fRtsControl = RTS_CONTROL_DISABLE; if (!SetCommState(h, &dcb)) { m_lastError.store(GetLastError()); CloseHandle(h); return false; } COMMTIMEOUTS to{}; to.WriteTotalTimeoutConstant = 50; // PITFALLS §1 / spike carry-forward #5 to.WriteTotalTimeoutMultiplier = 0; to.ReadIntervalTimeout = MAXDWORD; // return immediately to.ReadTotalTimeoutMultiplier = 0; to.ReadTotalTimeoutConstant = 10; if (!SetCommTimeouts(h, &to)) { m_lastError.store(GetLastError()); CloseHandle(h); return false; } // Flush any lingering buffer state across reopen cycles. PurgeComm(h, PURGE_RXABORT | PURGE_RXCLEAR | PURGE_TXABORT | PURGE_TXCLEAR); m_hPort = h; m_bOpen.store(true); m_lastError.store(0); // Spike finding: ESP32-C3 USB CDC needs ≥100 ms settle before first write // to avoid first-byte-loss (Open Question 2; spike used 150 ms). Done inside // Open so callers never have to know about this hardware quirk. std::this_thread::sleep_for(std::chrono::milliseconds(150)); return true; } void WledSerialTransport::Close() { if (m_hPort != INVALID_HANDLE_VALUE) { CancelIoEx(m_hPort, nullptr); CloseHandle(m_hPort); m_hPort = INVALID_HANDLE_VALUE; } m_bOpen.store(false); } bool WledSerialTransport::IsOpen() const { return m_bOpen.load(); } unsigned long WledSerialTransport::LastErrorCode() const { return m_lastError.load(); } // ---------------------------------------------------------------------------- // Overlapped bounded write. Returns true only when `len` bytes were written // within 100 ms. On any failure — including the transient-retry set // {22 ERROR_BAD_COMMAND, 31 ERROR_GEN_FAILURE, 995 ERROR_OPERATION_ABORTED, // 1167 ERROR_DEVICE_NOT_CONNECTED} observed under unplug — the handle is // invalidated via Close() so the LedController writer thread's reopen gate // picks up on its next iteration (Pitfall 4, spike carry-forward #1). // ---------------------------------------------------------------------------- bool WledSerialTransport::WriteAllWithTimeout(const uint8_t* data, size_t len) { if (!m_bOpen.load() || m_hPort == INVALID_HANDLE_VALUE) { m_lastError.store(ERROR_INVALID_HANDLE); return false; } OVERLAPPED ov{}; ov.hEvent = CreateEventA(nullptr, TRUE, FALSE, nullptr); if (!ov.hEvent) { m_lastError.store(GetLastError()); return false; } DWORD written = 0; BOOL ok = WriteFile(m_hPort, data, static_cast(len), &written, &ov); if (!ok) { DWORD err = GetLastError(); if (err != ERROR_IO_PENDING) { m_lastError.store(err); CloseHandle(ov.hEvent); Close(); return false; } DWORD wait = WaitForSingleObject(ov.hEvent, 100); if (wait != WAIT_OBJECT_0) { CancelIoEx(m_hPort, &ov); // Drain the cancelled I/O synchronously so the handle stays sane. GetOverlappedResult(m_hPort, &ov, &written, TRUE); m_lastError.store(ERROR_OPERATION_ABORTED); // 995 CloseHandle(ov.hEvent); Close(); return false; } if (!GetOverlappedResult(m_hPort, &ov, &written, FALSE)) { m_lastError.store(GetLastError()); CloseHandle(ov.hEvent); Close(); return false; } } CloseHandle(ov.hEvent); if (written != static_cast(len)) { // Partial write — treat as a transport failure so the controller // reopens on the next iteration rather than desyncing the WLED FSM. m_lastError.store(ERROR_WRITE_FAULT); Close(); return false; } m_lastError.store(0); return true; } // ---------------------------------------------------------------------------- // Adalight frame encoder (spike carry-forward #2). // Layout: 'A' 'd' 'a' count_hi count_lo (hi ^ lo ^ 0x55) R0 G0 B0 ... Rn Gn Bn // count = numLeds - 1. Total bytes = 6 + numLeds * 3. // // Upper bound is kBackglowMaxLeds = 10 per v3.0 spec (the transport itself is // indifferent to count; LedController locks the surface). The buffer is sized // for that bound so there is no heap allocation on the hot path. // ---------------------------------------------------------------------------- bool WledSerialTransport::SendRgbFrame(const uint8_t* rgb, int numLeds) { if (!rgb) return false; if (numLeds <= 0 || numLeds > kBackglowMaxLeds) return false; uint8_t buf[6 + kBackglowMaxLeds * 3]; const uint16_t count = static_cast(numLeds - 1); const uint8_t hi = static_cast((count >> 8) & 0xFF); const uint8_t lo = static_cast(count & 0xFF); buf[0] = 'A'; buf[1] = 'd'; buf[2] = 'a'; buf[3] = hi; buf[4] = lo; buf[5] = static_cast(hi ^ lo ^ 0x55); std::memcpy(buf + 6, rgb, static_cast(numLeds) * 3); const size_t total = static_cast(6 + numLeds * 3); // WLED 0.15 quirk: first Adalight frame after idle does not render visibly // (state machine consumes it, pwr stays 0). Send twice with 20 ms gap to // guarantee first-frame visibility. Verified empirically against MagWLED-1 // during Plan 14-03 hardware smoke (2026-04-19). if (!WriteAllWithTimeout(buf, total)) return false; std::this_thread::sleep_for(std::chrono::milliseconds(20)); return WriteAllWithTimeout(buf, total); } // ---------------------------------------------------------------------------- // JSON {"bri":N}\n — D-09 / D-13. Master brightness; per-channel clamp is the // LedController's job (defense-in-depth per PITFALLS §5). // ---------------------------------------------------------------------------- bool WledSerialTransport::SendBrightness(uint8_t value) { char line[32]; int n = std::snprintf(line, sizeof(line), "{\"bri\":%u}\n", static_cast(value)); if (n <= 0 || static_cast(n) >= sizeof(line)) return false; return WriteAllWithTimeout(reinterpret_cast(line), static_cast(n)); } // ---------------------------------------------------------------------------- // JSON {"on":bool}\n — D-06 / D-09. // ---------------------------------------------------------------------------- bool WledSerialTransport::SendPower(bool on) { const char* line = on ? "{\"on\":true}\n" : "{\"on\":false}\n"; return WriteAllWithTimeout(reinterpret_cast(line), std::strlen(line)); }