// ---------------------------------------------------------------------------- // WLED DDP-over-UDP transport (TRNS-02). See 15-RESEARCH.md §Pattern 2 + // Code Examples §1, §2. // // Lifecycle: // Open(ipv4): // 1. inet_pton(AF_INET, ipv4) validation — T-15-01 mitigation // (no DNS, no hostnames, D-05). // 2. ProbeJsonInfo: raw-socket HTTP/1.0 GET /json/info on host:80; // parse "count":N — D-07/D-08. // 3. PostJsonState: best-effort POST /json/state with body // {"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]} — Pitfall 9. // 4. socket(AF_INET, SOCK_DGRAM, 0); cache sockaddr_in for host:4048 — D-06. // SendRgbFrame: // 10-byte DDP header + payload, single sendto, double-send w/ 20 ms gap // (Pitfall 8 parity with USB first-frame quirk). // SendBrightness/SendPower: // HTTP POST /json/state with {"bri":N} / {"on":bool}. // Close: // closesocket(); idempotent. Mirrors WledSerialTransport contract // (any Send failure -> Close()). // // Winsock init/teardown is owned by DeviceProvider (RESEARCH Code Examples §3). // This file deliberately avoids WSA*tartup / WSA*leanup calls. // // Threat mitigations: // T-15-01: inet_pton strict IPv4 validation; reject hostnames. // T-15-03: bounded HTTP request/response buffers (snprintf with sizeof(), // recv loop capped at 16 KB). // T-15-05: single socket per Open; reconnect gate in LedController bounds // churn. // T-15-06: NEVER log raw /json/info body — only the parsed count surfaces // via ReportedLedCount(). // T-15-08: bounded /json/info recv loop (max 16 KB); fall through to // "probe failed" on parse error. // T-15-11: non-blocking connect + select w/ 500 ms timeout per phase; // recv loop with select per chunk. // ---------------------------------------------------------------------------- #include "wled_ddp.h" #include #include #include #include #include #include // ---------------------------------------------------------------------------- // Internal constants // ---------------------------------------------------------------------------- namespace { // T-15-08 / Pitfall 4: cap /json/info response at 16 KB. WLED's info object // can grow with installed effects/segments/usermods; 16 KB is 8x our nominal // observation (~2 KB) and bounds pathological responses. constexpr int kMaxProbeResponseBytes = 16 * 1024; constexpr int kRecvChunkBytes = 4 * 1024; // HTTP request scratch buffer size (T-15-03 — fixed, snprintf-bounded). constexpr int kHttpReqBufBytes = 512; // ---------------------------------------------------------------------------- // SelectForWrite — wait until socket is writable OR timeout elapses. // Returns true if writable, false on timeout / error. // ---------------------------------------------------------------------------- static bool SelectForWrite(SOCKET s, int timeoutMs) { fd_set wfds; FD_ZERO(&wfds); FD_SET(s, &wfds); timeval tv{}; tv.tv_sec = timeoutMs / 1000; tv.tv_usec = (timeoutMs % 1000) * 1000; int rc = ::select(0, nullptr, &wfds, nullptr, &tv); return rc > 0; } // ---------------------------------------------------------------------------- // SelectForRead — wait until socket has data OR timeout elapses. // Returns >0 on data, 0 on timeout, <0 on error. // ---------------------------------------------------------------------------- static int SelectForRead(SOCKET s, int timeoutMs) { fd_set rfds; FD_ZERO(&rfds); FD_SET(s, &rfds); timeval tv{}; tv.tv_sec = timeoutMs / 1000; tv.tv_usec = (timeoutMs % 1000) * 1000; return ::select(0, &rfds, nullptr, nullptr, &tv); } // ---------------------------------------------------------------------------- // NonBlockingConnect — TCP connect with bounded timeout via non-blocking // connect + select. Returns true on connect success, false on timeout/error. // On failure, outErr is set; caller is responsible for closesocket(s). // ---------------------------------------------------------------------------- static bool NonBlockingConnect(SOCKET s, const sockaddr_in& addr, int timeoutMs, unsigned long& outErr) { u_long nb = 1; if (::ioctlsocket(s, FIONBIO, &nb) != 0) { outErr = static_cast(WSAGetLastError()); return false; } int rc = ::connect(s, reinterpret_cast(&addr), sizeof(addr)); if (rc == 0) { return true; // immediate success (rare for non-blocking but possible) } int err = WSAGetLastError(); if (err != WSAEWOULDBLOCK) { // RESEARCH Anti-Patterns: WSAEWOULDBLOCK is NOT an error; anything else is. outErr = static_cast(err); return false; } if (!SelectForWrite(s, timeoutMs)) { outErr = WSAETIMEDOUT; return false; } // Confirm connect actually succeeded via SO_ERROR int soErr = 0; int soErrLen = sizeof(soErr); if (::getsockopt(s, SOL_SOCKET, SO_ERROR, reinterpret_cast(&soErr), &soErrLen) != 0) { outErr = static_cast(WSAGetLastError()); return false; } if (soErr != 0) { outErr = static_cast(soErr); return false; } return true; } // ---------------------------------------------------------------------------- // SendAll — send all bytes, looping while EWOULDBLOCK with select. Returns // true if all bytes sent within timeoutMs. // ---------------------------------------------------------------------------- static bool SendAll(SOCKET s, const char* data, int len, int timeoutMs) { int sent = 0; while (sent < len) { int n = ::send(s, data + sent, len - sent, 0); if (n > 0) { sent += n; continue; } int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { if (!SelectForWrite(s, timeoutMs)) return false; continue; } return false; } return true; } // ---------------------------------------------------------------------------- // ProbeJsonInfo — RESEARCH §Pattern 3 (lines 271-306) + Code Examples §2. // // Raw-socket HTTP/1.0 GET /json/info to host:80. Uses Connection: close so // the server signals end-of-body via EOF (no chunked encoding to deal with — // "Don't Hand-Roll" line 444 of RESEARCH). // // On success: outLedCount holds parsed value, returns true. // On failure: outErr set to a Win32/WSA error code, returns false. // // T-15-08: graceful failure on missing/malformed "count" field. // T-15-11: non-blocking connect + bounded recv loop (16 KB cap, Pitfall 4). // ---------------------------------------------------------------------------- static bool ProbeJsonInfo(const std::string& host, int& outLedCount, unsigned long& outErr, int timeoutMs) { outLedCount = 0; outErr = 0; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(80); if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { outErr = WSAEINVAL; return false; } SOCKET s = ::socket(AF_INET, SOCK_STREAM, 0); if (s == INVALID_SOCKET) { outErr = static_cast(WSAGetLastError()); return false; } if (!NonBlockingConnect(s, addr, timeoutMs, outErr)) { ::closesocket(s); return false; } // HTTP/1.0 + Connection: close => non-chunked, ends with EOF. static const char* kReq = "GET /json/info HTTP/1.0\r\n" "Host: wled\r\n" "Connection: close\r\n" "\r\n"; if (!SendAll(s, kReq, static_cast(std::strlen(kReq)), timeoutMs)) { outErr = WSAETIMEDOUT; ::closesocket(s); return false; } // Bounded recv loop. 16 KB total cap (T-15-08 / Pitfall 4). std::string body; body.reserve(kRecvChunkBytes); char chunk[kRecvChunkBytes]; while (static_cast(body.size()) < kMaxProbeResponseBytes) { int sel = SelectForRead(s, timeoutMs); if (sel == 0) { outErr = WSAETIMEDOUT; break; } if (sel < 0) { outErr = static_cast(WSAGetLastError()); break; } int n = ::recv(s, chunk, sizeof(chunk), 0); if (n == 0) break; // EOF — server closed (HTTP/1.0 Connection: close) if (n < 0) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) continue; outErr = static_cast(err); break; } body.append(chunk, n); } ::closesocket(s); if (body.empty()) { if (outErr == 0) outErr = WSAETIMEDOUT; return false; } // T-15-08: parse "count": substring; nullptr means malformed -> fail gracefully. // T-15-06: NEVER log body; only parsed count surfaces. // IN-04 (Phase 15 review): skip past HTTP headers (CRLFCRLF) before scanning // for "count": so a pathological server that echoes "count": in a custom // header cannot fool the parser. WLED itself never does this, but the // header-skip is cheap defence-in-depth. const char* bodyStart = std::strstr(body.c_str(), "\r\n\r\n"); if (!bodyStart) { outErr = ERROR_INVALID_DATA; // 13 return false; } bodyStart += 4; const char* p = std::strstr(bodyStart, "\"count\":"); if (!p) { outErr = ERROR_INVALID_DATA; // 13 return false; } p += std::strlen("\"count\":"); while (*p == ' ' || *p == '\t') ++p; outLedCount = static_cast(std::strtoul(p, nullptr, 10)); if (outLedCount <= 0) { outErr = ERROR_INVALID_DATA; return false; } return true; } // ---------------------------------------------------------------------------- // PostJsonState — Pitfall 9 segment-clear + brightness/power POSTs. // // HTTP/1.0 POST /json/state with caller-supplied JSON body. Returns true on // HTTP 2xx response (looks for "200 " or "204 " in the response status line). // Best-effort: caller (Open) ignores the return for the segment-clear case. // // T-15-03: bounded snprintf (kHttpReqBufBytes = 512), fixed buffers everywhere. // T-15-11: same non-blocking connect + bounded recv as ProbeJsonInfo. // T-15-12 (accept): per-recv select() bounds malicious slow-connection. // ---------------------------------------------------------------------------- static bool PostJsonState(const std::string& host, const char* jsonBody, int timeoutMs) { if (!jsonBody) return false; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(80); if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { return false; } SOCKET s = ::socket(AF_INET, SOCK_STREAM, 0); if (s == INVALID_SOCKET) return false; unsigned long unusedErr = 0; if (!NonBlockingConnect(s, addr, timeoutMs, unusedErr)) { ::closesocket(s); return false; } char req[kHttpReqBufBytes]; int n = std::snprintf(req, sizeof(req), "POST /json/state HTTP/1.0\r\n" "Host: wled\r\n" "Content-Type: application/json\r\n" "Content-Length: %zu\r\n" "Connection: close\r\n" "\r\n" "%s", std::strlen(jsonBody), jsonBody); if (n <= 0 || n >= static_cast(sizeof(req))) { ::closesocket(s); return false; // body too large for fixed scratch (T-15-03) } if (!SendAll(s, req, n, timeoutMs)) { ::closesocket(s); return false; } // Read enough of the response to find the HTTP status line. char respBuf[1024] = {0}; int respLen = 0; while (respLen < static_cast(sizeof(respBuf)) - 1) { int sel = SelectForRead(s, timeoutMs); if (sel <= 0) break; int got = ::recv(s, respBuf + respLen, static_cast(sizeof(respBuf)) - 1 - respLen, 0); if (got <= 0) break; respLen += got; // Have enough to inspect the status line? respBuf[respLen] = 0; if (std::strstr(respBuf, "\r\n")) break; } respBuf[respLen < static_cast(sizeof(respBuf)) ? respLen : static_cast(sizeof(respBuf)) - 1] = 0; ::closesocket(s); if (respLen <= 0) return false; // Accept any HTTP 2xx; WLED replies 200 typically, 204 No Content also acceptable. return std::strstr(respBuf, " 200 ") != nullptr || std::strstr(respBuf, " 204 ") != nullptr || std::strstr(respBuf, "HTTP/1.1 200") != nullptr || std::strstr(respBuf, "HTTP/1.0 200") != nullptr || std::strstr(respBuf, "HTTP/1.1 204") != nullptr || std::strstr(respBuf, "HTTP/1.0 204") != nullptr; } } // namespace // ============================================================================ // WledDdpTransport member implementations // ============================================================================ WledDdpTransport::WledDdpTransport() : m_sock(INVALID_SOCKET), m_dest{}, m_bOpen(false), m_lastError(0), m_reportedLedCount(0), m_seq(1) {} WledDdpTransport::~WledDdpTransport() { Close(); } // ---------------------------------------------------------------------------- // Open — RESEARCH §Code Examples §2 (lines 575-619). // // Sequence: // 1. Validate host as strict IPv4 dotted quad (T-15-01). // 2. Probe /json/info → ledCount (D-07/D-08); fail if unreachable. // 3. Best-effort POST segment-clear (Pitfall 9). Failure does NOT fail Open. // 4. Bind UDP socket; cache sockaddr_in for host:4048 (D-06). // 5. Reset m_seq = 1 (Pitfall 3). // ---------------------------------------------------------------------------- bool WledDdpTransport::Open(const std::string& host) { Close(); // T-15-01: strict IPv4 validation. inet_pton returns 1 ONLY for a // well-formed dotted quad. Hostnames, paths, embedded \r\n, garbage all // return 0/-1 and are rejected here. sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(80); // probe port first if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { m_lastError.store(WSAEINVAL); // 10022 return false; } // 1. HTTP probe → /json/info → "leds":{...,"count":N,...} (D-07/D-08) int ledCount = 0; unsigned long probeErr = 0; if (!ProbeJsonInfo(host, ledCount, probeErr, /*timeoutMs*/ 500)) { m_lastError.store(probeErr ? probeErr : WSAETIMEDOUT); return false; } m_reportedLedCount = ledCount; // Plan 15-03 reads ReportedLedCount() and logs a WARN if != kBackglowMaxLeds. // 2. Pitfall 9: best-effort segment-clear POST. Failure does NOT fail Open. PostJsonState(host, R"({"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]})", /*timeoutMs*/ 500); // 3. Bind UDP socket for DDP frames; cache the dest sockaddr. m_sock = ::socket(AF_INET, SOCK_DGRAM, 0); if (m_sock == INVALID_SOCKET) { m_lastError.store(static_cast(WSAGetLastError())); return false; } addr.sin_port = htons(4048); // D-06: WLED DDP fixed port m_dest = addr; m_seq = 1; // Pitfall 3: reset per-Open m_bOpen.store(true); m_lastError.store(0); return true; } void WledDdpTransport::Close() { if (m_sock != INVALID_SOCKET) { ::closesocket(m_sock); m_sock = INVALID_SOCKET; } m_bOpen.store(false); } bool WledDdpTransport::IsOpen() const { return m_bOpen.load(); } unsigned long WledDdpTransport::LastErrorCode() const { return m_lastError.load(); } int WledDdpTransport::ReportedLedCount() const { return m_reportedLedCount; } // ---------------------------------------------------------------------------- // SendRgbFrame — RESEARCH §Code Examples §1 (lines 524-568). // // Builds 10-byte DDP header + numLeds*3 RGB bytes. One sendto. Header layout: // // pkt[0] = 0x41 ver=1, push=1 // pkt[1] = m_seq 1..15 wrap (Pitfall 3) // pkt[2] = 0x07 8 bits/channel // pkt[3] = 0x00 default channel // pkt[4..7] = 0,0,0,0 offset=0 (BE uint32) // pkt[8..9] = lenHi, lenLo dataLen = numLeds*3 (BE uint16) // pkt[10..] = R0 G0 B0 ... Rn Gn Bn // // D-08: clamp numLeds to kBackglowMaxLeds (10) regardless of WLED's reported // count. Buffer is stack-allocated 10 + kBackglowMaxLeds*3 = 40 bytes; no // heap allocation on the hot path. // // Pitfall 8: double-send with 20 ms gap for first-frame quirk parity with USB. // // On any sendto mismatch: store WSAGetLastError, call Close() (self-invalidate // per Phase 14 contract), return false. LedController writer thread reopen // gate then attempts reconnect on the next iteration. // ---------------------------------------------------------------------------- bool WledDdpTransport::SendRgbFrame(const uint8_t* rgb, int numLeds) { if (!m_bOpen.load() || m_sock == INVALID_SOCKET) { m_lastError.store(WSAEINVAL); return false; } if (numLeds <= 0 || !rgb) { m_lastError.store(WSAEINVAL); return false; } if (numLeds > kBackglowMaxLeds) numLeds = kBackglowMaxLeds; // D-08 clamp const uint16_t dataLen = static_cast(numLeds * 3); uint8_t pkt[10 + kBackglowMaxLeds * 3]; pkt[0] = 0x41; // ver=1, push=1 pkt[1] = m_seq; // 1..15 // IN-01 (Phase 15 review): DDP sequence field is 4 bits (1..15, 0=no-seq). // Previous wrap `(m_seq % 15) + 1` produced 1..14 (skipped 15); include 15 // in the cycle for spec compliance. m_seq = (m_seq >= 15) ? static_cast(1) : static_cast(m_seq + 1); // wrap 1..15 pkt[2] = 0x07; // 8 bits/channel pkt[3] = 0x00; // default channel pkt[4] = 0; pkt[5] = 0; pkt[6] = 0; pkt[7] = 0; // offset = 0 pkt[8] = static_cast((dataLen >> 8) & 0xFF); // length hi (BE) pkt[9] = static_cast(dataLen & 0xFF); // length lo (BE) std::memcpy(pkt + 10, rgb, dataLen); const int total = 10 + dataLen; int sent = ::sendto(m_sock, reinterpret_cast(pkt), total, 0, reinterpret_cast(&m_dest), sizeof(m_dest)); if (sent != total) { m_lastError.store(static_cast(WSAGetLastError())); Close(); // self-invalidate; mirrors WledSerialTransport contract return false; } // Pitfall 8: mirror Phase 14 double-send for first-frame quirk safety. // 20 ms gap; verified empirically against MagWLED-1 in Phase 14 SMOKE. // SMOKE in Plan 15-04 confirms or refutes whether DDP strictly needs this; // we keep parity with USB regardless so the two transports behave // identically to the user. std::this_thread::sleep_for(std::chrono::milliseconds(20)); sent = ::sendto(m_sock, reinterpret_cast(pkt), total, 0, reinterpret_cast(&m_dest), sizeof(m_dest)); if (sent != total) { m_lastError.store(static_cast(WSAGetLastError())); Close(); return false; } m_lastError.store(0); return true; } // ---------------------------------------------------------------------------- // SendBrightness / SendPower — HTTP POST /json/state to WLED. // // We did not store the host string in Open() (kept the class state minimal); // reconstruct the dotted quad from m_dest via inet_ntop. This is round-trip // safe because the dest was originally populated via inet_pton, so we are // guaranteed an IPv4 address. // // Soft-fault model: on POST failure we DO NOT call Close(). The UDP socket // for SendRgbFrame is a separate ephemeral channel — a transient HTTP failure // (e.g. WLED busy) does not invalidate the realtime stream. This differs // intentionally from WledSerialTransport, which shares one handle for both. // ---------------------------------------------------------------------------- bool WledDdpTransport::SendBrightness(uint8_t value) { if (!m_bOpen.load()) { m_lastError.store(WSAEINVAL); return false; } char body[24]; int n = std::snprintf(body, sizeof(body), R"({"bri":%u})", static_cast(value)); if (n <= 0 || n >= static_cast(sizeof(body))) { m_lastError.store(WSAEINVAL); return false; } char hostStr[INET_ADDRSTRLEN] = {0}; if (!::inet_ntop(AF_INET, &m_dest.sin_addr, hostStr, sizeof(hostStr))) { m_lastError.store(static_cast(WSAGetLastError())); return false; } if (!PostJsonState(hostStr, body, /*timeoutMs*/ 500)) { m_lastError.store(static_cast(WSAGetLastError())); return false; } m_lastError.store(0); return true; } bool WledDdpTransport::SendPower(bool on) { if (!m_bOpen.load()) { m_lastError.store(WSAEINVAL); return false; } const char* body = on ? R"({"on":true})" : R"({"on":false})"; char hostStr[INET_ADDRSTRLEN] = {0}; if (!::inet_ntop(AF_INET, &m_dest.sin_addr, hostStr, sizeof(hostStr))) { m_lastError.store(static_cast(WSAGetLastError())); return false; } if (!PostJsonState(hostStr, body, /*timeoutMs*/ 500)) { m_lastError.store(static_cast(WSAGetLastError())); return false; } m_lastError.store(0); return true; }