FastLED 3.10.8
Loading...
Searching...
No Matches
AutoResearchNet.cpp
Go to the documentation of this file.
1// examples/AutoResearch/AutoResearchNet.cpp
2//
3// Network autoresearch implementation for ESP32.
4// Uses fl::asio::http::Server (unified HTTP API) for the HTTP server.
5// Uses ESP-IDF native APIs for WiFi Soft AP and HTTP client.
6// Guarded with FL_IS_ESP32 - no-op stubs on other platforms.
7
8// Keep the Arduino-Pico WiFi library visible to the LDF before the
9// header-derived low-memory guard below. The RP2350W peer owns
10// WiFiClient/WiFiServer, so this is its direct framework dependency.
11#if defined(PICO_CYW43_SUPPORTED)
12// IWYU pragma: begin_keep
13#include <WiFi.h>
14// IWYU pragma: end_keep
15#endif
16
17// Gate out under low-memory mode -- the LowMemory bring-up surface
18// (AutoResearchLowMemory.h) doesn't expose any network endpoints and the
19// fl::net HTTP / asio machinery is several KB that won't fit on the
20// LPC845-BRK / similar Low + Tiny memory targets. Matches the conditional
21// structure in AutoResearch.ino itself.
23#if FL_PLATFORM_HAS_LARGE_MEMORY
24
25#include "AutoResearchNet.h"
26#include "FastLED.h"
27#include "fl/log/log.h"
28#include "fl/stl/json.h"
29#include "fl/stl/singleton.h"
30
33}
34
35// ============================================================================
36// ESP32 Implementation
37// ============================================================================
38
39// ESP32-H2 (and other WiFi-less ESP32 family members) must take the
40// stub branch — esp_wifi.h does not even preprocess there
41// (CONFIG_ESP_WIFI_* are absent from its sdkconfig). Gate on actual
42// WiFi silicon, not on the family macro. (FastLED#3576 Phase 4)
43#if defined(FL_IS_ESP32)
44#include "soc/soc_caps.h" // SOC_WIFI_SUPPORTED
45#endif
46
47#if defined(FL_IS_ESP32) && defined(SOC_WIFI_SUPPORTED) && SOC_WIFI_SUPPORTED
48
49// Unified HTTP server API (must be included before Arduino.h to avoid INADDR_NONE conflict)
51
52#include "fl/stl/array.h"
53#include "fl/stl/cstring.h"
54#include "fl/stl/unique_ptr.h"
55#include "fl/stl/sstream.h"
56#include <Arduino.h>
57
58// ESP-IDF headers for WiFi AP and HTTP client
59// Must come after Arduino.h to get proper IPAddress definitions
60// IWYU pragma: begin_keep
61#include <esp_wifi.h>
62#include <esp_netif.h>
63#include <esp_event.h>
64#include <esp_http_client.h>
65// IWYU pragma: end_keep
66
67struct EspNetState {
68 fl::unique_ptr<fl::asio::http::Server> http_server;
69 esp_netif_t* netif_ap = nullptr;
70 bool event_loop_initialized = false;
71 bool wifi_initialized = false;
72};
73
74EspNetState& espNetState() {
76}
77
78// ============================================================================
79// WiFi Soft AP Setup
80// ============================================================================
81
82static bool initWifiAP() {
83 if (getNetState().wifi_ap_active) {
84 return true; // Already active
85 }
86
87 // Initialize TCP/IP stack (safe to call multiple times)
88 esp_err_t err = esp_netif_init();
89 if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
90 FL_WARN("[NET] esp_netif_init failed: " << esp_err_to_name(err));
91 return false;
92 }
93
94 // Create default event loop (safe to call multiple times)
95 if (!espNetState().event_loop_initialized) {
96 err = esp_event_loop_create_default();
97 if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
98 FL_WARN("[NET] esp_event_loop_create_default failed: " << esp_err_to_name(err));
99 return false;
100 }
101 espNetState().event_loop_initialized = true;
102 }
103
104 // Create default WiFi AP netif
105 if (!espNetState().netif_ap) {
106 espNetState().netif_ap = esp_netif_create_default_wifi_ap();
107 if (!espNetState().netif_ap) {
108 FL_WARN("[NET] esp_netif_create_default_wifi_ap failed");
109 return false;
110 }
111 }
112
113 // Initialize WiFi with default config
114 if (!espNetState().wifi_initialized) {
115 wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
116 err = esp_wifi_init(&cfg);
117 if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
118 FL_WARN("[NET] esp_wifi_init failed: " << esp_err_to_name(err));
119 return false;
120 }
121 espNetState().wifi_initialized = true;
122 }
123
124 // Configure AP
125 wifi_config_t wifi_config = {};
127 wifi_config.ap.ssid_len = strlen(AUTORESEARCH_NET_SSID);
129 wifi_config.ap.max_connection = AUTORESEARCH_NET_MAX_CONNECTIONS;
130 wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK;
131 wifi_config.ap.channel = 1;
132
133 err = esp_wifi_set_mode(WIFI_MODE_AP);
134 if (err != ESP_OK) {
135 FL_WARN("[NET] esp_wifi_set_mode failed: " << esp_err_to_name(err));
136 return false;
137 }
138
139 err = esp_wifi_set_config(WIFI_IF_AP, &wifi_config);
140 if (err != ESP_OK) {
141 FL_WARN("[NET] esp_wifi_set_config failed: " << esp_err_to_name(err));
142 return false;
143 }
144
145 err = esp_wifi_start();
146 if (err != ESP_OK) {
147 FL_WARN("[NET] esp_wifi_start failed: " << esp_err_to_name(err));
148 return false;
149 }
150
152 FL_WARN("[NET] WiFi AP started: SSID=" << AUTORESEARCH_NET_SSID << " IP=" << AUTORESEARCH_NET_AP_IP);
153 return true;
154}
155
156// ============================================================================
157// HTTP Server Setup (using unified fl::asio::http::Server)
158// ============================================================================
159
160static bool startHttpServer() {
161 if (espNetState().http_server.get()) {
162 return true; // Already running
163 }
164
165 espNetState().http_server = fl::make_unique<fl::asio::http::Server>();
166
167 // Register routes using the unified API
168 espNetState().http_server->get("/ping", [](const fl::asio::http::Request&) {
169 return fl::asio::http::Response::ok("pong");
170 });
171
172 // NOTE (FastLED#3588): these handlers execute ON the esp_http_server
173 // task, concurrently with the main loop. Building fl::json here
174 // reproducibly corrupted main-task json objects on classic ESP32
175 // (bisect: string-only handlers are stable, fl::json handlers crash
176 // the main task within one request). Until fl::json is audited for
177 // cross-task use, handlers format responses with fl::snprintf.
178 espNetState().http_server->get("/status", [](const fl::asio::http::Request&) {
179 char body[128];
180#if defined(FL_IS_ESP_32S3)
181 const char* chip = "esp32s3";
182#elif defined(FL_IS_ESP_32C6)
183 const char* chip = "esp32c6";
184#elif defined(FL_IS_ESP_32C3)
185 const char* chip = "esp32c3";
186#else
187 const char* chip = "esp32";
188#endif
189 fl::snprintf(body, sizeof(body),
190 "{\"uptime_ms\":%u,\"free_heap\":%u,\"chip\":\"%s\"}",
191 static_cast<unsigned>(millis()),
192 static_cast<unsigned>(ESP.getFreeHeap()), chip);
194 resp.header("Content-Type", "application/json");
195 return resp;
196 });
197
198 espNetState().http_server->post("/echo", [](const fl::asio::http::Request& req) {
199 if (!req.has_body()) {
201 }
203 });
204
205 espNetState().http_server->get("/leds", [](const fl::asio::http::Request&) {
206 // Static body — see the fl::json cross-task note above (#3588).
208 fl::asio::http::Response::ok("{\"num_leds\":10,\"brightness\":64}");
209 resp.header("Content-Type", "application/json");
210 return resp;
211 });
212
213 // Network-carried JSON-RPC contract for the peer fixture. These responses
214 // stay string-only because the ESP HTTP task must not construct fl::json
215 // objects concurrently with the sketch loop (#3588).
216 espNetState().http_server->get("/rpc/discover", [](const fl::asio::http::Request&) {
218 "{\"jsonrpc\":\"2.0\",\"result\":{\"methods\":[\"rpc.discover\",\"ping\",\"debugTest\",\"status\"]},\"id\":1}");
219 resp.header("Content-Type", "application/json");
220 return resp;
221 });
222 espNetState().http_server->get("/rpc/ping", [](const fl::asio::http::Request&) {
224 "{\"jsonrpc\":\"2.0\",\"result\":{\"pong\":true},\"id\":1}");
225 resp.header("Content-Type", "application/json");
226 return resp;
227 });
228 espNetState().http_server->get("/rpc/status", [](const fl::asio::http::Request&) {
230 "{\"jsonrpc\":\"2.0\",\"result\":{\"ready\":true},\"id\":1}");
231 resp.header("Content-Type", "application/json");
232 return resp;
233 });
234 espNetState().http_server->get("/rpc/unknown", [](const fl::asio::http::Request&) {
236 resp.body("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"},\"id\":1}");
237 resp.header("Content-Type", "application/json");
238 return resp;
239 });
240 espNetState().http_server->post("/rpc", [](const fl::asio::http::Request& req) {
241 const fl::string body = req.body();
242 if (!fl::strstr(body.c_str(), "\"jsonrpc\":\"2.0\"") ||
243 !fl::strstr(body.c_str(), "\"method\"")) {
245 "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"},\"id\":1}");
246 resp.header("Content-Type", "application/json");
247 return resp;
248 }
249 const char* response =
250 "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"},\"id\":1}";
251 if (fl::strstr(body.c_str(), "\"method\":\"rpc.discover\"")) {
252 response =
253 "{\"jsonrpc\":\"2.0\",\"result\":{\"methods\":[\"rpc.discover\",\"ping\",\"debugTest\",\"status\"]},\"id\":1}";
254 } else if (fl::strstr(body.c_str(), "\"method\":\"ping\"")) {
255 response = "{\"jsonrpc\":\"2.0\",\"result\":{\"pong\":true},\"id\":1}";
256 } else if (fl::strstr(body.c_str(), "\"method\":\"status\"")) {
257 response = "{\"jsonrpc\":\"2.0\",\"result\":{\"ready\":true},\"id\":1}";
258 }
260 resp.header("Content-Type", "application/json");
261 return resp;
262 });
263
264 if (!espNetState().http_server->start(getNetState().server_port)) {
265 FL_WARN("[NET] HTTP server failed to start: " << espNetState().http_server->last_error().c_str());
266 espNetState().http_server.reset();
267 return false;
268 }
269
271 FL_WARN("[NET] HTTP server started on port " << getNetState().server_port);
272 return true;
273}
274
275// ============================================================================
276// HTTP Client Tests (using esp_http_client - no unified client for ESP32 yet)
277// ============================================================================
278
280static bool initNetifForLoopback() {
281 esp_err_t err = esp_netif_init();
282 if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
283 FL_WARN("[NET] esp_netif_init failed: " << esp_err_to_name(err));
284 return false;
285 }
286
287 if (!espNetState().event_loop_initialized) {
288 err = esp_event_loop_create_default();
289 if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
290 FL_WARN("[NET] esp_event_loop_create_default failed: "
291 << esp_err_to_name(err));
292 return false;
293 }
294 espNetState().event_loop_initialized = true;
295 }
296 return true;
297}
298
299struct HttpResponseCapture {
300 char body[256] = {};
301 size_t length = 0;
302 bool truncated = false;
303};
304
305struct HttpResponseHash {
306 uint32_t hash = 2166136261u;
307 size_t bytes = 0;
308};
309
310static esp_err_t captureHttpResponse(esp_http_client_event_t* event) {
311 if (event->event_id != HTTP_EVENT_ON_DATA || event->data == nullptr ||
312 event->data_len <= 0 || event->user_data == nullptr) {
313 return ESP_OK;
314 }
315 HttpResponseCapture* capture =
316 static_cast<HttpResponseCapture*>(event->user_data);
317 const size_t available = sizeof(capture->body) - 1 - capture->length;
318 const size_t incoming = static_cast<size_t>(event->data_len);
319 const size_t copied = incoming < available ? incoming : available;
320 fl::memcpy(capture->body + capture->length, event->data, copied);
321 capture->length += copied;
322 capture->body[capture->length] = '\0';
323 capture->truncated = capture->truncated || copied != incoming;
324 return ESP_OK;
325}
326
327static esp_err_t hashHttpResponse(esp_http_client_event_t* event) {
328 if (event->event_id != HTTP_EVENT_ON_DATA || event->data == nullptr ||
329 event->data_len <= 0 || event->user_data == nullptr) {
330 return ESP_OK;
331 }
332 HttpResponseHash* response = static_cast<HttpResponseHash*>(event->user_data);
333 const char* data = static_cast<const char*>(event->data);
334 for (int i = 0; i < event->data_len; ++i) {
335 response->hash ^= static_cast<uint8_t>(data[i]);
336 response->hash *= 16777619u;
337 }
338 response->bytes += static_cast<size_t>(event->data_len);
339 return ESP_OK;
340}
341
342static uint32_t fnv1a(const char* bytes, size_t length) {
343 uint32_t hash = 2166136261u;
344 for (size_t i = 0; i < length; ++i) {
345 hash ^= static_cast<uint8_t>(bytes[i]);
346 hash *= 16777619u;
347 }
348 return hash;
349}
350
351static fl::json runHttpPayloadEchoTest(const char* url) {
352 constexpr size_t kPayloadBytes = 4096;
354 for (size_t i = 0; i < payload.size(); ++i) {
355 payload[i] = static_cast<char>('A' + static_cast<char>(i % 23));
356 }
357 const uint32_t expected_hash = fnv1a(payload.data(), payload.size());
358
360 result.set("test", "POST /echo 4096-byte FNV-1a");
361 result.set("bytes", static_cast<int64_t>(payload.size()));
362 result.set("expected_hash", static_cast<int64_t>(expected_hash));
363
364 HttpResponseHash response;
365 esp_http_client_config_t config = {};
366 config.url = url;
367 config.event_handler = hashHttpResponse;
368 config.user_data = &response;
369 config.timeout_ms = 4000;
370 esp_http_client_handle_t client = esp_http_client_init(&config);
371 if (!client) {
372 result.set("passed", false);
373 result.set("error", "Failed to init HTTP client");
374 return result;
375 }
376 esp_http_client_set_method(client, HTTP_METHOD_POST);
377 esp_http_client_set_header(client, "Content-Type", "application/octet-stream");
378 esp_http_client_set_post_field(client, payload.data(),
379 static_cast<int>(payload.size()));
380
381 const esp_err_t err = esp_http_client_perform(client);
382 const int status = esp_http_client_get_status_code(client);
383 esp_http_client_cleanup(client);
384 const bool passed = err == ESP_OK && status == 200 &&
385 response.bytes == payload.size() &&
386 response.hash == expected_hash;
387 result.set("status_code", static_cast<int64_t>(status));
388 result.set("received_bytes", static_cast<int64_t>(response.bytes));
389 result.set("received_hash", static_cast<int64_t>(response.hash));
390 result.set("passed", passed);
391 if (!passed) {
392 result.set("error", "Echo payload hash mismatch");
393 }
394 return result;
395}
396
398static fl::json runHttpRequestTest(const char* url, const char* test_name,
399 const char* request_body,
400 int expected_status,
401 const char* expected_fragment) {
403 result.set("test", test_name);
404
405 HttpResponseCapture capture;
406 esp_http_client_config_t config = {};
407 config.url = url;
408 config.event_handler = captureHttpResponse;
409 config.user_data = &capture;
410 // Must stay below the task-WDT window: esp_http_client_perform()
411 // blocks the loop task, and on single-core chips (ESP32-C6) a 5 s
412 // block starved the idle task -> WDT panic_abort mid-test
413 // (FastLED#3576 Phase 7 dual-device bench).
414 config.timeout_ms = 2000;
415
416 esp_http_client_handle_t client = esp_http_client_init(&config);
417 if (!client) {
418 result.set("passed", false);
419 result.set("error", "Failed to init HTTP client");
420 return result;
421 }
422
423 if (request_body != nullptr) {
424 esp_http_client_set_method(client, HTTP_METHOD_POST);
425 esp_http_client_set_header(client, "Content-Type", "application/json");
426 esp_http_client_set_post_field(client, request_body,
427 static_cast<int>(fl::strlen(request_body)));
428 }
429
430 esp_err_t err = esp_http_client_perform(client);
431 if (err != ESP_OK) {
432 result.set("passed", false);
433 fl::sstream ss;
434 ss << "HTTP request failed: " << esp_err_to_name(err);
435 result.set("error", ss.str().c_str());
436 esp_http_client_cleanup(client);
437 return result;
438 }
439
440 int status = esp_http_client_get_status_code(client);
441 result.set("status_code", static_cast<int64_t>(status));
442
443 const bool status_ok = status == expected_status;
444 const bool content_ok = expected_fragment == nullptr ||
445 fl::strstr(capture.body, expected_fragment) != nullptr;
446 result.set("body_truncated", capture.truncated);
447 if (!status_ok || !content_ok || capture.truncated) {
448 result.set("passed", false);
449 fl::sstream ss;
450 if (!status_ok) {
451 ss << "Expected status " << expected_status << ", got " << status;
452 } else if (capture.truncated) {
453 ss << "Response body exceeded capture limit";
454 } else {
455 ss << "Unexpected response body";
456 }
457 result.set("error", ss.str().c_str());
458 } else {
459 result.set("passed", true);
460 }
461
462 esp_http_client_cleanup(client);
463 return result;
464}
465
466// ============================================================================
467// Public API
468// ============================================================================
469
472
473 if (!initWifiAP()) {
474 response.set("success", false);
475 response.set("error", "Failed to start WiFi AP");
476 return response;
477 }
478
479 if (!startHttpServer()) {
480 response.set("success", false);
481 response.set("error", "Failed to start HTTP server");
482 return response;
483 }
484
485 response.set("success", true);
486 response.set("ssid", AUTORESEARCH_NET_SSID);
487 response.set("password", AUTORESEARCH_NET_PASSWORD);
489 response.set("port", static_cast<int64_t>(getNetState().server_port));
490 return response;
491}
492
495
496 if (!initWifiAP()) {
497 response.set("success", false);
498 response.set("error", "Failed to start WiFi AP");
499 return response;
500 }
501
502 response.set("success", true);
503 response.set("ssid", AUTORESEARCH_NET_SSID);
504 response.set("password", AUTORESEARCH_NET_PASSWORD);
505 response.set("gateway_ip", AUTORESEARCH_NET_AP_IP);
506 return response;
507}
508
509fl::json runNetClientTest(const char* host_ip, uint16_t port) {
511 int tests_passed = 0;
512 int tests_failed = 0;
513 fl::json results = fl::json::array();
514
515 // Build URLs
516 char url_ping[128];
517 char url_status[128];
518 char url_leds[128];
519 char url_rpc_discover[128];
520 char url_rpc_ping[128];
521 char url_rpc_status[128];
522 char url_rpc_unknown[128];
523 char url_rpc[128];
524 char url_echo[128];
525 snprintf(url_ping, sizeof(url_ping), "http://%s:%u/ping", host_ip, port);
526 snprintf(url_status, sizeof(url_status), "http://%s:%u/status", host_ip, port);
527 snprintf(url_leds, sizeof(url_leds), "http://%s:%u/leds", host_ip, port);
528 snprintf(url_rpc_discover, sizeof(url_rpc_discover), "http://%s:%u/rpc/discover", host_ip, port);
529 snprintf(url_rpc_ping, sizeof(url_rpc_ping), "http://%s:%u/rpc/ping", host_ip, port);
530 snprintf(url_rpc_status, sizeof(url_rpc_status), "http://%s:%u/rpc/status", host_ip, port);
531 snprintf(url_rpc_unknown, sizeof(url_rpc_unknown), "http://%s:%u/rpc/unknown", host_ip, port);
532 snprintf(url_rpc, sizeof(url_rpc), "http://%s:%u/rpc", host_ip, port);
533 snprintf(url_echo, sizeof(url_echo), "http://%s:%u/echo", host_ip, port);
534
535 // Test 1: GET /ping
536 {
537 fl::json r = runHttpRequestTest(url_ping, "GET /ping", nullptr, 200,
538 "pong");
539 auto passed = r[fl::string("passed")].as_bool();
540 if (passed.has_value() && passed.value()) {
541 tests_passed++;
542 } else {
543 tests_failed++;
544 }
545 results.push_back(r);
546 }
547
548 FastLED.watchdog().feed(); // between blocking HTTP tests (single-core WDT)
549
550 // Test 2: GET /status
551 {
552 fl::json r = runHttpRequestTest(url_status, "GET /status", nullptr, 200,
553 "\"chip\":\"rp2350w\"");
554 auto passed = r[fl::string("passed")].as_bool();
555 if (passed.has_value() && passed.value()) {
556 tests_passed++;
557 } else {
558 tests_failed++;
559 }
560 results.push_back(r);
561 }
562
563 FastLED.watchdog().feed(); // between blocking HTTP tests (single-core WDT)
564
565 // Test 3: GET /leds
566 {
567 fl::json r = runHttpRequestTest(url_leds, "GET /leds", nullptr, 200,
568 "num_leds");
569 auto passed = r[fl::string("passed")].as_bool();
570 if (passed.has_value() && passed.value()) {
571 tests_passed++;
572 } else {
573 tests_failed++;
574 }
575 results.push_back(r);
576 }
577
578 const char* rpc_urls[] = {url_rpc_discover, url_rpc_ping, url_rpc_status,
579 url_rpc_unknown};
580 const char* rpc_names[] = {"GET /rpc/discover", "GET /rpc/ping",
581 "GET /rpc/status", "GET /rpc/unknown"};
582 const int rpc_statuses[] = {200, 200, 200, 404};
583 const char* rpc_expected[] = {"\"methods\"", "\"pong\":true",
584 "\"ready\":true", "-32601"};
585 for (size_t i = 0; i < 4; ++i) {
586 fl::json r = runHttpRequestTest(rpc_urls[i], rpc_names[i], nullptr,
587 rpc_statuses[i], rpc_expected[i]);
588 auto passed = r[fl::string("passed")].as_bool();
589 if (passed.has_value() && passed.value()) {
590 tests_passed++;
591 } else {
592 tests_failed++;
593 }
594 results.push_back(r);
595 FastLED.watchdog().feed();
596 }
597
598 const char* rpc_post_names[] = {"POST /rpc discover", "POST /rpc ping",
599 "POST /rpc status", "POST /rpc unknown"};
600 const char* rpc_post_bodies[] = {
601 "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.discover\",\"id\":1}",
602 "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}",
603 "{\"jsonrpc\":\"2.0\",\"method\":\"status\",\"id\":1}",
604 "{\"jsonrpc\":\"2.0\",\"method\":\"unknown\",\"id\":1}",
605 };
606 const char* rpc_post_expected[] = {"\"methods\"", "\"pong\":true",
607 "\"ready\":true", "-32601"};
608 for (size_t i = 0; i < 4; ++i) {
609 fl::json r = runHttpRequestTest(url_rpc, rpc_post_names[i],
610 rpc_post_bodies[i], 200,
611 rpc_post_expected[i]);
612 auto passed = r[fl::string("passed")].as_bool();
613 if (passed.has_value() && passed.value()) {
614 tests_passed++;
615 } else {
616 tests_failed++;
617 }
618 results.push_back(r);
619 FastLED.watchdog().feed();
620 }
621
622 fl::json payload_result = runHttpPayloadEchoTest(url_echo);
623 const auto payload_passed = payload_result[fl::string("passed")].as_bool();
624 if (payload_passed.has_value() && payload_passed.value()) {
625 ++tests_passed;
626 } else {
627 ++tests_failed;
628 }
629 results.push_back(payload_result);
630 FastLED.watchdog().feed();
631
632 response.set("success", tests_failed == 0);
633 response.set("tests_passed", static_cast<int64_t>(tests_passed));
634 response.set("tests_failed", static_cast<int64_t>(tests_failed));
635 response.set("results", results);
636 return response;
637}
638
641 response.set("success", false);
642 response.set("error", "netServerStats is RP-only");
643 return response;
644}
645
648 int tests_passed = 0;
649 int tests_failed = 0;
650 fl::json results = fl::json::array();
651
652 // Initialize TCP/IP stack for loopback (no WiFi needed)
653 if (!initNetifForLoopback()) {
654 response.set("success", false);
655 response.set("error", "Failed to initialize network stack for loopback");
656 return response;
657 }
658
659 // Start the HTTP server on localhost
660 if (!startHttpServer()) {
661 response.set("success", false);
662 response.set("error", "Failed to start HTTP server for loopback test");
663 return response;
664 }
665
666 FL_WARN("[NET] Loopback test: server running on port " << getNetState().server_port);
667
668 // Small delay to let server settle
669 delay(100);
670
671 // Build loopback URLs using 127.0.0.1
672 char url_ping[128];
673 char url_status[128];
674 char url_leds[128];
675 snprintf(url_ping, sizeof(url_ping), "http://127.0.0.1:%u/ping",
676 getNetState().server_port);
677 snprintf(url_status, sizeof(url_status), "http://127.0.0.1:%u/status",
678 getNetState().server_port);
679 snprintf(url_leds, sizeof(url_leds), "http://127.0.0.1:%u/leds",
680 getNetState().server_port);
681
682 // Test 1: GET /ping
683 {
684 fl::json r = runHttpRequestTest(url_ping, "GET /ping (loopback)",
685 nullptr, 200, "pong");
686 auto passed = r[fl::string("passed")].as_bool();
687 if (passed.has_value() && passed.value()) {
688 tests_passed++;
689 } else {
690 tests_failed++;
691 }
692 results.push_back(r);
693 }
694
695 // Test 2: GET /status
696 {
697 fl::json r = runHttpRequestTest(url_status, "GET /status (loopback)",
698 nullptr, 200, "\"chip\"");
699 auto passed = r[fl::string("passed")].as_bool();
700 if (passed.has_value() && passed.value()) {
701 tests_passed++;
702 } else {
703 tests_failed++;
704 }
705 results.push_back(r);
706 }
707
708 // Test 3: GET /leds
709 {
710 fl::json r = runHttpRequestTest(url_leds, "GET /leds (loopback)",
711 nullptr, 200, "num_leds");
712 auto passed = r[fl::string("passed")].as_bool();
713 if (passed.has_value() && passed.value()) {
714 tests_passed++;
715 } else {
716 tests_failed++;
717 }
718 results.push_back(r);
719 }
720
721 response.set("success", tests_failed == 0);
722 response.set("tests_passed", static_cast<int64_t>(tests_passed));
723 response.set("tests_failed", static_cast<int64_t>(tests_failed));
724 response.set("results", results);
725 return response;
726}
727
730
731 // Stop HTTP server (unified API)
732 if (espNetState().http_server.get()) {
733 espNetState().http_server->stop();
734 espNetState().http_server.reset();
736 FL_WARN("[NET] HTTP server stopped");
737 }
738
739 // Stop WiFi
740 if (getNetState().wifi_ap_active) {
741 esp_wifi_stop();
742 getNetState().wifi_ap_active = false;
743 FL_WARN("[NET] WiFi AP stopped");
744 }
745
746 response.set("success", true);
747 return response;
748}
749
750void pollNetServer() {}
751
752#elif defined(FL_IS_RP2350) && defined(PICO_CYW43_SUPPORTED)
753
754// ============================================================================
755// RP2350W CYW43 peer implementation
756// ============================================================================
757//
758// Arduino-Pico exposes lwIP through WiFiClient/WiFiServer rather than the
759// ESP-IDF APIs used above. Keep this deliberately small: it provides the same
760// GET endpoints as the C6 fixture and the matching outbound GET checks, while
761// the sketch loop calls pollNetServer() to service one peer at a time.
762
763#include "fl/net/wifi.h"
764#include "fl/stl/array.h"
765#include "fl/stl/cstdio.h"
766#include "fl/stl/cstdlib.h" // fl::atol for Content-Length
767#include "fl/stl/cstring.h"
768#include "fl/stl/singleton.h"
769#include "fl/stl/unique_ptr.h"
770
771namespace {
772
773// A stalled peer is one that has sent nothing for this long.
774//
775// 6000, matching kRpHttpClientStallMs. This was left at 2000 when the client
776// side was raised (#4231) on the reasoning that a dead connection should be
777// reaped quickly -- but the argument that justified raising the client
778// applies to the server unchanged, because it is a property of the peer, not
779// of which side we are. The C6 services a request from its sketch main loop
780// and blocks up to 5000 ms doing it, so a gap of more than 2000 ms between
781// our reading its headers and its body arriving is ordinary, not a fault.
782//
783// Observed on the bench: `--net-peer` fails intermittently -- about one run
784// in three, on master as well as on any branch -- with the RP's server taking
785// complete headers and then none of a 4096-byte body:
786//
787// POST /echo 4096-byte FNV-1a -> 408, received_bytes: 0
788// {'lastTimeoutWasStall': True, 'lastTimeoutElapsedMs': 2006,
789// 'lastTimeoutBodyLength': 0, 'lastTimeoutExpectedBody': 4096,
790// 'lastTimeoutHeadersComplete': True}
791//
792// 2006 ms: the peer missed the budget by six milliseconds. The client-side
793// fix recorded five requests at 2706-3586 ms that the old value would have
794// failed, all under the peer's 5000 ms ceiling; this is the same distribution
795// seen from the other end.
796//
797// A truly dead connection is still reaped -- kRpPeerMaxRequestMs (10000) is
798// the absolute ceiling and is unchanged. The cost of this change is that a
799// dead peer now occupies the single-client server for up to 6 s rather than
800// 2 s before that ceiling applies. See FastLED#3899.
801constexpr uint32_t kRpPeerStallMs = 6000;
802// Absolute ceiling for one request, so a peer that dribbles a byte at a time
803// cannot occupy the single-client server forever.
804constexpr uint32_t kRpPeerMaxRequestMs = 10000;
805
806struct RpPeerState {
807 fl::unique_ptr<WiFiServer> server;
808 fl::unique_ptr<WiFiClient> client;
809 char request[512] = {};
810 size_t request_length = 0;
811 char body[4097] = {};
812 size_t body_length = 0;
813 size_t expected_body_length = 0;
814 bool headers_complete = false;
815 bool response_complete = false;
816 uint32_t request_started_ms = 0;
817 // Why the last request was abandoned, for netServerStats. A 408 tells the
818 // peer that time ran out but not which budget ran out, nor how much of
819 // the request had arrived -- which is the difference between a slow peer,
820 // a request the server never completes, and a starved poll loop.
821 uint32_t timeout_count = 0;
822 bool last_timeout_was_stall = false;
823 size_t last_timeout_body_length = 0;
824 size_t last_timeout_expected_body = 0;
825 uint32_t last_timeout_elapsed_ms = 0;
826 bool last_timeout_headers_complete = false;
827 char last_timeout_request[64] = {};
828 // Progress watermark. The request deadline must measure a *stall*, not
829 // total elapsed time: a 4096-byte POST body arrives across several TCP
830 // segments and legitimately outruns a fixed budget on CYW43 SoftAP.
831 uint32_t last_progress_ms = 0;
832 // it after draining the RPC queue, so a blocking handler can starve it
833 // for seconds. Without this the stall timer charges that gap to the peer
834 // and answers 408 to a connection that never went quiet.
835};
836
837RpPeerState& rpPeerState() {
839}
840
841void resetRpPeerRequest(RpPeerState& state) {
842 state.request_length = 0;
843 state.body_length = 0;
844 state.expected_body_length = 0;
845 state.headers_complete = false;
846 state.response_complete = false;
847 state.request_started_ms = 0;
848 state.last_progress_ms = 0;
849 state.request[0] = '\0';
850 state.body[0] = '\0';
851}
852
853void writeHttpResponse(WiFiClient& client, const char* body,
854 const char* content_type) {
855 client.print("HTTP/1.1 200 OK\r\nContent-Type: ");
856 client.print(content_type);
857 client.print("\r\nContent-Length: ");
858 client.print(fl::strlen(body));
859 client.print("\r\nConnection: close\r\n\r\n");
860 client.print(body);
861}
862
863void writeHttpNotFoundResponse(WiFiClient& client, const char* body) {
864 client.print("HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: ");
865 client.print(fl::strlen(body));
866 client.print("\r\nConnection: close\r\n\r\n");
867 client.print(body);
868}
869
870void writeHttpErrorResponse(WiFiClient& client, const char* status,
871 const char* body,
872 const char* content_type = "text/plain") {
873 client.print("HTTP/1.1 ");
874 client.print(status);
875 client.print("\r\nContent-Type: ");
876 client.print(content_type);
877 client.print("\r\nContent-Length: ");
878 client.print(fl::strlen(body));
879 client.print("\r\nConnection: close\r\n\r\n");
880 client.print(body);
881}
882
883// A peer that has sent nothing for this long is stalled.
884//
885// This has to exceed the peer's own budget for producing a response, or the
886// client abandons work the server is still legitimately doing. The ESP peer
887// does not answer from its network task: handle_esp_request() queues the
888// request to the sketch's main loop (ServerAsyncRunner, pumped by
889// task::Executor) and blocks up to 5000 ms waiting for it, so nothing is
890// written at all until that completes. There are no early bytes to refresh a
891// stall timer, which is why a 2000 ms value behaved exactly like the flat
892// deadline it replaced for this failure.
893//
894// Measured on the RP2350W <-> ESP32-C6 bench: 4 hard failures in 241 cycles,
895// every one with the socket still open, zero bytes read, and elapsed of
896// exactly the client budget. With a larger budget, 215 cycles produced none,
897// and five requests took 2706, 2734, 2961, 3108 and 3586 ms and completed
898// correctly -- each of which the old value would have failed. The maximum,
899// 3586 ms, sits under the peer's own 5000 ms ceiling, as the mechanism
900// predicts. See FastLED#3899.
901constexpr uint32_t kRpHttpClientStallMs = 6000;
902// Ceiling for one exchange, so a peer dribbling a byte at a time cannot hold
903// the loop past AutoResearch's watchdog. The loop feeds the WDT while
904// waiting, but bounding it keeps a wedged peer from stalling the sketch.
905// Matches kRpPeerMaxRequestMs so both sides of the fixture agree on how long
906// one request may take, rather than the client being the stricter of the two.
907constexpr uint32_t kRpHttpClientMaxMs = kRpPeerMaxRequestMs;
908
909fl::json runRpHttpRequestTest(const char* host_ip, uint16_t port,
910 const char* method, const char* path,
911 const char* request_body, const char* test_name,
912 int expected_status,
913 const char* expected_fragment) {
915 result.set("test", test_name);
916
917 WiFiClient client;
918 // Start the exchange clock before connect(): a slow or unreachable peer
919 // spends that time inside connect(), and a deadline armed afterwards
920 // cannot bound it.
921 const uint32_t exchange_started_ms = millis();
922 uint32_t last_progress_ms = exchange_started_ms;
923 if (!client.connect(host_ip, port)) {
924 result.set("passed", false);
925 result.set("error", "TCP connect failed");
926 return result;
927 }
928
929 client.print(method);
930 client.print(" ");
931 client.print(path);
932 client.print(" HTTP/1.1\r\nHost: ");
933 client.print(host_ip);
934 if (request_body != nullptr) {
935 client.print("\r\nContent-Type: application/json\r\nContent-Length: ");
936 client.print(fl::strlen(request_body));
937 }
938 client.print("\r\nConnection: close\r\n\r\n");
939 if (request_body != nullptr) {
940 client.print(request_body);
941 }
942
943 // One flat deadline for the whole exchange -- first-byte wait, status
944 // line, headers and body -- meant a peer that was merely slow to start
945 // failed the request outright, with an empty status line and zero bytes
946 // read. Measure a stall instead, refreshed by every byte that arrives,
947 // and keep an absolute ceiling so a dead peer is still abandoned. Same
948 // correction as #4219 made on the server side of this file.
949 const uint32_t hard_deadline_ms = exchange_started_ms + kRpHttpClientMaxMs;
950 const auto expired = [&]() -> bool {
951 const uint32_t now = millis();
952 return static_cast<int32_t>(now - last_progress_ms) >= kRpHttpClientStallMs ||
953 static_cast<int32_t>(now - hard_deadline_ms) >= 0;
954 };
955 while (!client.available() && client.connected() && !expired()) {
956 FastLED.watchdog().feed();
957 delay(1);
958 }
959
960 // Read one CRLF-terminated line, waiting for bytes that have not arrived
961 // yet. The previous status-line loop stopped as soon as `available()` went
962 // false, so a response split across TCP segments left it holding a partial
963 // line -- harmless when everything was drained into one buffer, fatal once
964 // the headers have to be parsed. Returns false only if the deadline
965 // expires with the line unfinished. See FastLED#4173.
966 //
967 // Bytes consumed by the most recent call, including the '\r' that is
968 // stripped from `out`. An empty `out` on its own cannot separate "the
969 // peer sent nothing" from "the peer sent only '\r' and then stalled or
970 // closed", and those are different faults.
971 size_t last_line_bytes_read = 0;
972 auto read_line = [&](char* out, size_t out_size) -> bool {
973 size_t used = 0;
974 while (!expired()) {
975 if (!client.available()) {
976 if (!client.connected()) {
977 break;
978 }
979 FastLED.watchdog().feed();
980 delay(1);
981 continue;
982 }
983 const int ch = client.read();
984 if (ch < 0) {
985 break;
986 }
987 last_progress_ms = millis();
988 if (ch == '\n') {
989 out[used] = '\0';
990 return true;
991 }
992 if (ch != '\r' && used + 1 < out_size) {
993 out[used++] = static_cast<char>(ch);
994 }
995 }
996 out[used] = '\0';
997 return false;
998 };
999
1000 // Whether a full status line actually arrived. Discarding this made every
1001 // no-response failure report "Unexpected HTTP status" with an empty
1002 // status_line -- the peer had answered nothing at all, which is a
1003 // different fault from answering with the wrong code, and the one that
1004 // actually occurs on this link. See FastLED#3899.
1005 char status_line[64];
1006 const bool status_line_complete = read_line(status_line, sizeof(status_line));
1007 const bool status_bytes_seen = last_line_bytes_read > 0;
1008 // Captured before the body loop and the stop() below can change it. With
1009 // no status line, this is what separates "the peer closed without
1010 // answering" from "the peer held the connection open and never answered".
1011 const bool peer_open_after_status = client.connected();
1012
1013 // Consume headers, capturing Content-Length. Every response this client
1014 // talks to sends one along with `Connection: close`; stopping on the
1015 // declared length is what lets the read finish promptly, because
1016 // `client.connected()` does not go false quickly enough to end it and the
1017 // old loop therefore waited out its full 2 s deadline on every request --
1018 // ~2.2 s x 12 requests = the 26.5 s measured, almost all of it idle.
1019 long content_length = -1;
1020 char header[128];
1021 while (read_line(header, sizeof(header))) {
1022 if (header[0] == '\0') {
1023 break; // blank line: headers done
1024 }
1025 if (content_length < 0 &&
1026 fl::strncmp(header, "Content-Length:", 15) == 0) {
1027 const char* value = header + 15;
1028 while (*value == ' ') {
1029 ++value;
1030 }
1031 content_length = fl::atol(value);
1032 }
1033 }
1034
1035 // Record the body only. It previously held headers *and* body, so a
1036 // response with long headers could push the asserted fragment out of the
1037 // 256-byte window entirely.
1038 char response[256] = {};
1039 size_t response_length = 0;
1040 long body_read = 0;
1041 while (!expired()) {
1042 if (content_length >= 0 && body_read >= content_length) {
1043 break; // complete
1044 }
1045 if (client.available()) {
1046 const int ch = client.read();
1047 if (ch < 0) {
1048 break;
1049 }
1050 last_progress_ms = millis();
1051 ++body_read;
1052 // Keep draining past the buffer so the socket is not abandoned
1053 // mid-response; only the first 256 bytes are recorded.
1054 if (response_length + 1 < sizeof(response)) {
1055 response[response_length++] = static_cast<char>(ch);
1056 }
1057 continue;
1058 }
1059 if (!client.connected()) {
1060 break; // closed with nothing buffered
1061 }
1062 FastLED.watchdog().feed();
1063 delay(1);
1064 }
1065 // Captured before stop(), which would make connected() false regardless.
1066 // With a short body this separates "the peer closed early" from "the peer
1067 // held the connection and stopped sending", and elapsed_ms says whether
1068 // the 2 s deadline is what ended the read.
1069 const bool peer_open_after_body = client.connected();
1070 const uint32_t elapsed_ms = millis() - exchange_started_ms;
1071 client.stop();
1072 response[response_length] = '\0';
1073
1074 char expected_status_code[4] = {};
1075 fl::snprintf(expected_status_code, sizeof(expected_status_code), "%d",
1076 expected_status);
1077 const bool has_http_prefix = fl::strncmp(status_line, "HTTP/1.1 ", 9) == 0 ||
1078 fl::strncmp(status_line, "HTTP/1.0 ", 9) == 0;
1079 const bool passed = has_http_prefix &&
1080 fl::strncmp(status_line + 9, expected_status_code, 3) == 0;
1081 const bool content_ok = expected_fragment == nullptr ||
1082 fl::strstr(response, expected_fragment) != nullptr;
1083
1084 // A short body is a failure even when the fragment is in it. The loop
1085 // above can leave early two ways -- the peer closing, or the deadline --
1086 // and both can happen mid-body, which is exactly the truncation this
1087 // change is about not tolerating. Without this, a response cut off after
1088 // the fragment reads as a pass.
1089 //
1090 // Only checkable when the peer declared a length; without one there is
1091 // nothing to compare against and the close is the only end-of-body
1092 // signal there is.
1093 const bool body_complete = content_length < 0 || body_read >= content_length;
1094
1095 result.set("status_line", status_line);
1096 result.set("content_length", static_cast<int32_t>(content_length));
1097 result.set("body_read", static_cast<int32_t>(body_read));
1098 const bool failed = !(passed && content_ok && body_complete);
1099 result.set("passed", !failed);
1100 if (failed) {
1101 // Recorded for every failure mode, not just one: a truncated body is
1102 // the mode actually seen on this link, and without these two fields
1103 // it cannot say whether the peer closed early or simply stopped
1104 // sending until the deadline expired.
1105 result.set("peer_open_after_status", peer_open_after_status);
1106 result.set("peer_open_after_body", peer_open_after_body);
1107 result.set("elapsed_ms", static_cast<int32_t>(elapsed_ms));
1108 }
1109 if (!passed) {
1110 // Distinguish "no answer" from "wrong answer": an empty status line
1111 // means the request went out and nothing came back within the
1112 // deadline, so the status code is not the thing that failed.
1113 if (!status_line_complete && !status_bytes_seen) {
1114 result.set("error", "No HTTP response: request sent, nothing read");
1115 } else if (!status_line_complete) {
1116 result.set("error", "Truncated HTTP status line");
1117 } else {
1118 result.set("error", "Unexpected HTTP status");
1119 }
1120 } else if (!body_complete) {
1121 result.set("error", "Truncated response body");
1122 } else if (!content_ok) {
1123 result.set("error", "Unexpected response body");
1124 }
1125 return result;
1126}
1127
1128uint32_t fnv1a(const char* bytes, size_t length) {
1129 uint32_t hash = 2166136261u;
1130 for (size_t i = 0; i < length; ++i) {
1131 hash ^= static_cast<uint8_t>(bytes[i]);
1132 hash *= 16777619u;
1133 }
1134 return hash;
1135}
1136
1137fl::json runRpHttpPayloadEchoTest(const char* host_ip, uint16_t port) {
1138 constexpr size_t kPayloadBytes = 4096;
1140 for (size_t i = 0; i < payload.size(); ++i) {
1141 payload[i] = static_cast<char>('A' + static_cast<char>(i % 23));
1142 }
1143 const uint32_t expected_hash = fnv1a(payload.data(), payload.size());
1144
1146 result.set("test", "POST /echo 4096-byte FNV-1a");
1147 result.set("bytes", static_cast<int64_t>(payload.size()));
1148 result.set("expected_hash", static_cast<int64_t>(expected_hash));
1149
1150 WiFiClient client;
1151 // Same as runRpHttpRequestTest: the clock has to start before connect(),
1152 // since an unreachable peer spends that time inside connect() and a
1153 // deadline armed afterwards cannot bound it.
1154 const uint32_t echo_started_ms = millis();
1155 uint32_t echo_progress_ms = echo_started_ms;
1156 if (!client.connect(host_ip, port)) {
1157 result.set("passed", false);
1158 result.set("error", "TCP connect failed");
1159 return result;
1160 }
1161 client.print("POST /echo HTTP/1.1\r\nHost: ");
1162 client.print(host_ip);
1163 client.print("\r\nContent-Type: application/octet-stream\r\nContent-Length: ");
1164 client.print(payload.size());
1165 client.print("\r\nConnection: close\r\n\r\n");
1166 client.write(reinterpret_cast<const uint8_t*>(payload.data()), payload.size());
1167
1168 // Stall-based, for the same reason as runRpHttpRequestTest above: a flat
1169 // budget for a 4 KiB round trip fails a peer that is merely slow rather
1170 // than stuck, and reports it as a hash mismatch against zero bytes.
1171 const uint32_t echo_hard_deadline_ms =
1172 echo_started_ms + kRpHttpClientMaxMs;
1173 const auto echo_expired = [&]() -> bool {
1174 const uint32_t now = millis();
1175 return static_cast<int32_t>(now - echo_progress_ms) >= kRpHttpClientStallMs ||
1176 static_cast<int32_t>(now - echo_hard_deadline_ms) >= 0;
1177 };
1178 char status_line[64] = {};
1179 size_t status_length = 0;
1180 bool status_done = false;
1181 bool header_done = false;
1182 uint8_t header_match = 0;
1183 uint32_t received_hash = 2166136261u;
1184 size_t received_bytes = 0;
1185 while (!echo_expired() && (client.connected() || client.available())) {
1186 while (client.available()) {
1187 // A peer streaming without pause would otherwise keep this inner
1188 // loop running past the hard deadline, because the outer check
1189 // is only reached when available() goes false.
1190 if (echo_expired()) break;
1191 const int value = client.read();
1192 if (value < 0) {
1193 break;
1194 }
1195 echo_progress_ms = millis();
1196 const char ch = static_cast<char>(value);
1197 if (!status_done) {
1198 if (ch == '\n') {
1199 status_done = true;
1200 } else if (ch != '\r' && status_length + 1 < sizeof(status_line)) {
1201 status_line[status_length++] = ch;
1202 }
1203 continue;
1204 }
1205 if (!header_done) {
1206 header_match = ch == "\r\n\r\n"[header_match]
1207 ? static_cast<uint8_t>(header_match + 1) : 0;
1208 if (header_match == 4) {
1209 header_done = true;
1210 }
1211 continue;
1212 }
1213 received_hash ^= static_cast<uint8_t>(ch);
1214 received_hash *= 16777619u;
1215 ++received_bytes;
1216 }
1217 FastLED.watchdog().feed();
1218 delay(1);
1219 }
1220 client.stop();
1221 status_line[status_length] = '\0';
1222 const bool status_ok = fl::strncmp(status_line, "HTTP/1.1 200", 12) == 0 ||
1223 fl::strncmp(status_line, "HTTP/1.0 200", 12) == 0;
1224 const bool passed = status_ok && received_bytes == payload.size() &&
1225 received_hash == expected_hash;
1226 result.set("received_bytes", static_cast<int64_t>(received_bytes));
1227 result.set("received_hash", static_cast<int64_t>(received_hash));
1228 result.set("passed", passed);
1229 if (!passed) {
1230 result.set("error", "Echo payload hash mismatch");
1231 }
1232 return result;
1233}
1234
1235} // namespace
1236
1240 response.set("success", false);
1241 response.set("error", "WiFi station is not connected");
1242 return response;
1243 }
1244
1245 RpPeerState& state = rpPeerState();
1246 if (!state.server) {
1248 const uint32_t deadline_ms = millis() + 2000;
1249 do {
1250 state.server->begin();
1251 if (*state.server) {
1252 state.server->setNoDelay(true);
1253 break;
1254 }
1255 FastLED.watchdog().feed();
1256 delay(10);
1257 } while (static_cast<int32_t>(millis() - deadline_ms) < 0);
1258 if (!*state.server) {
1259 state.server.reset();
1260 response.set("success", false);
1261 response.set("error", "TCP server failed to listen");
1262 return response;
1263 }
1264 }
1266 response.set("success", true);
1267 response.set("ip", fl::net::wifi::ipAddress().c_str());
1268 response.set("port", static_cast<int64_t>(AUTORESEARCH_NET_SERVER_PORT));
1269 return response;
1270}
1271
1274 response.set("success", fl::net::wifi::isConnected());
1275 response.set("ip", fl::net::wifi::ipAddress().c_str());
1276 return response;
1277}
1278
1279fl::json runNetClientTest(const char* host_ip, uint16_t port) {
1281 if (host_ip == nullptr || *host_ip == '\0' ||
1283 response.set("success", false);
1284 response.set("error", "WiFi station is not connected");
1285 return response;
1286 }
1287
1288 fl::json results = fl::json::array();
1289 const char* paths[] = {"/ping", "/status", "/leds", "/rpc/discover",
1290 "/rpc/ping", "/rpc/status", "/rpc/unknown"};
1291 const char* names[] = {"GET /ping", "GET /status", "GET /leds",
1292 "GET /rpc/discover", "GET /rpc/ping", "GET /rpc/status",
1293 "GET /rpc/unknown"};
1294 const int expected_statuses[] = {200, 200, 200, 200, 200, 200, 404};
1295 const char* expected[] = {"pong", "\"chip\"", "num_leds", "\"methods\"",
1296 "\"pong\":true", "\"ready\":true", "-32601"};
1297 int passed = 0;
1298 for (size_t i = 0; i < 7; ++i) {
1299 fl::json result = runRpHttpRequestTest(host_ip, port, "GET", paths[i],
1300 nullptr, names[i],
1301 expected_statuses[i], expected[i]);
1302 const auto result_passed = result[fl::string("passed")].as_bool();
1303 if (result_passed.has_value() && result_passed.value()) {
1304 ++passed;
1305 }
1306 results.push_back(result);
1307 FastLED.watchdog().feed();
1308 }
1309
1310 const char* rpc_post_names[] = {"POST /rpc discover", "POST /rpc ping",
1311 "POST /rpc status", "POST /rpc unknown"};
1312 const char* rpc_post_bodies[] = {
1313 "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.discover\",\"id\":1}",
1314 "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}",
1315 "{\"jsonrpc\":\"2.0\",\"method\":\"status\",\"id\":1}",
1316 "{\"jsonrpc\":\"2.0\",\"method\":\"unknown\",\"id\":1}",
1317 };
1318 const char* rpc_post_expected[] = {"\"methods\"", "\"pong\":true",
1319 "\"ready\":true", "-32601"};
1320 for (size_t i = 0; i < 4; ++i) {
1321 fl::json result = runRpHttpRequestTest(host_ip, port, "POST", "/rpc",
1322 rpc_post_bodies[i], rpc_post_names[i],
1323 200, rpc_post_expected[i]);
1324 const auto result_passed = result[fl::string("passed")].as_bool();
1325 if (result_passed.has_value() && result_passed.value()) {
1326 ++passed;
1327 }
1328 results.push_back(result);
1329 FastLED.watchdog().feed();
1330 }
1331
1332 fl::json payload_result = runRpHttpPayloadEchoTest(host_ip, port);
1333 const auto payload_passed = payload_result[fl::string("passed")].as_bool();
1334 if (payload_passed.has_value() && payload_passed.value()) {
1335 ++passed;
1336 }
1337 results.push_back(payload_result);
1338
1339 response.set("success", passed == 12);
1340 response.set("tests_passed", static_cast<int64_t>(passed));
1341 response.set("tests_failed", static_cast<int64_t>(12 - passed));
1342 response.set("results", results);
1343 return response;
1344}
1345
1347 RpPeerState& state = rpPeerState();
1349 response.set("success", true);
1350 response.set("timeoutCount", static_cast<int64_t>(state.timeout_count));
1351 response.set("lastTimeoutWasStall", state.last_timeout_was_stall);
1352 response.set("lastTimeoutBodyLength",
1353 static_cast<int64_t>(state.last_timeout_body_length));
1354 response.set("lastTimeoutExpectedBody",
1355 static_cast<int64_t>(state.last_timeout_expected_body));
1356 response.set("lastTimeoutElapsedMs",
1357 static_cast<int64_t>(state.last_timeout_elapsed_ms));
1358 response.set("lastTimeoutHeadersComplete", state.last_timeout_headers_complete);
1359 response.set("lastTimeoutRequest", state.last_timeout_request);
1360 return response;
1361}
1362
1365 response.set("success", false);
1366 response.set("error", "Loopback is not applicable to the CYW43 peer path");
1367 return response;
1368}
1369
1370fl::json stopNet() {
1372 RpPeerState& state = rpPeerState();
1373 if (state.client) {
1374 state.client->stop();
1375 state.client.reset();
1376 }
1377 resetRpPeerRequest(state);
1378 if (state.server) {
1379 state.server->stop();
1380 state.server.reset();
1381 }
1383 getNetState().wifi_ap_active = false;
1385 response.set("success", true);
1386 return response;
1387}
1388
1389void pollNetServer() {
1390 RpPeerState& state = rpPeerState();
1391 if (!state.server) {
1392 return;
1393 }
1394 if (!state.client && state.server->hasClient()) {
1395 state.client = fl::make_unique<WiFiClient>(state.server->accept());
1396 resetRpPeerRequest(state);
1397 state.request_started_ms = millis();
1398 state.last_progress_ms = state.request_started_ms;
1399 }
1400 if (!state.client) {
1401 return;
1402 }
1403 if (state.response_complete && !state.client->connected()) {
1404 state.client.reset();
1405 resetRpPeerRequest(state);
1406 return;
1407 }
1408 // Drop only a genuinely stalled peer, and cap the whole exchange so a
1409 // slow-loris cannot hold the single-client server open indefinitely.
1410 // Previously this was a flat 2 s from accept, which silently killed
1411 // in-progress 4 KiB POST bodies mid-transfer and left the client with
1412 // status_code=-1 and zero bytes -- the server-side twin of #4173/#4196.
1413 // Answering 408 instead of closing mutely means a real timeout is
1414 // reported as one rather than as a transport error.
1415 const uint32_t now_ms = millis();
1416 const bool stalled =
1417 static_cast<int32_t>(now_ms - state.last_progress_ms) >= kRpPeerStallMs;
1418 const bool over_budget =
1419 static_cast<int32_t>(now_ms - state.request_started_ms) >= kRpPeerMaxRequestMs;
1420 if (stalled || over_budget) {
1421 if (!state.response_complete) {
1422 ++state.timeout_count;
1423 state.last_timeout_was_stall = stalled;
1424 state.last_timeout_body_length = state.body_length;
1425 state.last_timeout_expected_body = state.expected_body_length;
1426 state.last_timeout_elapsed_ms = now_ms - state.request_started_ms;
1427 state.last_timeout_headers_complete = state.headers_complete;
1428 fl::size copy_len = state.request_length;
1429 if (copy_len >= sizeof(state.last_timeout_request)) {
1430 copy_len = sizeof(state.last_timeout_request) - 1;
1431 }
1432 fl::memcpy(state.last_timeout_request, state.request, copy_len);
1433 state.last_timeout_request[copy_len] = '\0';
1434 writeHttpErrorResponse(*state.client, "408 Request Timeout", "");
1435 }
1436 state.client->stop();
1437 state.client.reset();
1438 resetRpPeerRequest(state);
1439 return;
1440 }
1441 if (state.response_complete) {
1442 return;
1443 }
1444
1445 while (state.client->available() && !state.headers_complete &&
1446 state.request_length + 1 < sizeof(state.request)) {
1447 const int ch = state.client->read();
1448 if (ch < 0) {
1449 break;
1450 }
1451 state.request[state.request_length++] = static_cast<char>(ch);
1452 state.request[state.request_length] = '\0';
1453 state.last_progress_ms = millis();
1454 if (state.request_length >= 4 &&
1455 fl::strncmp(state.request + state.request_length - 4, "\r\n\r\n", 4) == 0) {
1456 state.headers_complete = true;
1457 }
1458 }
1459 if (!state.headers_complete) {
1460 if (state.request_length + 1 == sizeof(state.request)) {
1461 writeHttpErrorResponse(*state.client,
1462 "431 Request Header Fields Too Large", "");
1463 state.response_complete = true;
1464 }
1465 return;
1466 }
1467
1468 const bool is_post = fl::strncmp(state.request, "POST ", 5) == 0;
1469 const bool is_post_rpc = fl::strncmp(state.request, "POST /rpc ", 10) == 0;
1470 const bool is_post_echo = fl::strncmp(state.request, "POST /echo ", 11) == 0;
1471 if (is_post && state.expected_body_length == 0) {
1472 const char* content_length = fl::strstr(state.request, "\r\nContent-Length:");
1473 if (content_length == nullptr) {
1474 writeHttpErrorResponse(*state.client, "411 Length Required", "");
1475 state.response_complete = true;
1476 return;
1477 }
1478 content_length = fl::strstr(content_length, ":");
1479 ++content_length;
1480 while (*content_length == ' ') {
1481 ++content_length;
1482 }
1483 while (*content_length >= '0' && *content_length <= '9') {
1484 if (state.expected_body_length > (sizeof(state.body) - 1) / 10) {
1485 writeHttpErrorResponse(*state.client, "413 Payload Too Large", "");
1486 state.response_complete = true;
1487 return;
1488 }
1489 state.expected_body_length = state.expected_body_length * 10 +
1490 static_cast<size_t>(*content_length - '0');
1491 if (state.expected_body_length >= sizeof(state.body)) {
1492 writeHttpErrorResponse(*state.client, "413 Payload Too Large", "");
1493 state.response_complete = true;
1494 return;
1495 }
1496 ++content_length;
1497 }
1498 if (state.expected_body_length == 0) {
1499 writeHttpErrorResponse(*state.client, "400 Bad Request", "");
1500 state.response_complete = true;
1501 return;
1502 }
1503 }
1504 while (is_post && state.client->available() &&
1505 state.body_length < state.expected_body_length) {
1506 const int ch = state.client->read();
1507 if (ch < 0) {
1508 break;
1509 }
1510 state.body[state.body_length++] = static_cast<char>(ch);
1511 state.last_progress_ms = millis();
1512 }
1513 if (is_post && state.body_length < state.expected_body_length) {
1514 // Partial body: come back on a later poll. The stall timer above is
1515 // what bounds this, and it has just been refreshed by the bytes we
1516 // did consume, so a steadily-arriving 4 KiB body is never dropped.
1517 return;
1518 }
1519 state.body[state.body_length] = '\0';
1520
1521 if (fl::strncmp(state.request, "GET /ping ", 10) == 0) {
1522 writeHttpResponse(*state.client, "pong", "text/plain");
1523 } else if (fl::strncmp(state.request, "GET /status ", 12) == 0) {
1524 writeHttpResponse(*state.client,
1525 "{\"chip\":\"rp2350w\",\"network\":\"cyw43\"}",
1526 "application/json");
1527 } else if (fl::strncmp(state.request, "GET /leds ", 10) == 0) {
1528 writeHttpResponse(*state.client, "{\"num_leds\":10,\"brightness\":64}",
1529 "application/json");
1530 } else if (fl::strncmp(state.request, "GET /rpc/discover ", 18) == 0) {
1531 writeHttpResponse(*state.client,
1532 "{\"jsonrpc\":\"2.0\",\"result\":{\"methods\":[\"rpc.discover\",\"ping\",\"debugTest\",\"status\"]},\"id\":1}",
1533 "application/json");
1534 } else if (fl::strncmp(state.request, "GET /rpc/ping ", 14) == 0) {
1535 writeHttpResponse(*state.client,
1536 "{\"jsonrpc\":\"2.0\",\"result\":{\"pong\":true},\"id\":1}",
1537 "application/json");
1538 } else if (fl::strncmp(state.request, "GET /rpc/status ", 16) == 0) {
1539 writeHttpResponse(*state.client,
1540 "{\"jsonrpc\":\"2.0\",\"result\":{\"ready\":true},\"id\":1}",
1541 "application/json");
1542 } else if (fl::strncmp(state.request, "GET /rpc/unknown ", 17) == 0) {
1543 writeHttpNotFoundResponse(*state.client,
1544 "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"},\"id\":1}");
1545 } else if (is_post_echo) {
1546 writeHttpResponse(*state.client, state.body, "application/octet-stream");
1547 } else if (is_post_rpc) {
1548 if (!fl::strstr(state.body, "\"jsonrpc\":\"2.0\"") ||
1549 !fl::strstr(state.body, "\"method\"")) {
1550 writeHttpErrorResponse(
1551 *state.client, "400 Bad Request",
1552 "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"},\"id\":1}",
1553 "application/json");
1554 } else if (fl::strstr(state.body, "\"method\":\"rpc.discover\"")) {
1555 writeHttpResponse(*state.client,
1556 "{\"jsonrpc\":\"2.0\",\"result\":{\"methods\":[\"rpc.discover\",\"ping\",\"debugTest\",\"status\"]},\"id\":1}",
1557 "application/json");
1558 } else if (fl::strstr(state.body, "\"method\":\"ping\"")) {
1559 writeHttpResponse(*state.client,
1560 "{\"jsonrpc\":\"2.0\",\"result\":{\"pong\":true},\"id\":1}",
1561 "application/json");
1562 } else if (fl::strstr(state.body, "\"method\":\"status\"")) {
1563 writeHttpResponse(*state.client,
1564 "{\"jsonrpc\":\"2.0\",\"result\":{\"ready\":true},\"id\":1}",
1565 "application/json");
1566 } else {
1567 writeHttpResponse(*state.client,
1568 "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"},\"id\":1}",
1569 "application/json");
1570 }
1571 } else {
1572 writeHttpErrorResponse(*state.client, "404 Not Found", "");
1573 }
1574 state.response_complete = true;
1575}
1576
1577#else // !FL_IS_ESP32 && !FL_IS_RP2350
1578
1581 response.set("success", false);
1582 response.set("error", "netServerStats is RP-only");
1583 return response;
1584}
1585
1586// ============================================================================
1587// Stub Implementation for Non-ESP32 Platforms
1588// ============================================================================
1589
1592 response.set("success", false);
1593 response.set("error", "Net autoresearch only supported on ESP32");
1594 return response;
1595}
1596
1599 response.set("success", false);
1600 response.set("error", "Net autoresearch only supported on ESP32");
1601 return response;
1602}
1603
1604fl::json runNetClientTest(const char* host_ip, uint16_t port) {
1605 (void)host_ip;
1606 (void)port;
1608 response.set("success", false);
1609 response.set("error", "Net autoresearch only supported on ESP32");
1610 return response;
1611}
1612
1615 response.set("success", false);
1616 response.set("error", "Net loopback autoresearch only supported on ESP32");
1617 return response;
1618}
1619
1620fl::json stopNet() {
1622 response.set("success", true);
1623 return response;
1624}
1625
1626void pollNetServer() {}
1627
1628#endif // FL_IS_ESP32 || FL_IS_RP2350
1629
1630#endif // FL_PLATFORM_HAS_LARGE_MEMORY
#define AUTORESEARCH_NET_SSID
#define AUTORESEARCH_NET_PASSWORD
#define AUTORESEARCH_NET_MAX_CONNECTIONS
void pollNetServer()
Service pending peer HTTP requests from the sketch loop.
fl::json runNetLoopback()
Run self-contained loopback test: start HTTP server, client GETs localhost.
#define AUTORESEARCH_NET_AP_IP
fl::json startNetClient()
Start WiFi Soft AP only (for net-client mode).
AutoResearchNetState & getNetState()
Get current network autoresearch state.
fl::json runNetClientTest(const char *host_ip, uint16_t port)
Run HTTP client tests against a host server.
#define AUTORESEARCH_NET_SERVER_PORT
fl::json startNetServer()
Start WiFi Soft AP and HTTP server with test endpoints.
fl::json stopNet()
Stop WiFi AP and HTTP server/client, release all resources.
fl::json netServerStats()
Why the RP peer server last abandoned a request (FastLED#3899 diagnostics).
State for network autoresearch.
size_t capture(fl::shared_ptr< fl::RxChannel > rx_channel, fl::span< uint8_t > rx_buffer, size_t expected_data_bytes, int tx_pin, const fl::ChipsetTimingConfig &timing, const char *driver_name, fl::RunResult *diagnostics=nullptr)
fl::HttpServer server
int tests_passed
int tests_failed
TestState state
FL_DISABLE_WARNING_PUSH FL_DISABLE_WARNING_GLOBAL_CONSTRUCTORS CFastLED FastLED
Global LED strip management instance.
static T & instance() FL_NO_EXCEPT
Definition singleton.h:41
pointer data() FL_NO_EXCEPT
Definition array.h:70
fl::size size() const FL_NO_EXCEPT
Definition array.h:90
A fixed-size array implementation similar to std::array.
Definition array.h:27
const string & body() const FL_NO_EXCEPT
Get request body (for POST/PUT requests)
Definition server.h:59
bool has_body() const FL_NO_EXCEPT
Check if request has a body.
Definition server.h:81
HTTP request object (immutable, passed by const reference)
Definition server.h:48
Response & header(const string &name, const string &value) FL_NO_EXCEPT
Add HTTP header.
Response & body(const string &content) FL_NO_EXCEPT
Set response body.
static Response bad_request(const string &message) FL_NO_EXCEPT
Factory method for 400 Bad Request response.
static Response ok(const string &body="") FL_NO_EXCEPT
Factory method for 200 OK response.
static Response not_found() FL_NO_EXCEPT
Factory method for 404 Not Found response.
HTTP response builder (fluent interface)
Definition server.h:95
const char * c_str() const FL_NO_EXCEPT
fl::optional< bool > as_bool() const FL_NO_EXCEPT
Definition json.h:397
void push_back(const json &value) FL_NO_EXCEPT
Definition json.h:918
static json object() FL_NO_EXCEPT
Definition json.h:857
static json array() FL_NO_EXCEPT
Definition json.h:853
string str() const FL_NO_EXCEPT
Definition strstream.h:43
fl::UISlider length("Length", 1.0f, 0.0f, 1.0f, 0.01f)
FastLED's Elegant JSON Library: fl::json
#define FL_WARN(...)
Definition log.h:437
Centralized logging categories for FastLED hardware interfaces and subsystems.
fl::u32 fnv1a(fl::u32 hash, const fl::i16 *samples, fl::u32 count)
fl::enable_if<!fl::is_array< T >::value, unique_ptr< T > >::type make_unique(Args &&... args) FL_NO_EXCEPT
Definition unique_ptr.h:263
void * memcpy(void *dest, const void *src, size_t n) FL_NO_EXCEPT
size_t strlen(const char *s) FL_NO_EXCEPT
bool isConnected() FL_NO_EXCEPT
True when the station interface has an IP.
Definition wifi.cpp.hpp:44
void stop() FL_NO_EXCEPT
Stop WiFi entirely (STA and AP).
Definition wifi.cpp.hpp:42
Status status() FL_NO_EXCEPT
Current connection state.
Definition wifi.cpp.hpp:43
fl::string ipAddress() FL_NO_EXCEPT
Dotted-quad IP of the station interface ("" until connected).
Definition wifi.cpp.hpp:45
FASTLED_FORCE_INLINE long atol(const char *str) FL_NO_EXCEPT
Definition cstdlib.h:73
int snprintf(char *buffer, fl::size size, const char *format) FL_NO_EXCEPT
void delay(u32 ms, bool run_async=true) FL_NO_EXCEPT
Public delay wrapper that keeps bare Arduino delay() preferred after using fl::delay; while still all...
Definition delay.h:98
fl::u32 millis() FL_NO_EXCEPT
Universal millisecond timer - returns milliseconds since system startup.
FASTLED_FORCE_INLINE void * memcpy(void *dest, const void *src, size_t n) FL_NO_EXCEPT
Definition cstring.h:136
FASTLED_FORCE_INLINE const char * strstr(const char *haystack, const char *needle) FL_NO_EXCEPT
Definition cstring.h:88
expected< T, E > result
Alias for expected (Rust-style naming)
Definition result.h:31
int available() FL_NO_EXCEPT
FASTLED_FORCE_INLINE int strncmp(const char *s1, const char *s2, size_t n) FL_NO_EXCEPT
Definition cstring.h:64
FASTLED_FORCE_INLINE size_t strlen(const char *s) FL_NO_EXCEPT
Definition cstring.h:56
unsigned char uint8_t
Definition stdint.h:208
fl::u32 uint32_t
Definition stdint.h:218
fl::i32 int32_t
Definition stdint.h:219
fl::u16 uint16_t
Definition stdint.h:213
fl::net::wifi — platform-neutral WiFi connection API (FastLED#3576 Phase 6).