// Phase 14.1 spike: hardware-exercising CLI (throwaway — remove in Plan 14-02). // // Usage: // backglow_serial_spike // in {v, fill-red, fill-green, fill-blue, bri50, off, on, // unplug-loop, all} // // Implements D-18 steps 1-4 against a MagWLED-1 (WLED on ESP32-C3) over // Windows native USB CDC. Pattern 3 from 14-RESEARCH.md is reproduced verbatim: // CreateFileA("\\\\.\\COMn", GENERIC_READ|GENERIC_WRITE, 0, OPEN_EXISTING, // FILE_FLAG_OVERLAPPED) // DCB: CBR_115200 / 8N1 / fBinary / DTR_CONTROL_DISABLE / RTS_CONTROL_DISABLE // COMMTIMEOUTS: WriteTotalTimeoutConstant=50 / MAXDWORD read-interval-poll // // Threat model mitigations (14-01-PLAN ): // T-14.1-01: port name must match ^COM[0-9]{1,3}$; reject anything else. // T-14.1-02: every ReadFile bounded (10ms DCB + 2s outer loop for 'v'). // T-14.1-05: 'v' reply classified only as WLED-prefix vs. inconclusive; no trust. // // No project dependencies. Win32 SDK only. #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #include #include #include #include #include #include #include #include // ----------------------------------------------------------------------------- // Adalight frame encoder — duplicated from harness.cpp so this TU is standalone. // Plan 14-02 will collapse the two copies into src/led/wled_serial.cpp. // ----------------------------------------------------------------------------- // MagWLED-1 strip: 12 physical LEDs (last 2 are spare backups per hardware note). // WLED firmware rejects Adalight frames whose count_lo mismatches configured length, // silently falling back to default effect after ~3s serial timeout (observed UAT). static constexpr int kLedCount = 12; static constexpr int kRgbBytes = kLedCount * 3; static constexpr int kFrameBytes = 6 + kRgbBytes; static void BuildAdalightFrame(const uint8_t* rgb, int numLeds, uint8_t* out) { // Header: 'A' 'd' 'a' count_hi count_lo (hi ^ lo ^ 0x55), then N*3 RGB bytes. 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); out[0] = 'A'; out[1] = 'd'; out[2] = 'a'; out[3] = hi; out[4] = lo; out[5] = static_cast(hi ^ lo ^ 0x55); std::memcpy(out + 6, rgb, static_cast(numLeds) * 3); } static void LogHex(const uint8_t* buf, size_t n) { for (size_t i = 0; i < n; ++i) std::printf("%02X%s", buf[i], (i + 1 == n) ? "" : " "); std::printf("\n"); } // ----------------------------------------------------------------------------- // Port-name validation — T-14.1-01 mitigation. // Accepts "COM" followed by 1-3 ASCII digits, total length 4..6. Rejects anything // else (no "..", no backslash, no semicolons, no spaces). // ----------------------------------------------------------------------------- static bool IsValidComPortName(const char* s) { if (!s) return false; const size_t len = std::strlen(s); if (len < 4 || len > 6) return false; if (s[0] != 'C' || s[1] != 'O' || s[2] != 'M') return false; for (size_t i = 3; i < len; ++i) { if (s[i] < '0' || s[i] > '9') return false; } return true; } // ----------------------------------------------------------------------------- // Open COM port with Pattern 3 DCB + COMMTIMEOUTS verbatim. // Returns INVALID_HANDLE_VALUE on failure; caller prints GetLastError. // ----------------------------------------------------------------------------- static HANDLE OpenComPort(const char* portName, DWORD& lastError) { char path[32]; std::snprintf(path, sizeof(path), "\\\\.\\%s", portName); // e.g. "\\\\.\\COM5" HANDLE h = CreateFileA( path, GENERIC_READ | GENERIC_WRITE, 0, // exclusive access (Pattern 3) nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // non-blocking I/O nullptr); if (h == INVALID_HANDLE_VALUE) { lastError = GetLastError(); return h; } DCB dcb = {0}; dcb.DCBlength = sizeof(dcb); if (!GetCommState(h, &dcb)) { lastError = GetLastError(); CloseHandle(h); return INVALID_HANDLE_VALUE; } dcb.BaudRate = CBR_115200; // D-10: fixed dcb.ByteSize = 8; dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; dcb.fBinary = TRUE; dcb.fDtrControl = DTR_CONTROL_DISABLE; // avoid ESP32-C3 reset on DTR toggle dcb.fRtsControl = RTS_CONTROL_DISABLE; if (!SetCommState(h, &dcb)) { lastError = GetLastError(); CloseHandle(h); return INVALID_HANDLE_VALUE; } COMMTIMEOUTS to = {0}; to.WriteTotalTimeoutConstant = 50; // hard cap (PITFALLS.md §1) to.WriteTotalTimeoutMultiplier = 0; to.ReadIntervalTimeout = MAXDWORD; // return immediately with whatever bytes available to.ReadTotalTimeoutMultiplier = 0; to.ReadTotalTimeoutConstant = 10; if (!SetCommTimeouts(h, &to)) { lastError = GetLastError(); CloseHandle(h); return INVALID_HANDLE_VALUE; } // Flush any pending input/output that may have lingered across opens. PurgeComm(h, PURGE_RXABORT | PURGE_RXCLEAR | PURGE_TXABORT | PURGE_TXCLEAR); lastError = 0; return h; } // ----------------------------------------------------------------------------- // Overlapped write with 100 ms WaitForSingleObject bound + CancelIoEx on timeout. // Returns true only when bytesWritten == bytes. On false, lastError is populated. // ----------------------------------------------------------------------------- static bool OverlappedWriteAll(HANDLE h, const uint8_t* buf, size_t bytes, DWORD& lastError) { OVERLAPPED ov = {0}; ov.hEvent = CreateEventA(nullptr, TRUE, FALSE, nullptr); if (!ov.hEvent) { lastError = GetLastError(); return false; } DWORD written = 0; BOOL ok = WriteFile(h, buf, static_cast(bytes), &written, &ov); if (!ok) { DWORD err = GetLastError(); if (err != ERROR_IO_PENDING) { lastError = err; CloseHandle(ov.hEvent); return false; } DWORD wait = WaitForSingleObject(ov.hEvent, 100); if (wait != WAIT_OBJECT_0) { CancelIoEx(h, &ov); // Drain cancelled I/O (wait=TRUE) so the handle stays in a sane state. GetOverlappedResult(h, &ov, &written, TRUE); lastError = (wait == WAIT_TIMEOUT) ? WAIT_TIMEOUT : GetLastError(); CloseHandle(ov.hEvent); return false; } if (!GetOverlappedResult(h, &ov, &written, FALSE)) { lastError = GetLastError(); CloseHandle(ov.hEvent); return false; } } CloseHandle(ov.hEvent); if (written != bytes) { lastError = ERROR_WRITE_FAULT; return false; } lastError = 0; return true; } // ----------------------------------------------------------------------------- // Overlapped read up to `cap` bytes with a per-call 100ms outer wait. // Returns bytes read (0 on timeout). Bounded by DCB 10ms ReadTotalTimeoutConstant. // ----------------------------------------------------------------------------- static DWORD OverlappedReadSome(HANDLE h, uint8_t* buf, DWORD cap, DWORD waitMs) { OVERLAPPED ov = {0}; ov.hEvent = CreateEventA(nullptr, TRUE, FALSE, nullptr); if (!ov.hEvent) return 0; DWORD read = 0; BOOL ok = ReadFile(h, buf, cap, &read, &ov); if (!ok) { DWORD err = GetLastError(); if (err != ERROR_IO_PENDING) { CloseHandle(ov.hEvent); return 0; } DWORD wait = WaitForSingleObject(ov.hEvent, waitMs); if (wait != WAIT_OBJECT_0) { CancelIoEx(h, &ov); GetOverlappedResult(h, &ov, &read, TRUE); CloseHandle(ov.hEvent); return 0; } GetOverlappedResult(h, &ov, &read, FALSE); } CloseHandle(ov.hEvent); return read; } // ----------------------------------------------------------------------------- // Step implementations // ----------------------------------------------------------------------------- // D-18 step 1: write 'v' (0x76), read reply up to 2000 ms. Accept if starts "WLED ". static int StepV(HANDLE h) { const uint8_t query = 0x76; DWORD err = 0; if (!OverlappedWriteAll(h, &query, 1, err)) { std::printf("FAIL v write code=%lu\n", static_cast(err)); return 1; } uint8_t reply[64] = {0}; size_t haveBytes = 0; const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000); while (std::chrono::steady_clock::now() < deadline && haveBytes < sizeof(reply) - 1) { DWORD got = OverlappedReadSome(h, reply + haveBytes, static_cast(sizeof(reply) - 1 - haveBytes), 100); haveBytes += got; // Stop on newline — WLED replies end the line with '\n' / '\r'. bool sawTerminator = false; for (size_t i = haveBytes - got; i < haveBytes; ++i) { if (reply[i] == '\n' || reply[i] == '\r') { sawTerminator = true; break; } } if (sawTerminator) break; if (got == 0) std::this_thread::sleep_for(std::chrono::milliseconds(50)); } if (haveBytes >= 5 && std::memcmp(reply, "WLED ", 5) == 0) { // Strip trailing whitespace for a tidy log line. while (haveBytes > 0 && (reply[haveBytes-1] == '\n' || reply[haveBytes-1] == '\r' || reply[haveBytes-1] == ' ')) { reply[--haveBytes] = 0; } reply[haveBytes] = 0; std::printf("OK v reply=\"%s\"\n", reinterpret_cast(reply)); return 0; } if (haveBytes == 0) { std::printf("FAIL v reply_bytes=(none, 2s timeout)\n"); } else { std::printf("FAIL v reply_bytes="); LogHex(reply, haveBytes); } return 1; } // D-18 step 2: all- Adalight frame for kLedCount LEDs. static int StepFill(HANDLE h, uint8_t r, uint8_t g, uint8_t b, const char* label) { uint8_t rgb[kRgbBytes]; for (int i = 0; i < kLedCount; ++i) { rgb[i*3+0] = r; rgb[i*3+1] = g; rgb[i*3+2] = b; } uint8_t frame[kFrameBytes]; BuildAdalightFrame(rgb, kLedCount, frame); DWORD err = 0; if (!OverlappedWriteAll(h, frame, sizeof(frame), err)) { std::printf("FAIL fill=%s code=%lu\n", label, static_cast(err)); return 1; } std::printf("OK fill=%s bytes=%d\n", label, kFrameBytes); return 0; } // Helper: write a JSON line. static int WriteJsonLine(HANDLE h, const char* line, const char* okMsg) { DWORD err = 0; const size_t len = std::strlen(line); if (!OverlappedWriteAll(h, reinterpret_cast(line), len, err)) { std::printf("FAIL %s code=%lu\n", okMsg, static_cast(err)); return 1; } std::printf("OK %s\n", okMsg); return 0; } // D-18 step 4: {"bri":50} after an all-white Adalight frame with 25ms gap (>= D-09 20ms). static int StepBri50(HANDLE h) { uint8_t rgb[kRgbBytes]; for (int i = 0; i < kRgbBytes; ++i) rgb[i] = 0xFF; // all-white uint8_t frame[kFrameBytes]; BuildAdalightFrame(rgb, kLedCount, frame); DWORD err = 0; if (!OverlappedWriteAll(h, frame, sizeof(frame), err)) { std::printf("FAIL bri50 adalight_write code=%lu\n", static_cast(err)); return 1; } std::this_thread::sleep_for(std::chrono::milliseconds(25)); const char* json = "{\"bri\":50}\n"; const size_t len = std::strlen(json); if (!OverlappedWriteAll(h, reinterpret_cast(json), len, err)) { std::printf("FAIL bri50 json_write code=%lu\n", static_cast(err)); return 1; } std::printf("OK bri=50 gap_ms=25\n"); return 0; } // D-18 step 3: unplug-loop — write all-dim Adalight every 250ms, break on write // error, log Windows error code, then retry CreateFileA every 1s for 30s. static int StepUnplugLoop(const char* portName, HANDLE openedH) { std::printf("Unplug the MagWLED-1 now; replug within 30 s. Press ENTER to begin.\n"); std::fflush(stdout); // Drain a single line of input. int c; while ((c = std::fgetc(stdin)) != EOF && c != '\n') { /* drop */ } HANDLE h = openedH; uint8_t rgb[kRgbBytes]; for (int i = 0; i < kRgbBytes; ++i) rgb[i] = 0x10; // dim white uint8_t frame[kFrameBytes]; BuildAdalightFrame(rgb, kLedCount, frame); DWORD writeErr = 0; bool detached = false; for (int i = 0; i < 600; ++i) { // up to 150 s safety cap — user should unplug much sooner DWORD err = 0; if (!OverlappedWriteAll(h, frame, sizeof(frame), err)) { writeErr = err; detached = true; std::printf("ERR write code=%lu\n", static_cast(err)); break; } std::this_thread::sleep_for(std::chrono::milliseconds(250)); } if (!detached) { std::printf("FAIL unplug-loop: no write error observed within 150 s (did you unplug?)\n"); CloseHandle(h); return 1; } CloseHandle(h); // Retry reopen every 1 s for up to 30 s. for (int attempt = 1; attempt <= 30; ++attempt) { std::this_thread::sleep_for(std::chrono::milliseconds(1000)); DWORD reopErr = 0; HANDLE h2 = OpenComPort(portName, reopErr); if (h2 != INVALID_HANDLE_VALUE) { std::printf("OK reopen attempt=%d (first_write_err=%lu)\n", attempt, static_cast(writeErr)); CloseHandle(h2); return 0; } } std::printf("FAIL no-reopen (first_write_err=%lu)\n", static_cast(writeErr)); return 1; } // ----------------------------------------------------------------------------- // main // ----------------------------------------------------------------------------- static int PrintUsage() { std::fprintf(stderr, "usage: backglow_serial_spike \n" " : v | fill-red | fill-green | fill-blue | bri50 | off | on |\n" " unplug-loop | all\n"); return 2; } int main(int argc, char** argv) { if (argc != 3) return PrintUsage(); const char* portName = argv[1]; const char* step = argv[2]; if (!IsValidComPortName(portName)) { std::fprintf(stderr, "ERR invalid port name (expected COM, got '%s')\n", portName); return 2; } DWORD err = 0; HANDLE h = OpenComPort(portName, err); if (h == INVALID_HANDLE_VALUE) { std::fprintf(stderr, "ERR open %s code=%lu\n", portName, static_cast(err)); return 1; } int rc = 0; if (std::strcmp(step, "v") == 0) { rc = StepV(h); } else if (std::strcmp(step, "fill-red") == 0) { rc = StepFill(h, 0xFF, 0x00, 0x00, "red"); } else if (std::strcmp(step, "fill-green") == 0) { rc = StepFill(h, 0x00, 0xFF, 0x00, "green"); } else if (std::strcmp(step, "fill-blue") == 0) { rc = StepFill(h, 0x00, 0x00, 0xFF, "blue"); } else if (std::strcmp(step, "bri50") == 0) { rc = StepBri50(h); } else if (std::strcmp(step, "off") == 0) { rc = WriteJsonLine(h, "{\"on\":false}\n", "off"); } else if (std::strcmp(step, "on") == 0) { rc = WriteJsonLine(h, "{\"on\":true}\n", "on"); } else if (std::strcmp(step, "unplug-loop") == 0) { // Ownership passes to StepUnplugLoop — it closes the handle itself. rc = StepUnplugLoop(portName, h); h = INVALID_HANDLE_VALUE; } else if (std::strcmp(step, "all") == 0) { rc = StepV(h); std::this_thread::sleep_for(std::chrono::milliseconds(250)); if (rc == 0) rc = StepFill(h, 0xFF, 0x00, 0x00, "red"); std::this_thread::sleep_for(std::chrono::milliseconds(500)); if (rc == 0) rc = StepFill(h, 0x00, 0xFF, 0x00, "green"); std::this_thread::sleep_for(std::chrono::milliseconds(500)); if (rc == 0) rc = StepFill(h, 0x00, 0x00, 0xFF, "blue"); std::this_thread::sleep_for(std::chrono::milliseconds(500)); if (rc == 0) rc = StepBri50(h); std::this_thread::sleep_for(std::chrono::milliseconds(1000)); if (rc == 0) rc = WriteJsonLine(h, "{\"on\":false}\n", "off"); std::this_thread::sleep_for(std::chrono::milliseconds(250)); if (rc == 0) rc = WriteJsonLine(h, "{\"on\":true}\n", "on"); std::printf("NOTE: run `unplug-loop` separately — it requires an interactive unplug/replug.\n"); } else { std::fprintf(stderr, "ERR unknown step '%s'\n", step); rc = PrintUsage(); } if (h != INVALID_HANDLE_VALUE) CloseHandle(h); return rc; }