From 8a75e6c8ebfa8342b778ea76d7484d81aa0c787a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:16:22 +0000 Subject: [PATCH 1/6] web,serial: add transport-agnostic API dispatch + USB serial config server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors every REST endpoint in WebServer.h onto a shared (ApiRequest, ApiResponse) handler shape registered once in a dispatch table, instead of being written directly against AsyncWebServerRequest. Adds SerialConfigServer, a framed newline-delimited JSON protocol over the existing Serial connection that reaches the same dispatch table — so a device can be fully configured over USB without WiFi or the device's AP (#355), and a new endpoint only needs to be registered once to work over both HTTP and serial. Logger's serial sink is suspended for the duration of a serial config session so plain-text log lines can't corrupt the framed protocol. --- src/main.cpp | 18 + src/web/ApiTypes.h | 70 ++ src/web/SerialConfigServer.h | 284 +++++++ src/web/WebServer.h | 1517 ++++++++++++++++------------------ 4 files changed, 1088 insertions(+), 801 deletions(-) create mode 100644 src/web/ApiTypes.h create mode 100644 src/web/SerialConfigServer.h diff --git a/src/main.cpp b/src/main.cpp index ebe000e7..9149e0e7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,7 @@ #include "storage/SdCardManager.h" #include "timesync/TimeSync.h" #include "version.h" +#include "web/SerialConfigServer.h" #include "web/WebServer.h" static Ws2812bDriver _ws2812bPool[MAX_LIGHTS]; @@ -36,6 +37,7 @@ static MeshManager mesh; static ChannelManager channelMgr; static WifiElection wifiElection; static BatteryWebServer webServer; +static SerialConfigServer serialConfigServer; static MqttManager mqtt; static SceneSyncManager sceneSync; static ActionExecutor actionExecutor; @@ -87,6 +89,10 @@ static String _cfgSyncBuf; static uint16_t _cfgSyncExpected = 0; static void serialSink(LogLevel level, const char* msg) { + // A serial config session (see SerialConfigServer, #355) needs the wire + // free of plain-text log lines so its framed protocol can't be corrupted + // by one interleaving mid-frame. + if (serialConfigServer.sessionActive()) return; const char* p = level == LogLevel::ERROR ? "[E]" : level == LogLevel::WARN ? "[W]" : level == LogLevel::INFO ? "[I]" @@ -327,6 +333,11 @@ static void setupOta() { // ── Setup ───────────────────────────────────────────────────────────────────── void setup() { + // Above the default 256 bytes so a serial config session (see + // SerialConfigServer, #355) has room for one full base64 chunk line + // without the browser needing to wait on a byte-by-byte drain. Must be + // called before begin(). + Serial.setRxBufferSize(4096); Serial.begin(115200); Logger::addSink(serialSink); @@ -636,6 +647,8 @@ void setup() { &channelMgr, &_sdCard); + serialConfigServer.begin(&webServer); + auto notifySceneUpdated = [](const char* id) { for (uint8_t i = 0; i < MAX_LIGHTS; i++) if (_leds[i]) _runners[i].notifySceneUpdated(id); @@ -718,6 +731,11 @@ void setup() { // ── Loop ────────────────────────────────────────────────────────────────────── void loop() { + // Unthrottled and ahead of every early return below — a serial config + // session (#355) needs bytes drained promptly regardless of what else + // main.cpp is busy with (an in-flight OTA download included). + serialConfigServer.loop(); + uint32_t now = millis(); bool backgroundTick = now - _lastBackgroundTickMs >= BACKGROUND_TICK_INTERVAL_MS; if (backgroundTick) _lastBackgroundTickMs = now; diff --git a/src/web/ApiTypes.h b/src/web/ApiTypes.h new file mode 100644 index 00000000..5500e358 --- /dev/null +++ b/src/web/ApiTypes.h @@ -0,0 +1,70 @@ +#pragma once +#include +#include + +#include + +// The shape every API endpoint is written against, regardless of which +// transport carried the request in — HTTP (BatteryWebServer) or USB Serial +// (SerialConfigServer). A handler is registered once (see BatteryWebServer::_get/ +// _post/_postStream) and both transports call the exact same function, so a +// new endpoint only has to be written once to work over both. +enum class ApiMethod : uint8_t { GET = 0, POST = 1 }; + +struct ApiRequest { + ApiMethod method = ApiMethod::GET; + const char* path = ""; + + // Parsed JSON body. Null for GET requests and for streaming requests + // (see chunk fields below) — those carry their payload as raw bytes instead. + JsonVariantConst body; + + // Single query-string parameter, used only by GET /api/scenes/get?id=... + // — the one endpoint that takes a parameter outside the JSON body. + const char* query = nullptr; + + // Streaming-body endpoints only (scene save, storage upload): the chunk + // just delivered and its position within the overall body. A handler + // registered via _postStream is called once per chunk; chunkIndex==0 is + // the first call, chunkIndex+chunkLen==chunkTotal is the last. + const uint8_t* chunk = nullptr; + size_t chunkLen = 0; + size_t chunkIndex = 0; + size_t chunkTotal = 0; + + // Per-request scratch state a streaming handler allocates on the first + // chunk and frees on the last — analogous to AsyncWebServerRequest's + // _tempObject, but transport-neutral. The handler both reads and writes + // this field; the transport adapter is just responsible for persisting + // it between calls for the same underlying connection/request and for + // handing it back on the next chunk. + void* streamState = nullptr; +}; + +struct ApiResponse { + int status = 200; + JsonDocument body; + + // Set instead of `body` when the response is a raw file straight off + // LittleFS (GET /api/scenes/get) rather than a JSON object. A String + // (not const char*) because the path is normally built from a local + // temporary that must survive until the transport adapter reads it. + String rawFilePath; + + // Handler wants ESP.restart() after the response has been flushed — + // both transports must send the response first, then restart. + bool restart = false; + + // Streaming endpoints only: false on every chunk except the last, so the + // transport knows not to send a response yet. + bool streamDone = true; +}; + +using ApiHandler = std::function; + +struct ApiRoute { + ApiMethod method; + const char* path; + ApiHandler handler; + bool streaming = false; // true for scene save / storage upload +}; diff --git a/src/web/SerialConfigServer.h b/src/web/SerialConfigServer.h new file mode 100644 index 00000000..0b0ee130 --- /dev/null +++ b/src/web/SerialConfigServer.h @@ -0,0 +1,284 @@ +#pragma once +#include +#include +#include +#include + +#include "../config/Config.h" +#include "../logging/Logger.h" +#include "../version.h" +#include "ApiTypes.h" +#include "WebServer.h" + +// USB-serial config transport for #355: lets a browser (via the Web Serial +// API, from a page that isn't served by this device — the whole point is +// working without WiFi or the device's AP) configure this device over the +// same physical Serial connection normally used for log output. +// +// Every endpoint is reached through BatteryWebServer::dispatch(), the exact +// table the HTTP API is built from (see ApiTypes.h) — this class is purely a +// framing/transport layer and contains no endpoint logic of its own. A new +// endpoint registered on BatteryWebServer automatically works here too. +// +// Wire protocol: newline-delimited frames, each prefixed with "@BLCFG " so +// they can never be mistaken for a plain-text Logger line. While a session +// is active (from HELLO to BYE, or an idle timeout) the Logger sink to +// Serial is suspended (see sessionActive(), consulted by main.cpp's +// serialSink) so the two can't interleave on the wire. +// +// Browser -> device: +// @BLCFG HELLO +// @BLCFG REQ {"id":1,"method":"GET","path":"/api/config"} +// @BLCFG REQ {"id":2,"method":"POST","path":"/api/wifi/add","body":{...}} +// @BLCFG REQ {"id":3,"method":"POST","path":"/api/storage/upload", +// "query":"song.wav","index":0,"total":123,"chunk":""} +// @BLCFG BYE +// +// Device -> browser: +// @BLCFG READY {"proto":1,"device":"","version":""} +// @BLCFG RES {"id":1,"status":200,"body":{...}} +// @BLCFG RES {"id":3,"ack":true,"index":0} (mid-upload) +// @BLCFG RES {"id":4,"status":200,"index":0,"total":9000,"chunk":"","final":false} +// @BLCFG BYE +// +// A streaming request's response is only sent once, after its final chunk +// has been dispatched (mirrors the HTTP side, see BatteryWebServer:: +// _postStream). A response whose own body is large (GET /api/scenes/get, a +// big scene) is sent back the same chunked way instead of one inline frame. +// +// Single request in flight at a time — the browser must wait for a +// response (or upload ack) before sending the next request/chunk. This +// matches one browser tab talking to one device over one serial port; it is +// not a pipelined/concurrent protocol. +class SerialConfigServer { + public: + // Resumes plain-text logging if the browser disappears mid-session + // (tab closed, USB unplugged) without a clean BYE. + static constexpr uint32_t SESSION_IDLE_TIMEOUT_MS = 30000; + // Raw (pre-base64) size of one upload/download chunk. Matched by the + // frontend transport, which must never send a larger chunk than this. + static constexpr size_t CHUNK_BYTES = 2048; + + void begin(BatteryWebServer* webServer) { _web = webServer; } + + // Consulted by main.cpp's serialSink so plain log lines never interleave + // with protocol frames on the wire. + bool sessionActive() const { return _sessionActive; } + + void loop() { + while (Serial.available()) { + char c = (char)Serial.read(); + if (c == '\n') { + _handleLine(_line); + _line = ""; + } else if (c != '\r') { + if (_line.length() < MAX_LINE) + _line += c; + else + _line = ""; // runaway line (e.g. a stray non-protocol byte storm) — drop it + } + } + if (_sessionActive && millis() - _lastActivityMs > SESSION_IDLE_TIMEOUT_MS) { + Logger::w("[serialcfg] session idle timeout, resuming normal logging"); + _endSession(); + } + } + + private: + // One base64 chunk line (~4/3 expansion of CHUNK_BYTES) plus JSON framing. + static constexpr size_t MAX_LINE = 6144; + static constexpr const char* PREFIX = "@BLCFG "; + + BatteryWebServer* _web = nullptr; + String _line; + bool _sessionActive = false; + uint32_t _lastActivityMs = 0; + + // In-flight streaming upload (scene save / storage upload) state, owned + // by whichever handler allocated it (see ApiRequest::streamState). + String _streamPath; + void* _streamState = nullptr; + + void _endSession() { + // An interrupted upload leaks its handler-owned state (SceneSaveState/ + // StorageUploadState) rather than risk guessing its type to free it + // here — acceptable for the rare "connection dropped mid-upload" + // case; the target file is simply left partially written, same as an + // interrupted HTTP upload would be. + _streamState = nullptr; + _streamPath = ""; + _sessionActive = false; + } + + void _send(const String& frame) { + Serial.print(PREFIX); + Serial.print(frame); + Serial.print('\n'); + } + + void _handleLine(const String& line) { + if (!line.startsWith(PREFIX)) return; // stray log/noise line, ignore + _lastActivityMs = millis(); + String rest = line.substring(strlen(PREFIX)); + + if (rest == "HELLO") { + _sessionActive = true; + JsonDocument doc; + doc["proto"] = 1; + doc["device"] = Config::get().deviceName; + doc["version"] = FW_VERSION; + String body; + serializeJson(doc, body); + _send("READY " + body); + return; + } + if (rest == "BYE") { + _endSession(); + _send("BYE"); + return; + } + if (!_sessionActive) return; // ignore REQ frames outside a session + if (!rest.startsWith("REQ ")) return; + + JsonDocument doc; + if (deserializeJson(doc, rest.substring(4))) { + Logger::w("[serialcfg] bad request frame"); + return; + } + _handleRequest(doc); + } + + void _handleRequest(JsonDocument& doc) { + uint32_t id = doc["id"] | (uint32_t)0; + const char* method = doc["method"] | "GET"; + String path = doc["path"] | ""; + + ApiMethod m = strcmp(method, "POST") == 0 ? ApiMethod::POST : ApiMethod::GET; + bool streaming = _web->isStreamingRoute(m, path.c_str()); + + ApiRequest req; + req.method = m; + req.path = path.c_str(); + ApiResponse resp; + + String query = doc["query"] | ""; + if (query.length()) req.query = query.c_str(); + + uint8_t chunkBuf[CHUNK_BYTES]; + if (streaming) { + const char* b64 = doc["chunk"] | ""; + size_t outLen = 0; + if (b64[0] && + mbedtls_base64_decode(chunkBuf, sizeof(chunkBuf), &outLen, + (const unsigned char*)b64, strlen(b64)) != 0) { + Logger::w("[serialcfg] bad base64 chunk, dropping request id=%u", (unsigned)id); + return; + } + req.chunk = chunkBuf; + req.chunkLen = outLen; + req.chunkIndex = doc["index"] | (size_t)0; + req.chunkTotal = doc["total"] | (size_t)0; + req.streamState = (path == _streamPath) ? _streamState : nullptr; + resp.streamDone = false; + } + + bool found = _web->dispatch(req, resp); + if (!found) { + resp.status = 404; + resp.body.clear(); + resp.body["error"] = "not found"; + resp.streamDone = true; + } + + if (streaming) { + _streamState = req.streamState; + _streamPath = _streamState ? path : String(""); + if (!resp.streamDone) { + // Lightweight ack so the browser paces the next chunk instead + // of sending the whole upload without any flow control. + JsonDocument ack; + ack["id"] = id; + ack["ack"] = true; + ack["index"] = req.chunkIndex; + String body; + serializeJson(ack, body); + _send("RES " + body); + return; + } + } + + _sendResponse(id, resp); + if (resp.restart) { + delay(200); + ESP.restart(); + } + } + + void _sendResponse(uint32_t id, ApiResponse& resp) { + if (resp.rawFilePath.length()) { + _sendFileResponse(id, resp); + return; + } + String bodyStr; + serializeJson(resp.body, bodyStr); + // Comfortably true for every config/list endpoint; only raw scene + // files (handled above) and pathologically large scene JSON bodies + // need the chunked form. + if (bodyStr.length() < CHUNK_BYTES) { + JsonDocument doc; + doc["id"] = id; + doc["status"] = resp.status; + doc["body"] = resp.body; + String out; + serializeJson(doc, out); + _send("RES " + out); + } else { + _sendChunked(id, resp.status, bodyStr); + } + } + + void _sendFileResponse(uint32_t id, ApiResponse& resp) { + File f = LittleFS.open(resp.rawFilePath, "r"); + if (!f) { + JsonDocument doc; + doc["id"] = id; + doc["status"] = 500; + doc["body"]["error"] = "file open failed"; + String out; + serializeJson(doc, out); + _send("RES " + out); + return; + } + String content = f.readString(); + f.close(); + _sendChunked(id, 200, content); + } + + // Sends `content` as one or more base64-encoded "RES" frames of at most + // CHUNK_BYTES raw bytes each — the same chunk/index/total/final shape a + // streaming request uses on the way in. The frontend transport + // reassembles these into a single response body once final:true arrives. + void _sendChunked(uint32_t id, int status, const String& content) { + size_t total = content.length(); + size_t sent = 0; + uint8_t outBuf[CHUNK_BYTES * 2 + 16]; // base64 expands ~4/3; generous margin + do { + size_t n = min(CHUNK_BYTES, total - sent); + size_t outLen = 0; + mbedtls_base64_encode(outBuf, sizeof(outBuf), &outLen, + (const unsigned char*)content.c_str() + sent, n); + outBuf[outLen] = '\0'; + JsonDocument doc; + doc["id"] = id; + doc["status"] = status; + doc["index"] = sent; + doc["total"] = total; + doc["chunk"] = (const char*)outBuf; + sent += n; + doc["final"] = sent >= total; + String out; + serializeJson(doc, out); + _send("RES " + out); + } while (sent < total); + } +}; diff --git a/src/web/WebServer.h b/src/web/WebServer.h index 65bb2cda..a984b564 100644 --- a/src/web/WebServer.h +++ b/src/web/WebServer.h @@ -4,6 +4,7 @@ #include #include +#include #include "../battery/BatteryMonitor.h" #include "../config/Config.h" @@ -15,6 +16,7 @@ #include "../storage/SdCardManager.h" #include "../update/Updater.h" #include "../version.h" +#include "ApiTypes.h" // Called when this device's own group changes using GroupChangeCb = std::function; @@ -128,6 +130,20 @@ class BatteryWebServer { const char* error = nullptr; }; + // What a streaming route (scene save, storage upload) stores in + // AsyncWebServerRequest::_tempObject: the handler's own per-request + // scratch state (state) plus the response it computed once the final + // chunk was processed — the response can only actually be sent from the + // onRequest callback (below, after every onBody call has fired), not + // from inside onBody itself. + struct StreamCtx { + void* state = nullptr; + bool done = false; + int status = 200; + JsonDocument body; + bool restart = false; + }; + public: void begin(GroupChangeCb onGroupChange, GroupLightCb onGroupLight, GroupSyncCb onGroupSync, SetRemoteGroupCb onSetRemote, PeerRegistry* peers, @@ -171,142 +187,64 @@ class BatteryWebServer { }); _server.addHandler(_ws); + // Every endpoint below is registered exactly once via _get/_post/ + // _postSimple/_postStream, which both wires it up for HTTP (through + // ESPAsyncWebServer) and records it in _routes — the table + // SerialConfigServer's dispatch() walks for the USB-serial config + // transport (see #355). A new endpoint only needs to be added here + // once to work over both. + // // API routes must be registered before serveStatic, otherwise the // static handler matches /api/* paths and tries to open them from LittleFS. - _server.on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* r) { _getConfig(r); }); - _server.on( - "/api/config", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _postConfig(r, d, l); - }); + _get("/api/config", [this](ApiRequest& q, ApiResponse& s) { _getConfig(q, s); }); + _post("/api/config", [this](ApiRequest& q, ApiResponse& s) { _postConfig(q, s); }); - _server.on("/api/mqtt/clear", HTTP_POST, - [this](AsyncWebServerRequest* r) { _clearMqtt(r); }); + _postSimple("/api/mqtt/clear", [this](ApiRequest& q, ApiResponse& s) { _clearMqtt(q, s); }); - _server.on("/api/wifi", HTTP_GET, [this](AsyncWebServerRequest* r) { _getWifi(r); }); - _server.on( - "/api/wifi/add", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _addWifi(r, d, l); - }); - _server.on( - "/api/wifi/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteWifi(r, d, l); - }); - _server.on( - "/api/wifi/move", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _moveWifi(r, d, l); - }); - _server.on("/api/wifi/confirm-disable-ap", HTTP_POST, [this](AsyncWebServerRequest* r) { + _get("/api/wifi", [this](ApiRequest& q, ApiResponse& s) { _getWifi(q, s); }); + _post("/api/wifi/add", [this](ApiRequest& q, ApiResponse& s) { _addWifi(q, s); }); + _post("/api/wifi/delete", [this](ApiRequest& q, ApiResponse& s) { _deleteWifi(q, s); }); + _post("/api/wifi/move", [this](ApiRequest& q, ApiResponse& s) { _moveWifi(q, s); }); + _postSimple("/api/wifi/confirm-disable-ap", [this](ApiRequest&, ApiResponse& s) { if (_onConfirmApDisable) _onConfirmApDisable(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; }); - _server.on("/api/peers", HTTP_GET, [this](AsyncWebServerRequest* r) { _getPeers(r); }); - - _server.on( - "/api/groups/create", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _createGroup(r, d, l); - }); - _server.on( - "/api/groups/update", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _updateGroup(r, d, l); - }); - _server.on( - "/api/groups/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteGroup(r, d, l); - }); - - _server.on( - "/api/peers/setgroup", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _setRemoteGroup(r, d, l); - }); - - _server.on("/api/lights", HTTP_GET, [this](AsyncWebServerRequest* r) { _getLights(r); }); - _server.on( - "/api/lights/add", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _addLight(r, d, l); - }); - _server.on( - "/api/lights/update", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _updateLight(r, d, l); - }); - _server.on( - "/api/lights/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteLight(r, d, l); - }); - _server.on( - "/api/lights/test", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _testLight(r, d, l); - }); - _server.on( - "/api/lights/testcolor", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _testColorOrder(r, d, l); - }); - - _server.on("/api/sounds", HTTP_GET, [this](AsyncWebServerRequest* r) { _getSounds(r); }); - _server.on( - "/api/sounds/add", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _addSound(r, d, l); - }); - _server.on( - "/api/sounds/update", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _updateSound(r, d, l); - }); - _server.on( - "/api/sounds/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteSound(r, d, l); - }); - _server.on( - "/api/sounds/test", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _testSound(r, d, l); - }); - - _server.on("/api/storage", HTTP_GET, [this](AsyncWebServerRequest* r) { _getStorage(r); }); - _server.on( - "/api/storage/upload", HTTP_POST, - [this](AsyncWebServerRequest* r) { _finishStorageUpload(r); }, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t index, size_t total) { - _storageUploadChunk(r, d, l, index, total); - }); - _server.on( - "/api/storage/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteStorageFile(r, d, l); - }); - - _server.on("/api/buttons", HTTP_GET, [this](AsyncWebServerRequest* r) { _getButtons(r); }); - _server.on( - "/api/buttons/add", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _addButton(r, d, l); - }); - _server.on( - "/api/buttons/update", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _updateButton(r, d, l); - }); - _server.on( - "/api/buttons/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteButton(r, d, l); - }); + _get("/api/peers", [this](ApiRequest& q, ApiResponse& s) { _getPeers(q, s); }); + + _post("/api/groups/create", [this](ApiRequest& q, ApiResponse& s) { _createGroup(q, s); }); + _post("/api/groups/update", [this](ApiRequest& q, ApiResponse& s) { _updateGroup(q, s); }); + _post("/api/groups/delete", [this](ApiRequest& q, ApiResponse& s) { _deleteGroup(q, s); }); + + _post("/api/peers/setgroup", + [this](ApiRequest& q, ApiResponse& s) { _setRemoteGroup(q, s); }); + + _get("/api/lights", [this](ApiRequest& q, ApiResponse& s) { _getLights(q, s); }); + _post("/api/lights/add", [this](ApiRequest& q, ApiResponse& s) { _addLight(q, s); }); + _post("/api/lights/update", [this](ApiRequest& q, ApiResponse& s) { _updateLight(q, s); }); + _post("/api/lights/delete", [this](ApiRequest& q, ApiResponse& s) { _deleteLight(q, s); }); + _post("/api/lights/test", [this](ApiRequest& q, ApiResponse& s) { _testLight(q, s); }); + _post("/api/lights/testcolor", + [this](ApiRequest& q, ApiResponse& s) { _testColorOrder(q, s); }); + + _get("/api/sounds", [this](ApiRequest& q, ApiResponse& s) { _getSounds(q, s); }); + _post("/api/sounds/add", [this](ApiRequest& q, ApiResponse& s) { _addSound(q, s); }); + _post("/api/sounds/update", [this](ApiRequest& q, ApiResponse& s) { _updateSound(q, s); }); + _post("/api/sounds/delete", [this](ApiRequest& q, ApiResponse& s) { _deleteSound(q, s); }); + _post("/api/sounds/test", [this](ApiRequest& q, ApiResponse& s) { _testSound(q, s); }); + + _get("/api/storage", [this](ApiRequest& q, ApiResponse& s) { _getStorage(q, s); }); + _postStream("/api/storage/upload", + [this](ApiRequest& q, ApiResponse& s) { _storageUpload(q, s); }); + _post("/api/storage/delete", + [this](ApiRequest& q, ApiResponse& s) { _deleteStorageFile(q, s); }); + + _get("/api/buttons", [this](ApiRequest& q, ApiResponse& s) { _getButtons(q, s); }); + _post("/api/buttons/add", [this](ApiRequest& q, ApiResponse& s) { _addButton(q, s); }); + _post("/api/buttons/update", + [this](ApiRequest& q, ApiResponse& s) { _updateButton(q, s); }); + _post("/api/buttons/delete", + [this](ApiRequest& q, ApiResponse& s) { _deleteButton(q, s); }); // ── Scene API ───────────────────────────────────────────────────────── // Specific routes must be registered before /api/scenes because @@ -314,287 +252,131 @@ class BatteryWebServer { // intercept /api/scenes/get, /api/scenes/save, etc. SceneManager::init(); - _server.on("/api/scenes/get", HTTP_GET, [this](AsyncWebServerRequest* r) { _getScene(r); }); - - _server.on( - "/api/scenes/create", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _createScene(r, d, l); - }); - - _server.on( - "/api/scenes/delete", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _deleteScene(r, d, l); - }); - - // Scene save buffers the full body (may span multiple TCP packets) - // before writing to LittleFS. - _server.on( - "/api/scenes/save", HTTP_POST, - [this](AsyncWebServerRequest* r) { - auto* st = static_cast(r->_tempObject); - if (!st) { - Logger::e("[scene] save: request handler fired with no state"); - r->send(400, "application/json", "{\"error\":\"no body\"}"); - return; - } - - bool ok = !st->failed && st->written; - if (st->file) st->file.close(); - - if (ok) { - Logger::i("[scene] save ok: %s", st->id.c_str()); - if (_sceneSync) _sceneSync->onSceneChanged(st->id.c_str(), st->prevHash); - if (_onSceneSaved) _onSceneSaved(st->id.c_str()); - } else { - Logger::e("[scene] save failed: %s (failed=%d written=%d)", - st->error ? st->error : "?", st->failed, st->written); - } - - JsonDocument resp; - if (ok) - resp["ok"] = true; - else - resp["error"] = - st->failed ? (st->error ? st->error : "save failed") : "save failed"; - - delete st; - r->_tempObject = nullptr; - _sendJson(r, ok ? 200 : 500, resp); - }, - nullptr, - [this](AsyncWebServerRequest* r, uint8_t* data, size_t len, size_t index, - size_t total) { - auto* st = static_cast(r->_tempObject); - if (!st) { - st = new SceneSaveState(); - r->_tempObject = st; - Logger::d("[scene] save: body start, total=%u bytes", (unsigned)total); - } - - if (st->failed || !len) return; - - if (!st->file) { - st->buffer.concat((char*)data, len); - Logger::d("[scene] save: buffering chunk %u bytes (buf=%u): %.80s", - (unsigned)len, (unsigned)st->buffer.length(), st->buffer.c_str()); - String found; - if (!SceneManager::extractId(st->buffer.c_str(), st->buffer.length(), found)) { - if (st->buffer.length() > 16384) { - Logger::e("[scene] save: id not found after %u bytes", - (unsigned)st->buffer.length()); - st->failed = true; - st->error = "missing id"; - } else { - Logger::w( - "[scene] save: extractId returned false for buf=%u bytes (body " - "complete at total=%u)", - (unsigned)st->buffer.length(), (unsigned)total); - } - return; - } - - st->id = found; - st->prevHash = SceneManager::crc32(st->id.c_str()); - Logger::d("[scene] save: id=%s prevHash=%08x, opening file", st->id.c_str(), - st->prevHash); - SceneManager::init(); - st->file = LittleFS.open(SceneManager::path(st->id.c_str()), "w"); - if (!st->file) { - Logger::e("[scene] save: open failed for %s", st->id.c_str()); - st->failed = true; - st->error = "open failed"; - return; - } - - size_t written = - st->file.write((const uint8_t*)st->buffer.c_str(), st->buffer.length()); - Logger::d("[scene] save: wrote initial buffer %u/%u bytes", (unsigned)written, - (unsigned)st->buffer.length()); - if (written != st->buffer.length()) { - Logger::e("[scene] save: initial write incomplete (%u/%u)", - (unsigned)written, (unsigned)st->buffer.length()); - st->failed = true; - st->error = "write failed"; - st->file.close(); - st->file = File(); - return; - } - - st->buffer = ""; - st->written = true; - return; - } - - size_t written = st->file.write(data, len); - Logger::d("[scene] save: chunk at index=%u len=%u written=%u", (unsigned)index, - (unsigned)len, (unsigned)written); - if (written != len) { - Logger::e("[scene] save: chunk write incomplete (%u/%u) at index=%u", - (unsigned)written, (unsigned)len, (unsigned)index); - st->failed = true; - st->error = "write failed"; - st->file.close(); - st->file = File(); - return; - } - st->written = true; - }); - - _server.on("/api/scenes", HTTP_GET, [this](AsyncWebServerRequest* r) { _getScenes(r); }); + _get("/api/scenes/get", [this](ApiRequest& q, ApiResponse& s) { _getScene(q, s); }); + _post("/api/scenes/create", [this](ApiRequest& q, ApiResponse& s) { _createScene(q, s); }); + _post("/api/scenes/delete", [this](ApiRequest& q, ApiResponse& s) { _deleteScene(q, s); }); + _postStream("/api/scenes/save", + [this](ApiRequest& q, ApiResponse& s) { _saveScene(q, s); }); + _get("/api/scenes", [this](ApiRequest& q, ApiResponse& s) { _getScenes(q, s); }); // ── Scene sync API ──────────────────────────────────────────────────── - _server.on("/api/scenes/sync/conflicts", HTTP_GET, - [this](AsyncWebServerRequest* r) { _getSyncConflicts(r); }); - - _server.on( - "/api/scenes/sync/resolve", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _resolveSyncConflict(r, d, l); - }); - - _server.on( - "/api/peers/setscenesync", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _setRemoteSceneSync(r, d, l); - }); - - _server.on( - "/api/peers/pushconfig", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _pushConfig(r, d, l); - }); - - _server.on( - "/api/peers/triggerupdate", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _triggerPeerUpdate(r, d, l); - }); - - _server.on( - "/api/peers/checkupdate", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _checkPeerUpdate(r, d, l); - }); - - _server.on("/api/update/trigger", HTTP_POST, [this](AsyncWebServerRequest* r) { + _get("/api/scenes/sync/conflicts", + [this](ApiRequest& q, ApiResponse& s) { _getSyncConflicts(q, s); }); + _post("/api/scenes/sync/resolve", + [this](ApiRequest& q, ApiResponse& s) { _resolveSyncConflict(q, s); }); + _post("/api/peers/setscenesync", + [this](ApiRequest& q, ApiResponse& s) { _setRemoteSceneSync(q, s); }); + _post("/api/peers/pushconfig", + [this](ApiRequest& q, ApiResponse& s) { _pushConfig(q, s); }); + _post("/api/peers/triggerupdate", + [this](ApiRequest& q, ApiResponse& s) { _triggerPeerUpdate(q, s); }); + _post("/api/peers/checkupdate", + [this](ApiRequest& q, ApiResponse& s) { _checkPeerUpdate(q, s); }); + + _postSimple("/api/update/trigger", [this](ApiRequest&, ApiResponse& s) { if (_onRequestWifi) _onRequestWifi([]() { Updater::triggerAsync(); }); else Updater::triggerAsync(); - r->send(200, "application/json", "{\"ok\":true}"); + s.body["ok"] = true; }); - _server.on("/api/update/status", HTTP_GET, [](AsyncWebServerRequest* r) { - auto& s = Updater::status(); - JsonDocument doc; - doc["currentVersion"] = s.currentVersion; - doc["latestVersion"] = s.latestVersion; - doc["hasUpdate"] = s.hasUpdate; - doc["progress"] = s.progress; - doc["state"] = _fwStateToString(s.state); - if (s.error) doc["error"] = s.error; - String out; - serializeJson(doc, out); - r->send(200, "application/json", out); + _get("/api/update/status", [](ApiRequest&, ApiResponse& s) { + auto& u = Updater::status(); + s.body["currentVersion"] = u.currentVersion; + s.body["latestVersion"] = u.latestVersion; + s.body["hasUpdate"] = u.hasUpdate; + s.body["progress"] = u.progress; + s.body["state"] = _fwStateToString(u.state); + if (u.error) s.body["error"] = u.error; }); - _server.on("/api/update/check", HTTP_POST, [this](AsyncWebServerRequest* r) { + _postSimple("/api/update/check", [this](ApiRequest&, ApiResponse& s) { if (_onRequestWifi) _onRequestWifi([]() { Updater::checkAsync(); }); else Updater::checkAsync(); - r->send(200, "application/json", "{\"ok\":true}"); + s.body["ok"] = true; }); - _server.on("/api/update/apply", HTTP_POST, [this](AsyncWebServerRequest* r) { - auto& s = Updater::status(); - if (!s.hasUpdate) { - r->send(400, "application/json", "{\"error\":\"no update available\"}"); + _postSimple("/api/update/apply", [this](ApiRequest&, ApiResponse& s) { + auto& u = Updater::status(); + if (!u.hasUpdate) { + s.status = 400; + s.body["error"] = "no update available"; return; } if (_onRequestWifi) _onRequestWifi([]() { Updater::applyAsync(); }); else Updater::applyAsync(); - r->send(200, "application/json", "{\"ok\":true}"); + s.body["ok"] = true; }); // Experimental "install from PR" flow — every endpoint below requires // Config::prOtaEnabled, so it costs nothing unless explicitly opted in. - _server.on("/api/update/prs/refresh", HTTP_POST, [this](AsyncWebServerRequest* r) { + _postSimple("/api/update/prs/refresh", [this](ApiRequest&, ApiResponse& s) { if (!Config::get().prOtaEnabled) { - r->send(403, "application/json", "{\"error\":\"PR installs are disabled\"}"); + s.status = 403; + s.body["error"] = "PR installs are disabled"; return; } if (_onRequestWifi) _onRequestWifi([]() { Updater::listPrBuildsAsync(); }); else Updater::listPrBuildsAsync(); - r->send(200, "application/json", "{\"ok\":true}"); + s.body["ok"] = true; }); - _server.on("/api/update/prs", HTTP_GET, [](AsyncWebServerRequest* r) { - auto& s = Updater::prListStatus(); - JsonDocument doc; - doc["state"] = _prListStateToString(s.state); - JsonArray arr = doc["prs"].to(); - for (auto& b : s.builds) { + _get("/api/update/prs", [](ApiRequest&, ApiResponse& s) { + auto& st = Updater::prListStatus(); + s.body["state"] = _prListStateToString(st.state); + JsonArray arr = s.body["prs"].to(); + for (auto& b : st.builds) { JsonObject o = arr.add(); o["number"] = b.number; o["title"] = b.title; o["tag"] = b.tag; } - if (s.error) doc["error"] = s.error; - String out; - serializeJson(doc, out); - r->send(200, "application/json", out); + if (st.error) s.body["error"] = st.error; }); - _server.on( - "/api/update/apply-pr", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - if (!Config::get().prOtaEnabled) { - r->send(403, "application/json", "{\"error\":\"PR installs are disabled\"}"); - return; - } - JsonDocument doc; - if (!_parseJson(r, doc, d, l)) return; - String tag = doc["tag"] | ""; - if (_onRequestWifi) - _onRequestWifi([tag]() { Updater::applyPrAsync(tag); }); - else - Updater::applyPrAsync(tag); - r->send(200, "application/json", "{\"ok\":true}"); - }); + _post("/api/update/apply-pr", [this](ApiRequest& q, ApiResponse& s) { + if (!Config::get().prOtaEnabled) { + s.status = 403; + s.body["error"] = "PR installs are disabled"; + return; + } + String tag = q.body["tag"] | ""; + if (_onRequestWifi) + _onRequestWifi([tag]() { Updater::applyPrAsync(tag); }); + else + Updater::applyPrAsync(tag); + s.body["ok"] = true; + }); - _server.on("/api/reset", HTTP_POST, [](AsyncWebServerRequest* r) { + _postSimple("/api/reset", [](ApiRequest&, ApiResponse& s) { Config::reset(); - r->send(200, "application/json", "{\"ok\":true}"); - delay(500); - ESP.restart(); + s.body["ok"] = true; + s.restart = true; }); - _server.on("/api/mesh/search", HTTP_POST, [this](AsyncWebServerRequest* r) { + _postSimple("/api/mesh/search", [this](ApiRequest&, ApiResponse& s) { if (_onMeshSearch) _onMeshSearch(); - r->send(200, "application/json", "{\"ok\":true}"); + s.body["ok"] = true; }); // Body: {enabled}. Runtime-safe, mesh-wide toggle — no reboot, applies // to this device immediately and broadcasts to peers (see WifiElection). - _server.on( - "/api/mesh/wifipolicy", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, - [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { - _setWifiPolicy(r, d, l); - }); + _post("/api/mesh/wifipolicy", + [this](ApiRequest& q, ApiResponse& s) { _setWifiPolicy(q, s); }); // Manual "retry WiFi now" — applies locally and broadcasts to every // peer, so a mesh where every candidate gave up doesn't need the // mode toggled off and back on to try again. - _server.on("/api/mesh/wifiretry", HTTP_POST, [this](AsyncWebServerRequest* r) { + _postSimple("/api/mesh/wifiretry", [this](ApiRequest&, ApiResponse& s) { if (_onWifiRetry) _onWifiRetry(); - r->send(200, "application/json", "{\"ok\":true}"); + s.body["ok"] = true; }); // Browsers always request a favicon; return 204 so the request doesn't @@ -653,6 +435,31 @@ class BatteryWebServer { _onWifiAwaitingApConfirm = cb; } + // Looks up and invokes a registered route by (method, path) — the single + // entry point SerialConfigServer uses to reach every endpoint above, so a + // new endpoint only has to be registered once (via _get/_post/_postSimple/ + // _postStream) to work over both HTTP and USB-serial (see #355). Returns + // false if no route matches; the caller sends a 404 itself in that case. + bool dispatch(ApiRequest& req, ApiResponse& resp) { + for (auto& rt : _routes) { + if (rt.method == req.method && strcmp(rt.path, req.path) == 0) { + rt.handler(req, resp); + return true; + } + } + return false; + } + + // True for the two endpoints registered via _postStream (scene save, + // storage upload) — SerialConfigServer needs this to know whether an + // incoming request should be fed to the handler in chunks (one dispatch() + // call per chunk) or all at once. + bool isStreamingRoute(ApiMethod method, const char* path) const { + for (auto& rt : _routes) + if (rt.method == method && strcmp(rt.path, path) == 0) return rt.streaming; + return false; + } + private: AsyncWebServer _server{80}; AsyncWebSocket* _ws = nullptr; @@ -660,6 +467,7 @@ class BatteryWebServer { PeerRegistry* _peers = nullptr; ChannelManager* _channelMgr = nullptr; SdCardManager* _sdCard = nullptr; + std::vector _routes; GroupChangeCb _onGroupChange; GroupLightCb _onGroupLight; @@ -697,13 +505,151 @@ class BatteryWebServer { ConfirmApDisableCb _onConfirmApDisable; WifiAwaitingApConfirmCb _onWifiAwaitingApConfirm; - // ── helpers ────────────────────────────────────────────────────────────── + // ── transport-agnostic route registration ─────────────────────────────── + // Each of these both (a) registers the handler in _routes for + // SerialConfigServer::dispatch(), and (b) wires up the matching + // AsyncWebServer route, translating to/from ApiRequest/ApiResponse so the + // handler body itself never touches AsyncWebServerRequest. - static JsonDocument _makeOk() { - JsonDocument d; - d["ok"] = true; - return d; + void _sendApiResponse(AsyncWebServerRequest* r, ApiResponse& resp) { + if (resp.rawFilePath.length()) + r->send(LittleFS, resp.rawFilePath, "application/json"); + else + _sendJson(r, resp.status, resp.body); + if (resp.restart) { + delay(200); + ESP.restart(); + } + } + + // GET endpoints never carry a body; /api/scenes/get is the one route + // that takes a query parameter ("id") instead. + void _get(const char* path, ApiHandler h) { + _routes.push_back({ApiMethod::GET, path, h, false}); + _server.on(path, HTTP_GET, [this, path, h](AsyncWebServerRequest* r) { + ApiRequest req; + req.method = ApiMethod::GET; + req.path = path; + String qv; + if (r->hasParam("id")) { + qv = r->getParam("id")->value(); + req.query = qv.c_str(); + } + ApiResponse resp; + h(req, resp); + _sendApiResponse(r, resp); + }); } + + // POST endpoints whose whole JSON body arrives in a single onBody call — + // true for every non-streaming POST here, same assumption the original + // per-endpoint handlers already made via _parseJson. + void _post(const char* path, ApiHandler h) { + _routes.push_back({ApiMethod::POST, path, h, false}); + _server.on( + path, HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, + [this, path, h](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { + JsonDocument doc; + if (l && deserializeJson(doc, d, l)) { + auto e = _makeErr("bad json"); + _sendJson(r, 400, e); + return; + } + ApiRequest req; + req.method = ApiMethod::POST; + req.path = path; + req.body = doc.as(); + ApiResponse resp; + h(req, resp); + _sendApiResponse(r, resp); + }); + } + + // POST endpoints that take no body at all — registered as onRequest-only + // (matching how these already behaved before this route table existed), + // since relying on the onBody callback for a body-less request isn't + // guaranteed to fire. + void _postSimple(const char* path, ApiHandler h) { + _routes.push_back({ApiMethod::POST, path, h, false}); + _server.on(path, HTTP_POST, [this, path, h](AsyncWebServerRequest* r) { + ApiRequest req; + req.method = ApiMethod::POST; + req.path = path; + ApiResponse resp; + h(req, resp); + _sendApiResponse(r, resp); + }); + } + + // POST endpoints whose body streams in over multiple chunks (scene save, + // storage upload) — h is called once per chunk via onBody, computing a + // response once the final chunk (chunkIndex + chunkLen >= chunkTotal) has + // been processed. The response can only be sent once ESPAsyncWebServer + // calls the onRequest callback (guaranteed to fire once, after every + // onBody call) — calling request->send() from inside onBody itself isn't + // the library's supported pattern, so the computed response is stashed in + // StreamCtx (via _tempObject) for onRequest to actually send. + void _postStream(const char* path, ApiHandler h) { + _routes.push_back({ApiMethod::POST, path, h, true}); + _server.on( + path, HTTP_POST, + [this](AsyncWebServerRequest* r) { + auto* ctx = static_cast(r->_tempObject); + if (!ctx || !ctx->done) { + auto e = _makeErr("no body"); + _sendJson(r, 400, e); + delete ctx; + r->_tempObject = nullptr; + return; + } + _sendJson(r, ctx->status, ctx->body); + bool restart = ctx->restart; + delete ctx; + r->_tempObject = nullptr; + if (restart) { + delay(200); + ESP.restart(); + } + }, + nullptr, + [this, path, h](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t index, + size_t total) { + auto* ctx = static_cast(r->_tempObject); + if (!ctx) { + ctx = new StreamCtx(); + r->_tempObject = ctx; + } + + ApiRequest req; + req.method = ApiMethod::POST; + req.path = path; + req.chunk = d; + req.chunkLen = l; + req.chunkIndex = index; + req.chunkTotal = total; + req.streamState = ctx->state; + String qv; + // Only /api/storage/upload takes a query param ("name"); + // harmless no-op for /api/scenes/save. + if (r->hasParam("name")) { + qv = r->getParam("name")->value(); + req.query = qv.c_str(); + } + ApiResponse resp; + resp.streamDone = false; + h(req, resp); + ctx->state = req.streamState; + if (resp.streamDone) { + ctx->done = true; + ctx->status = resp.status; + ctx->body = resp.body; + ctx->restart = resp.restart; + } + }); + } + + // ── helpers ────────────────────────────────────────────────────────────── + static JsonDocument _makeErr(const char* e) { JsonDocument d; d["error"] = e; @@ -716,19 +662,6 @@ class BatteryWebServer { r->send(code, "application/json", s); } - // Parses the request body into doc, sending a 400 "bad json" response on failure. - // logCtx, if given, logs "[scene] : bad json" before responding. - static bool _parseJson(AsyncWebServerRequest* r, JsonDocument& doc, uint8_t* data, size_t len, - const char* logCtx = nullptr) { - if (deserializeJson(doc, data, len)) { - if (logCtx) Logger::e("[scene] %s: bad json", logCtx); - auto e = _makeErr("bad json"); - _sendJson(r, 400, e); - return false; - } - return true; - } - // Parses a "xx:xx:xx:xx:xx:xx" MAC string into a 6-byte array. static bool _parseMac(const char* macStr, uint8_t* mac) { return sscanf(macStr, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", &mac[0], &mac[1], &mac[2], &mac[3], @@ -749,9 +682,9 @@ class BatteryWebServer { } // ── GET /api/config ────────────────────────────────────────────────────── - void _getConfig(AsyncWebServerRequest* r) { + void _getConfig(ApiRequest&, ApiResponse& s) { auto& c = Config::get(); - JsonDocument doc; + JsonDocument& doc = s.body; doc["deviceName"] = c.deviceName; doc["otaPort"] = c.otaPort; doc["otaEnabled"] = c.otaEnabled; @@ -806,7 +739,6 @@ class BatteryWebServer { if (!c.groups[i].exists) continue; serializeGroup(arr.add(), c.groups[i]); } - _sendJson(r, 200, doc); } // ── POST /api/config ───────────────────────────────────────────────────── @@ -817,9 +749,8 @@ class BatteryWebServer { // is applied live via the callbacks below, and the response reports // whether a reboot is actually happening so the web UI only shows/waits // for one when it's really going to happen. - void _postConfig(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _postConfig(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; auto& c = Config::get(); bool rebootNeeded = false; @@ -871,10 +802,10 @@ class BatteryWebServer { uint8_t scl = doc["i2cSclPin"]; if (sda == PIN_UNUSED || scl == PIN_UNUSED) { if (Config::i2cBusInUse()) { - auto e = _makeErr( + s.status = 400; + s.body["error"] = "I2C bus still used by a configured sound output, button, or " - "the configured expander"); - _sendJson(r, 400, e); + "the configured expander"; return; } if (c.i2cSdaPin != PIN_UNUSED || c.i2cSclPin != PIN_UNUSED) rebootNeeded = true; @@ -882,14 +813,14 @@ class BatteryWebServer { c.i2cSclPin = PIN_UNUSED; } else { if (sda == scl) { - auto e = _makeErr("SDA and SCL must be different pins"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "SDA and SCL must be different pins"; return; } if (Config::isPinInUse(sda, -1, -1, -1, /*excludeI2cBus=*/true) || Config::isPinInUse(scl, -1, -1, -1, /*excludeI2cBus=*/true)) { - auto e = _makeErr("pin already in use"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "pin already in use"; return; } if (c.i2cSdaPin != sda || c.i2cSclPin != scl) rebootNeeded = true; @@ -911,13 +842,13 @@ class BatteryWebServer { : (uint8_t)doc["expanderAddress"]; if (newChip != IoExpanderChip::None && (c.i2cSdaPin == PIN_UNUSED || c.i2cSclPin == PIN_UNUSED)) { - auto e = _makeErr("configure the device I2C bus first"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "configure the device I2C bus first"; return; } if (newChip == IoExpanderChip::None && Config::expanderInUse()) { - auto e = _makeErr("expander still used by a configured sound output or button"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "expander still used by a configured sound output or button"; return; } if (c.expanderChip != newChip || c.expanderAddress != newAddr) rebootNeeded = true; @@ -966,13 +897,9 @@ class BatteryWebServer { if (apPasswordChanged && _onApPasswordChanged) _onApPasswordChanged(); if (timezoneChanged && _onTimezoneChanged) _onTimezoneChanged(c.timezone); - auto ok = _makeOk(); - ok["rebooting"] = rebootNeeded; - _sendJson(r, 200, ok); - if (rebootNeeded) { - delay(200); - ESP.restart(); - } + s.body["ok"] = true; + s.body["rebooting"] = rebootNeeded; + s.restart = rebootNeeded; } // ── POST /api/mqtt/clear ───────────────────────────────────────────────── @@ -982,7 +909,7 @@ class BatteryWebServer { // stale on the broker forever, since nothing else ever cleans it up. // Runtime-safe, like the dedicated /api/mesh/wifipolicy endpoint — no // reboot needed, MqttManager just disables itself for the rest of this session. - void _clearMqtt(AsyncWebServerRequest* r) { + void _clearMqtt(ApiRequest&, ApiResponse& s) { if (_onClearMqtt) _onClearMqtt(); auto& c = Config::get(); c.mqttHost[0] = '\0'; @@ -990,16 +917,11 @@ class BatteryWebServer { c.mqttUser[0] = '\0'; c.mqttPassword[0] = '\0'; Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── GET /api/peers ─────────────────────────────────────────────────────── - void _getPeers(AsyncWebServerRequest* r) { - JsonDocument doc; - _buildPeersJson(doc); - _sendJson(r, 200, doc); - } + void _getPeers(ApiRequest&, ApiResponse& s) { _buildPeersJson(s.body); } void _buildPeersJson(JsonDocument& doc) { auto& c = Config::get(); @@ -1074,37 +996,32 @@ class BatteryWebServer { } // ── POST /api/groups/create ────────────────────────────────────────────── - void _createGroup(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - const char* name = doc["name"] | "New Group"; + void _createGroup(ApiRequest& q, ApiResponse& s) { + const char* name = q.body["name"] | "New Group"; uint8_t id = Config::createGroup(name); if (id == 0xFF) { - auto e = _makeErr("group limit reached"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "group limit reached"; return; } const GroupConfig& g = Config::get().groups[id]; Config::save(); if (_onGroupSync) _onGroupSync(g); - JsonDocument resp; - resp["ok"] = true; - resp["id"] = id; - _sendJson(r, 200, resp); + s.body["ok"] = true; + s.body["id"] = id; _pushGroups(); } // ── POST /api/groups/update ────────────────────────────────────────────── // Body: {id, name?, pattern?, r?, g?, b?, brightness?, speed?} - void _updateGroup(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _updateGroup(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; uint8_t id = doc["id"] | (uint8_t)0; GroupConfig* g = Config::group(id); if (!g) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } @@ -1119,8 +1036,7 @@ class BatteryWebServer { Config::bumpGroupRevision(*g); Config::save(); if (_onGroupSync) _onGroupSync(*g); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; _pushGroups(); return; } @@ -1144,25 +1060,22 @@ class BatteryWebServer { Config::save(); if (nameChanged && _onGroupSync) _onGroupSync(*g); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; if (nameChanged) _pushGroups(); } // ── POST /api/groups/delete ────────────────────────────────────────────── - void _deleteGroup(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t id = doc["id"] | (uint8_t)0; + void _deleteGroup(ApiRequest& q, ApiResponse& s) { + uint8_t id = q.body["id"] | (uint8_t)0; if (id == 0) { - auto e = _makeErr("cannot delete Default"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "cannot delete Default"; return; } GroupConfig* g = Config::group(id); if (!g) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } @@ -1184,23 +1097,21 @@ class BatteryWebServer { Config::save(); if (_onGroupSync) _onGroupSync(tombstone); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; _pushGroups(); } // ── POST /api/peers/setgroup ───────────────────────────────────────────── // Body: {mac, lightIndex, groupId} - void _setRemoteGroup(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _setRemoteGroup(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; uint8_t lightIndex = doc["lightIndex"] | (uint8_t)0; uint8_t groupId = doc["groupId"] | (uint8_t)0; const char* macStr = doc["mac"] | ""; if (lightIndex >= MAX_LIGHTS) { - auto e = _makeErr("invalid lightIndex"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "invalid lightIndex"; return; } @@ -1210,22 +1121,20 @@ class BatteryWebServer { Config::save(); if (_onGroupChange) _onGroupChange(); } - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; _pushPeers(); return; } uint8_t mac[6]; if (!_parseMac(macStr, mac)) { - auto e = _makeErr("bad mac"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "bad mac"; return; } if (_onSetRemote) _onSetRemote(mac, lightIndex, groupId); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── WebSocket push ─────────────────────────────────────────────────────── @@ -1270,101 +1179,192 @@ class BatteryWebServer { // ── Scene handlers ─────────────────────────────────────────────────────── - void _getScenes(AsyncWebServerRequest* r) { - JsonDocument resp; - SceneManager::buildList(resp); - JsonArray arr = resp["scenes"].as(); + void _getScenes(ApiRequest&, ApiResponse& s) { + SceneManager::buildList(s.body); + JsonArray arr = s.body["scenes"].as(); Logger::d("[scene] list: %u scene(s)", arr ? (unsigned)arr.size() : 0); - _sendJson(r, 200, resp); } - void _getScene(AsyncWebServerRequest* r) { - if (!r->hasParam("id")) { + void _getScene(ApiRequest& q, ApiResponse& s) { + if (!q.query) { Logger::w("[scene] get: missing id param"); - auto e = _makeErr("missing id"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "missing id"; return; } - String id = r->getParam("id")->value(); - String path = SceneManager::path(id.c_str()); - Logger::d("[scene] get: id=%s path=%s exists=%d", id.c_str(), path.c_str(), + String path = SceneManager::path(q.query); + Logger::d("[scene] get: id=%s path=%s exists=%d", q.query, path.c_str(), LittleFS.exists(path)); if (!LittleFS.exists(path)) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } - r->send(LittleFS, path, "application/json"); + s.rawFilePath = path; } - void _createScene(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len, "create")) return; - const char* name = doc["name"] | "Unnamed"; - uint16_t w = doc["w"] | 20; - uint16_t h = doc["h"] | 10; + void _createScene(ApiRequest& q, ApiResponse& s) { + const char* name = q.body["name"] | "Unnamed"; + uint16_t w = q.body["w"] | 20; + uint16_t h = q.body["h"] | 10; Logger::i("[scene] create: name=%s w=%u h=%u", name, w, h); String id = SceneManager::create(name, w, h); if (id.isEmpty()) { Logger::e("[scene] create: failed"); - auto e = _makeErr("create failed"); - _sendJson(r, 500, e); + s.status = 500; + s.body["error"] = "create failed"; return; } Logger::i("[scene] create: ok id=%s", id.c_str()); if (_onSceneListChanged) _onSceneListChanged(); - JsonDocument resp; - resp["ok"] = true; - resp["id"] = id; - _sendJson(r, 200, resp); + s.body["ok"] = true; + s.body["id"] = id; } - void _deleteScene(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len, "delete")) return; - const char* id = doc["id"] | ""; + void _deleteScene(ApiRequest& q, ApiResponse& s) { + const char* id = q.body["id"] | ""; if (!id[0]) { Logger::w("[scene] delete: missing id"); - auto e = _makeErr("missing id"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "missing id"; return; } Logger::i("[scene] delete: id=%s", id); bool ok = _sceneSync ? _sceneSync->deleteScene(id) : SceneManager::remove(id); Logger::i("[scene] delete: %s", ok ? "ok" : "not found"); if (ok && _onSceneListChanged) _onSceneListChanged(); - JsonDocument resp; + s.status = ok ? 200 : 404; if (ok) - resp["ok"] = true; + s.body["ok"] = true; else - resp["error"] = "not found"; - _sendJson(r, ok ? 200 : 404, resp); + s.body["error"] = "not found"; + } + + // ── POST /api/scenes/save ──────────────────────────────────────────────── + // Streaming: the body's id field isn't known until enough of it has + // arrived, so bytes are buffered until SceneManager::extractId finds it, + // then written straight to LittleFS from there on (mirrors the original + // AsyncWebServerRequest-based implementation, just keyed off + // req.streamState instead of r->_tempObject). + void _saveScene(ApiRequest& q, ApiResponse& s) { + auto* st = static_cast(q.streamState); + if (!st) { + st = new SceneSaveState(); + q.streamState = st; + Logger::d("[scene] save: body start, total=%u bytes", (unsigned)q.chunkTotal); + } + + s.streamDone = false; + + if (!st->failed && q.chunkLen) { + if (!st->file) { + st->buffer.concat((const char*)q.chunk, q.chunkLen); + Logger::d("[scene] save: buffering chunk %u bytes (buf=%u): %.80s", + (unsigned)q.chunkLen, (unsigned)st->buffer.length(), st->buffer.c_str()); + String found; + if (!SceneManager::extractId(st->buffer.c_str(), st->buffer.length(), found)) { + if (st->buffer.length() > 16384) { + Logger::e("[scene] save: id not found after %u bytes", + (unsigned)st->buffer.length()); + st->failed = true; + st->error = "missing id"; + } else { + Logger::w( + "[scene] save: extractId returned false for buf=%u bytes (body " + "complete at total=%u)", + (unsigned)st->buffer.length(), (unsigned)q.chunkTotal); + } + } else { + st->id = found; + st->prevHash = SceneManager::crc32(st->id.c_str()); + Logger::d("[scene] save: id=%s prevHash=%08x, opening file", st->id.c_str(), + st->prevHash); + SceneManager::init(); + st->file = LittleFS.open(SceneManager::path(st->id.c_str()), "w"); + if (!st->file) { + Logger::e("[scene] save: open failed for %s", st->id.c_str()); + st->failed = true; + st->error = "open failed"; + } else { + size_t written = + st->file.write((const uint8_t*)st->buffer.c_str(), st->buffer.length()); + Logger::d("[scene] save: wrote initial buffer %u/%u bytes", + (unsigned)written, (unsigned)st->buffer.length()); + if (written != st->buffer.length()) { + Logger::e("[scene] save: initial write incomplete (%u/%u)", + (unsigned)written, (unsigned)st->buffer.length()); + st->failed = true; + st->error = "write failed"; + st->file.close(); + st->file = File(); + } else { + st->buffer = ""; + st->written = true; + } + } + } + } else { + size_t written = st->file.write(q.chunk, q.chunkLen); + Logger::d("[scene] save: chunk at index=%u len=%u written=%u", + (unsigned)q.chunkIndex, (unsigned)q.chunkLen, (unsigned)written); + if (written != q.chunkLen) { + Logger::e("[scene] save: chunk write incomplete (%u/%u) at index=%u", + (unsigned)written, (unsigned)q.chunkLen, (unsigned)q.chunkIndex); + st->failed = true; + st->error = "write failed"; + st->file.close(); + st->file = File(); + } else { + st->written = true; + } + } + } + + if (q.chunkIndex + q.chunkLen < q.chunkTotal) return; // more chunks coming + + bool ok = !st->failed && st->written; + if (st->file) st->file.close(); + + if (ok) { + Logger::i("[scene] save ok: %s", st->id.c_str()); + if (_sceneSync) _sceneSync->onSceneChanged(st->id.c_str(), st->prevHash); + if (_onSceneSaved) _onSceneSaved(st->id.c_str()); + } else { + Logger::e("[scene] save failed: %s (failed=%d written=%d)", + st->error ? st->error : "?", st->failed, st->written); + } + + s.status = ok ? 200 : 500; + if (ok) + s.body["ok"] = true; + else + s.body["error"] = st->failed ? (st->error ? st->error : "save failed") : "save failed"; + s.streamDone = true; + delete st; + q.streamState = nullptr; } // ── Scene sync handlers ────────────────────────────────────────────────── - void _getSyncConflicts(AsyncWebServerRequest* r) { - JsonDocument doc; + void _getSyncConflicts(ApiRequest&, ApiResponse& s) { if (_sceneSync) { - _sceneSync->buildConflictsJson(doc); - _sceneSync->buildPeerScenesJson(doc); + _sceneSync->buildConflictsJson(s.body); + _sceneSync->buildPeerScenesJson(s.body); } else { - doc["conflicts"].to(); - doc["peerScenes"].to(); + s.body["conflicts"].to(); + s.body["peerScenes"].to(); } - _sendJson(r, 200, doc); } // Body: {id, sourceMac} — sourceMac is the device whose copy wins. // Use sourceMac == own MAC or omit to use local copy. - void _resolveSyncConflict(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _resolveSyncConflict(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; const char* id = doc["id"] | ""; const char* macStr = doc["sourceMac"] | ""; if (!id[0]) { - auto e = _makeErr("missing id"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "missing id"; return; } @@ -1376,50 +1376,45 @@ class BatteryWebServer { } else { uint8_t mac[6]; if (!_parseMac(macStr, mac)) { - auto e = _makeErr("bad mac"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "bad mac"; return; } if (_onResolveConflict) _onResolveConflict(id, mac); } - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── GET /api/wifi ───────────────────────────────────────────────────────── - void _getWifi(AsyncWebServerRequest* r) { - JsonDocument doc; + void _getWifi(ApiRequest&, ApiResponse& s) { if (WiFi.status() == WL_CONNECTED) { // Use Config::wifiLast() rather than WiFi.SSID() to avoid calling // esp_wifi_sta_get_ap_info() from an async handler — that call can // block waiting for the WiFi driver lock during reconnect events. uint8_t last = Config::wifiLast(); - doc["connected"] = (last < Config::wifiCount()) - ? (const char*)Config::wifiNetworks()[last].ssid - : (const char*)nullptr; + s.body["connected"] = (last < Config::wifiCount()) + ? (const char*)Config::wifiNetworks()[last].ssid + : (const char*)nullptr; } else { - doc["connected"] = nullptr; + s.body["connected"] = nullptr; } - JsonArray arr = doc["networks"].to(); + JsonArray arr = s.body["networks"].to(); for (uint8_t i = 0; i < Config::wifiCount(); i++) arr.add(Config::wifiNetworks()[i].ssid); - _sendJson(r, 200, doc); } // ── POST /api/wifi/add ──────────────────────────────────────────────────── // Body: {ssid, password} - void _addWifi(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - const char* ssid = doc["ssid"] | ""; - const char* pass = doc["password"] | ""; + void _addWifi(ApiRequest& q, ApiResponse& s) { + const char* ssid = q.body["ssid"] | ""; + const char* pass = q.body["password"] | ""; if (strlen(ssid) == 0) { - auto e = _makeErr("ssid required"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "ssid required"; return; } if (!Config::addWifiNetwork(ssid, pass)) { - auto e = _makeErr("network list full"); - _sendJson(r, 409, e); + s.status = 409; + s.body["error"] = "network list full"; return; } // Nothing else actually connects to a newly-saved network until the @@ -1428,37 +1423,31 @@ class BatteryWebServer { if (WiFi.status() != WL_CONNECTED && _onWifiConnectForConfirm) { _onWifiConnectForConfirm([](bool) {}); } - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── POST /api/wifi/delete ───────────────────────────────────────────────── // Body: {ssid} - void _deleteWifi(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - const char* ssid = doc["ssid"] | ""; + void _deleteWifi(ApiRequest& q, ApiResponse& s) { + const char* ssid = q.body["ssid"] | ""; if (strlen(ssid) == 0) { - auto e = _makeErr("ssid required"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "ssid required"; return; } Config::deleteWifiNetwork(ssid); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── POST /api/wifi/move ─────────────────────────────────────────────────── // Body: {ssid, direction: "up"|"down"} — swaps ssid with its immediate // neighbor; connect order is list order, so this changes priority. - void _moveWifi(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - const char* ssid = doc["ssid"] | ""; - const char* dir = doc["direction"] | ""; + void _moveWifi(ApiRequest& q, ApiResponse& s) { + const char* ssid = q.body["ssid"] | ""; + const char* dir = q.body["direction"] | ""; if (strlen(ssid) == 0) { - auto e = _makeErr("ssid required"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "ssid required"; return; } int8_t direction; @@ -1467,17 +1456,16 @@ class BatteryWebServer { else if (strcmp(dir, "down") == 0) direction = 1; else { - auto e = _makeErr("direction must be up or down"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "direction must be up or down"; return; } if (!Config::moveWifiNetwork(ssid, direction)) { - auto e = _makeErr("cannot move"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "cannot move"; return; } - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // Body: {mac?, deviceName?, ledType?, addWifiNetworks?, apPassword?, @@ -1485,15 +1473,14 @@ class BatteryWebServer { // otaEnabled?} // mac omitted or empty = push to all peers. Only present fields are pushed; // deviceName and ledType require a specific target mac. - void _pushConfig(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _pushConfig(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; const char* macStr = doc["mac"] | ""; uint8_t targetMac[6] = {0, 0, 0, 0, 0, 0}; bool hasTarget = macStr[0] != '\0'; if (hasTarget && !_parseMac(macStr, targetMac)) { - auto e = _makeErr("bad mac"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "bad mac"; return; } @@ -1525,16 +1512,16 @@ class BatteryWebServer { if (newName[0] != '\0') { // Uniqueness check against peer registry and own name if (strcmp(Config::get().deviceName, newName) == 0) { - auto e = _makeErr("name already in use"); - _sendJson(r, 409, e); + s.status = 409; + s.body["error"] = "name already in use"; return; } if (_peers) { for (auto& p : *_peers) { if (!p.active || memcmp(p.mac, targetMac, 6) == 0) continue; if (strcmp(p.name, newName) == 0) { - auto e = _makeErr("name already in use"); - _sendJson(r, 409, e); + s.status = 409; + s.body["error"] = "name already in use"; return; } } @@ -1547,9 +1534,9 @@ class BatteryWebServer { // useLocal: fields the UI wants filled from this device's own config auto& c = Config::get(); - JsonArray useLocal = doc["useLocal"].as(); + JsonArrayConst useLocal = doc["useLocal"].as(); auto isUseLocal = [&](const char* key) -> bool { - for (JsonVariant v : useLocal) + for (JsonVariantConst v : useLocal) if (strcmp(v.as(), key) == 0) return true; return false; }; @@ -1594,22 +1581,20 @@ class BatteryWebServer { addBool("otaEnabled"); if (!any) { - auto e = _makeErr("no fields to push"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "no fields to push"; return; } String json; serializeJson(payload, json); if (_onPushConfig) _onPushConfig(targetMac, json.c_str(), json.length()); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // Body: {mac, enabled} - void _setRemoteSceneSync(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _setRemoteSceneSync(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; const char* macStr = doc["mac"] | ""; bool enabled = doc["enabled"] | true; @@ -1619,58 +1604,51 @@ class BatteryWebServer { Config::save(); if (enabled && !prev && _sceneSync) _sceneSync->onSyncEnabled(); if (enabled != prev && _onSceneSyncChanged) _onSceneSyncChanged(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; return; } uint8_t mac[6]; if (!_parseMac(macStr, mac)) { - auto e = _makeErr("bad mac"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "bad mac"; return; } if (_onSetRemoteSync) _onSetRemoteSync(mac, enabled); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // Body: {enabled} - void _setWifiPolicy(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - bool enabled = doc["enabled"] | false; + void _setWifiPolicy(ApiRequest& q, ApiResponse& s) { + bool enabled = q.body["enabled"] | false; if (_onMeshPolicyChange) _onMeshPolicyChange(enabled); else { Config::get().wifiSingleClientMode = enabled; Config::save(); } - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; _pushPeers(); // wifi icon colors in the device list depend on this flag } // Shared body for /api/peers/triggerupdate and /api/peers/checkupdate: // parse the target MAC, reject if that peer is known but offline, then // invoke the given callback. - void _peerUpdateRequest(AsyncWebServerRequest* r, uint8_t* data, size_t len, - const char* logVerb, const std::function& cb) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - const char* macStr = doc["mac"] | ""; + void _peerUpdateRequest(ApiRequest& q, ApiResponse& s, const char* logVerb, + const std::function& cb) { + const char* macStr = q.body["mac"] | ""; uint8_t mac[6]; if (!_parseMac(macStr, mac)) { - auto e = _makeErr("bad mac"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "bad mac"; return; } if (_peers) { for (auto& p : *_peers) { if (p.active && memcmp(p.mac, mac, 6) == 0) { if (!p.online()) { - auto e = _makeErr("peer offline"); - _sendJson(r, 409, e); + s.status = 409; + s.body["error"] = "peer offline"; return; } // In single-client mode, an online candidate peer that's @@ -1682,8 +1660,8 @@ class BatteryWebServer { bool canConnectOnDemand = Config::get().wifiSingleClientMode && p.hasWifiNetworks; if (!p.wifiConnected && !canConnectOnDemand) { - auto e = _makeErr("peer not connected to WiFi"); - _sendJson(r, 409, e); + s.status = 409; + s.body["error"] = "peer not connected to WiFi"; return; } break; @@ -1692,25 +1670,23 @@ class BatteryWebServer { } Logger::i("[web] %s for %s", logVerb, macStr); if (cb) cb(mac); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── POST /api/peers/triggerupdate ───────────────────────────────────────── - void _triggerPeerUpdate(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - _peerUpdateRequest(r, data, len, "trigger-update", _onTriggerPeerUpdate); + void _triggerPeerUpdate(ApiRequest& q, ApiResponse& s) { + _peerUpdateRequest(q, s, "trigger-update", _onTriggerPeerUpdate); } // ── POST /api/peers/checkupdate ─────────────────────────────────────────── - void _checkPeerUpdate(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - _peerUpdateRequest(r, data, len, "check-update", _onCheckPeerUpdate); + void _checkPeerUpdate(ApiRequest& q, ApiResponse& s) { + _peerUpdateRequest(q, s, "check-update", _onCheckPeerUpdate); } // ── GET /api/lights ─────────────────────────────────────────────────────── - void _getLights(AsyncWebServerRequest* r) { - JsonDocument doc; - JsonArray arr = doc["lights"].to(); - doc["maxLights"] = MAX_LIGHTS; + void _getLights(ApiRequest&, ApiResponse& s) { + JsonArray arr = s.body["lights"].to(); + s.body["maxLights"] = MAX_LIGHTS; for (uint8_t i = 0; i < MAX_LIGHTS; i++) { auto& l = Config::get().lights[i]; if (!l.exists) continue; @@ -1732,7 +1708,6 @@ class BatteryWebServer { o["brightnessOverrideEnabled"] = l.brightnessOverrideEnabled; o["brightnessOverride"] = l.brightnessOverride; } - _sendJson(r, 200, doc); } // Returns an error string if the light's active pins collide with each @@ -1754,9 +1729,8 @@ class BatteryWebServer { // ── POST /api/lights/add ────────────────────────────────────────────────── // Body: {ledType, colorOrder, dataPin, clockPin, width, height, matrixStart, matrixDir, // matrixSerpentine, wrapWidth, wrapHeight, groupId} - void _addLight(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _addLight(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; // Find first free slot uint8_t idx = 0xFF; for (uint8_t i = 0; i < MAX_LIGHTS; i++) { @@ -1766,8 +1740,8 @@ class BatteryWebServer { } } if (idx == 0xFF) { - auto e = _makeErr("light limit reached"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "light limit reached"; return; } LightHardwareConfig l; @@ -1788,32 +1762,28 @@ class BatteryWebServer { if (l.width == 0) l.width = 1; if (l.height == 0) l.height = 1; if (const char* conflict = _lightPinConflict(l, /*excludeLightIndex=*/idx)) { - auto e = _makeErr(conflict); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = conflict; return; } l.exists = true; Config::get().lights[idx] = l; Config::save(); - JsonDocument resp; - resp["ok"] = true; - resp["index"] = idx; - _sendJson(r, 200, resp); + s.body["ok"] = true; + s.body["index"] = idx; // Hardware config changes require restart to take effect - delay(200); - ESP.restart(); + s.restart = true; } // ── POST /api/lights/update ─────────────────────────────────────────────── // Body: {index, ledType?, colorOrder?, dataPin?, clockPin?, width?, height?, matrixStart?, // matrixDir?, matrixSerpentine?, wrapWidth?, wrapHeight?, groupId?} - void _updateLight(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _updateLight(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; uint8_t idx = doc["index"] | (uint8_t)0xFF; if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } auto& l = Config::get().lights[idx]; @@ -1842,8 +1812,8 @@ class BatteryWebServer { } if (pinsChanged) { if (const char* conflict = _lightPinConflict(pinCandidate, (int8_t)idx)) { - auto e = _makeErr(conflict); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = conflict; return; } l.ledType = pinCandidate.ledType; @@ -1904,11 +1874,9 @@ class BatteryWebServer { brightnessOverrideChanged = true; } Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; if (hwChanged) { - delay(200); - ESP.restart(); + s.restart = true; return; } if (orientationChanged && _onOrientationChange) _onOrientationChange(idx); @@ -1918,75 +1886,63 @@ class BatteryWebServer { // ── POST /api/lights/test ───────────────────────────────────────────────── // Body: {index} - void _testLight(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t idx = doc["index"] | (uint8_t)0xFF; + void _testLight(ApiRequest& q, ApiResponse& s) { + uint8_t idx = q.body["index"] | (uint8_t)0xFF; if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } if (Config::get().lights[idx].height < 2) { - auto e = _makeErr("not a matrix"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "not a matrix"; return; } if (_onTestLight) _onTestLight(idx); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── POST /api/lights/testcolor ──────────────────────────────────────────── // Body: {index} — cycles the light through solid red/green/blue so the // user can check colorOrder against the strip's actual wiring. Unlike // /api/lights/test (orientation), this isn't restricted to matrix lights. - void _testColorOrder(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t idx = doc["index"] | (uint8_t)0xFF; + void _testColorOrder(ApiRequest& q, ApiResponse& s) { + uint8_t idx = q.body["index"] | (uint8_t)0xFF; if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } if (_onTestColorOrder) _onTestColorOrder(idx); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── POST /api/lights/delete ─────────────────────────────────────────────── // Body: {index} - void _deleteLight(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t idx = doc["index"] | (uint8_t)0xFF; + void _deleteLight(ApiRequest& q, ApiResponse& s) { + uint8_t idx = q.body["index"] | (uint8_t)0xFF; if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } Config::get().lights[idx].exists = false; Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); - delay(200); - ESP.restart(); + s.body["ok"] = true; + s.restart = true; } // ── GET /api/sounds ──────────────────────────────────────────────────────── - void _getSounds(AsyncWebServerRequest* r) { - JsonDocument doc; - JsonArray arr = doc["sounds"].to(); - doc["maxSounds"] = MAX_SOUNDS; + void _getSounds(ApiRequest&, ApiResponse& s) { + JsonArray arr = s.body["sounds"].to(); + s.body["maxSounds"] = MAX_SOUNDS; for (uint8_t i = 0; i < MAX_SOUNDS; i++) { - auto& s = Config::get().sounds[i]; - if (!s.exists) continue; + auto& snd = Config::get().sounds[i]; + if (!snd.exists) continue; JsonObject o = arr.add(); o["index"] = i; - serializeSound(o, s); + serializeSound(o, snd); } - _sendJson(r, 200, doc); } // True if the device-wide I2C bus (DeviceConfig::i2cSdaPin/i2cSclPin) has @@ -2029,12 +1985,11 @@ class BatteryWebServer { // ── POST /api/sounds/add ────────────────────────────────────────────────── // Body: {name?, chip?, i2cAddress?, i2sMclkPin?, i2sBclkPin, i2sWsPin, i2sDoutPin, // paEnablePin?, paEnableActiveHigh?, paViaExpander?} - void _addSound(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _addSound(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; if (!_i2cBusConfigured()) { - auto e = _makeErr("configure the device I2C bus in Hardware settings first"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "configure the device I2C bus in Hardware settings first"; return; } uint8_t idx = 0xFF; @@ -2045,44 +2000,41 @@ class BatteryWebServer { } } if (idx == 0xFF) { - auto e = _makeErr("sound limit reached"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "sound limit reached"; return; } - SoundHardwareConfig s; - deserializeSound(doc, s); - if (s.i2sBclkPin == PIN_UNUSED || s.i2sWsPin == PIN_UNUSED || s.i2sDoutPin == PIN_UNUSED) { - auto e = _makeErr("missing required pin"); - _sendJson(r, 400, e); + SoundHardwareConfig snd; + deserializeSound(doc, snd); + if (snd.i2sBclkPin == PIN_UNUSED || snd.i2sWsPin == PIN_UNUSED || + snd.i2sDoutPin == PIN_UNUSED) { + s.status = 400; + s.body["error"] = "missing required pin"; return; } - if (const char* conflict = _soundPinConflict(s, /*excludeSoundIndex=*/idx)) { - auto e = _makeErr(conflict); - _sendJson(r, 400, e); + if (const char* conflict = _soundPinConflict(snd, /*excludeSoundIndex=*/idx)) { + s.status = 400; + s.body["error"] = conflict; return; } - s.exists = true; - Config::get().sounds[idx] = s; + snd.exists = true; + Config::get().sounds[idx] = snd; Config::save(); - JsonDocument resp; - resp["ok"] = true; - resp["index"] = idx; - _sendJson(r, 200, resp); + s.body["ok"] = true; + s.body["index"] = idx; // Hardware config changes require restart to take effect - delay(200); - ESP.restart(); + s.restart = true; } // ── POST /api/sounds/update ───────────────────────────────────────────────── // Body: {index, name?, chip?, i2cAddress?, i2sMclkPin?, i2sBclkPin?, i2sWsPin?, // i2sDoutPin?, paEnablePin?, paEnableActiveHigh?, paViaExpander?} - void _updateSound(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _updateSound(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; uint8_t idx = doc["index"] | (uint8_t)0xFF; if (idx >= MAX_SOUNDS || !Config::get().sounds[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } auto& existing = Config::get().sounds[idx]; @@ -2132,60 +2084,49 @@ class BatteryWebServer { if (hwChanged) { if (candidate.i2sBclkPin == PIN_UNUSED || candidate.i2sWsPin == PIN_UNUSED || candidate.i2sDoutPin == PIN_UNUSED) { - auto e = _makeErr("missing required pin"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "missing required pin"; return; } if (const char* conflict = _soundPinConflict(candidate, /*excludeSoundIndex=*/idx)) { - auto e = _makeErr(conflict); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = conflict; return; } candidate.exists = true; existing = candidate; } Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); - if (hwChanged) { - delay(200); - ESP.restart(); - } + s.body["ok"] = true; + if (hwChanged) s.restart = true; } // ── POST /api/sounds/delete ─────────────────────────────────────────────── // Body: {index} - void _deleteSound(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t idx = doc["index"] | (uint8_t)0xFF; + void _deleteSound(ApiRequest& q, ApiResponse& s) { + uint8_t idx = q.body["index"] | (uint8_t)0xFF; if (idx >= MAX_SOUNDS || !Config::get().sounds[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } Config::get().sounds[idx].exists = false; Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); - delay(200); - ESP.restart(); + s.body["ok"] = true; + s.restart = true; } // ── POST /api/sounds/test ───────────────────────────────────────────────── // Body: {index} — plays the built-in hardware-verification melody. - void _testSound(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t idx = doc["index"] | (uint8_t)0xFF; + void _testSound(ApiRequest& q, ApiResponse& s) { + uint8_t idx = q.body["index"] | (uint8_t)0xFF; if (idx >= MAX_SOUNDS || !Config::get().sounds[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } if (_onTestSound) _onTestSound(idx); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── Storage (SD card) ──────────────────────────────────────────────────── @@ -2202,18 +2143,17 @@ class BatteryWebServer { } // ── GET /api/storage ───────────────────────────────────────────────────── - void _getStorage(AsyncWebServerRequest* r) { - JsonDocument doc; - doc["hwSupported"] = SdCardManager::kHwSupported; + void _getStorage(ApiRequest&, ApiResponse& s) { + s.body["hwSupported"] = SdCardManager::kHwSupported; bool present = _sdCard && _sdCard->present(); - doc["present"] = present; - doc["totalBytes"] = present ? _sdCard->totalBytes() : 0; - doc["usedBytes"] = present ? _sdCard->usedBytes() : 0; + s.body["present"] = present; + s.body["totalBytes"] = present ? _sdCard->totalBytes() : 0; + s.body["usedBytes"] = present ? _sdCard->usedBytes() : 0; // Only list files this API can actually manage (see _isValidWavName) — // the card's root can otherwise hold arbitrary pre-existing files (a // prior recording, OS-created metadata from formatting the card on a // computer, ...) that would show up here but always 400 on delete. - JsonArray files = doc["files"].to(); + JsonArray files = s.body["files"].to(); if (present) { _sdCard->forEachFile([&](const String& name, size_t size) { if (!_isValidWavName(name)) return; @@ -2222,102 +2162,86 @@ class BatteryWebServer { o["size"] = size; }); } - _sendJson(r, 200, doc); } // ── POST /api/storage/upload?name= ──────────────────────────── - // Body: raw file bytes (streamed straight to the SD card, not buffered — - // audio files are too large for that, unlike the scene-save JSON above). - void _storageUploadChunk(AsyncWebServerRequest* r, uint8_t* data, size_t len, size_t index, - size_t) { - auto* st = static_cast(r->_tempObject); + // Streaming: raw file bytes written straight to the SD card as they + // arrive, not buffered — audio files are too large for that, unlike the + // scene-save JSON above. + void _storageUpload(ApiRequest& q, ApiResponse& s) { + auto* st = static_cast(q.streamState); + s.streamDone = false; if (!st) { st = new StorageUploadState(); - r->_tempObject = st; + q.streamState = st; - String name; - if (r->hasParam("name")) name = r->getParam("name")->value(); + String name = q.query ? q.query : ""; if (!_isValidWavName(name)) { Logger::w("[storage] upload: rejected filename %s", name.c_str()); st->failed = true; st->error = "invalid filename"; - return; - } - if (!_sdCard || !_sdCard->present()) { + } else if (!_sdCard || !_sdCard->present()) { st->failed = true; st->error = "no SD card"; - return; + } else { + st->file = _sdCard->openForWrite(name); + if (!st->file) { + Logger::e("[storage] upload: open failed for %s", name.c_str()); + st->failed = true; + st->error = "open failed"; + } else { + Logger::i("[storage] upload: %s", name.c_str()); + } } - st->file = _sdCard->openForWrite(name); - if (!st->file) { - Logger::e("[storage] upload: open failed for %s", name.c_str()); + } + + if (!st->failed && q.chunkLen) { + size_t written = st->file.write(q.chunk, q.chunkLen); + if (written != q.chunkLen) { + Logger::e("[storage] upload: chunk write incomplete (%u/%u) at index=%u", + (unsigned)written, (unsigned)q.chunkLen, (unsigned)q.chunkIndex); st->failed = true; - st->error = "open failed"; - return; + st->error = "write failed"; + st->file.close(); + st->file = File(); } - Logger::i("[storage] upload: %s", name.c_str()); } - if (st->failed || !len) return; + if (q.chunkIndex + q.chunkLen < q.chunkTotal) return; // more chunks coming - size_t written = st->file.write(data, len); - if (written != len) { - Logger::e("[storage] upload: chunk write incomplete (%u/%u) at index=%u", - (unsigned)written, (unsigned)len, (unsigned)index); - st->failed = true; - st->error = "write failed"; - st->file.close(); - st->file = File(); - } - } - - void _finishStorageUpload(AsyncWebServerRequest* r) { - auto* st = static_cast(r->_tempObject); - if (!st) { - auto e = _makeErr("no body"); - _sendJson(r, 400, e); - return; - } if (st->file) st->file.close(); - - JsonDocument resp; - int code = 200; + s.streamDone = true; if (st->failed) { - resp["error"] = st->error ? st->error : "upload failed"; - code = 400; + s.status = 400; + s.body["error"] = st->error ? st->error : "upload failed"; } else { - resp["ok"] = true; + s.body["ok"] = true; } delete st; - r->_tempObject = nullptr; - _sendJson(r, code, resp); + q.streamState = nullptr; } // ── POST /api/storage/delete ───────────────────────────────────────────── // Body: {name} - void _deleteStorageFile(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - String name = doc["name"] | ""; + void _deleteStorageFile(ApiRequest& q, ApiResponse& s) { + String name = q.body["name"] | ""; if (!_isValidWavName(name)) { - auto e = _makeErr("invalid filename"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "invalid filename"; return; } if (!_sdCard || !_sdCard->deleteFile(name)) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; } // ── GET /api/buttons ────────────────────────────────────────────────────── - void _getButtons(AsyncWebServerRequest* r) { - JsonDocument doc; - doc["maxButtons"] = MAX_BUTTONS; - JsonArray arr = doc["buttons"].to(); + void _getButtons(ApiRequest&, ApiResponse& s) { + s.body["maxButtons"] = MAX_BUTTONS; + JsonArray arr = s.body["buttons"].to(); for (uint8_t i = 0; i < MAX_BUTTONS; i++) { auto& b = Config::get().buttons[i]; if (!b.exists) continue; @@ -2325,7 +2249,6 @@ class BatteryWebServer { o["index"] = i; serializeButton(o, b); } - _sendJson(r, 200, doc); } // Returns an error string if b's pin collides with another configured @@ -2349,9 +2272,8 @@ class BatteryWebServer { // ── POST /api/buttons/add ───────────────────────────────────────────────── // Body: {name?, pin, activeLow?, viaExpander?, onShortPress?, onLongPress?, // onDoubleClick?} - void _addButton(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _addButton(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; uint8_t idx = 0xFF; for (uint8_t i = 0; i < MAX_BUTTONS; i++) { if (!Config::get().buttons[i].exists) { @@ -2360,37 +2282,34 @@ class BatteryWebServer { } } if (idx == 0xFF) { - auto e = _makeErr("button limit reached"); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = "button limit reached"; return; } ButtonHardwareConfig b; deserializeButton(doc, b); if (const char* conflict = _buttonPinConflict(b, /*excludeButtonIndex=*/idx)) { - auto e = _makeErr(conflict); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = conflict; return; } b.exists = true; // deserializeButton defaults "exists" to false when the key is absent Config::get().buttons[idx] = b; Config::save(); - JsonDocument resp; - resp["ok"] = true; - resp["index"] = idx; - _sendJson(r, 200, resp); + s.body["ok"] = true; + s.body["index"] = idx; if (_onButtonsChanged) _onButtonsChanged(); } // ── POST /api/buttons/update ────────────────────────────────────────────── // Body: {index, name?, pin?, activeLow?, viaExpander?, onShortPress?, onLongPress?, // onDoubleClick?} - void _updateButton(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; + void _updateButton(ApiRequest& q, ApiResponse& s) { + JsonVariantConst doc = q.body; uint8_t idx = doc["index"] | (uint8_t)0xFF; if (idx >= MAX_BUTTONS || !Config::get().buttons[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } auto& existing = Config::get().buttons[idx]; @@ -2412,8 +2331,8 @@ class BatteryWebServer { } if (pinChanged) { if (const char* conflict = _buttonPinConflict(candidate, (int8_t)idx)) { - auto e = _makeErr(conflict); - _sendJson(r, 400, e); + s.status = 400; + s.body["error"] = conflict; return; } } @@ -2428,26 +2347,22 @@ class BatteryWebServer { existing.onDoubleClick = deserializeButtonAction(doc["onDoubleClick"], existing.onDoubleClick); Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; if (_onButtonsChanged) _onButtonsChanged(); } // ── POST /api/buttons/delete ────────────────────────────────────────────── // Body: {index} - void _deleteButton(AsyncWebServerRequest* r, uint8_t* data, size_t len) { - JsonDocument doc; - if (!_parseJson(r, doc, data, len)) return; - uint8_t idx = doc["index"] | (uint8_t)0xFF; + void _deleteButton(ApiRequest& q, ApiResponse& s) { + uint8_t idx = q.body["index"] | (uint8_t)0xFF; if (idx >= MAX_BUTTONS || !Config::get().buttons[idx].exists) { - auto e = _makeErr("not found"); - _sendJson(r, 404, e); + s.status = 404; + s.body["error"] = "not found"; return; } Config::get().buttons[idx].exists = false; Config::save(); - auto ok = _makeOk(); - _sendJson(r, 200, ok); + s.body["ok"] = true; if (_onButtonsChanged) _onButtonsChanged(); } From 7d1c35e63aaf7639d42f048b3578c2b68275f1b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:22:28 +0000 Subject: [PATCH 2/6] web: add USB serial transport shim for offline device config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Web Serial-backed window.fetch shim (data/index.html) that speaks SerialConfigServer's framed protocol, engaged only when the page can't reach a device over HTTP (see the startup probe) — every existing fetch()-based call site keeps working unmodified. The one XHR-based upload path (storage .wav upload) branches on serial mode explicitly, and scene save streams its body in chunks the same way the firmware side does. WebSocket push has no serial equivalent, so a lightweight poll substitutes for peers/groups live updates while connected this way (#355). --- data/index.html | 310 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 307 insertions(+), 3 deletions(-) diff --git a/data/index.html b/data/index.html index 0adcd1dc..315df66d 100644 --- a/data/index.html +++ b/data/index.html @@ -236,6 +236,10 @@ .section-divider { border: none; border-top: 1px solid #222; margin: .8rem 0; } .push-overlay { position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;overflow-y:auto;display:none } .push-modal { max-width:480px;margin:2rem auto;padding:1rem } +.serial-overlay { position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:300;overflow-y:auto;display:none } +.serial-modal { max-width:420px;margin:4rem auto;padding:1.25rem;text-align:center } +.serial-modal p { color:#999;font-size:.9rem;line-height:1.4 } +.serial-modal .error { color:#f88 } .push-fh { display:flex;justify-content:space-between;align-items:baseline;margin-bottom:.2rem } .push-fh>span { font-size:.9rem;font-weight:600 } .push-fh small { font-weight:normal } @@ -1163,6 +1167,18 @@

Options

+ +
+
+

Connect over USB

+

This page isn't connected to a battery light over the network. + Connect the device with a USB cable and click below to configure it directly.

+ +
+
+ From 1e63880ede22ddefae6b1c11f1ca443e6e106f75 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:22:59 +0000 Subject: [PATCH 3/6] web,serial: apply clang-format --- src/web/SerialConfigServer.h | 5 ++--- src/web/WebServer.h | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/web/SerialConfigServer.h b/src/web/SerialConfigServer.h index 0b0ee130..dfb92a71 100644 --- a/src/web/SerialConfigServer.h +++ b/src/web/SerialConfigServer.h @@ -168,9 +168,8 @@ class SerialConfigServer { if (streaming) { const char* b64 = doc["chunk"] | ""; size_t outLen = 0; - if (b64[0] && - mbedtls_base64_decode(chunkBuf, sizeof(chunkBuf), &outLen, - (const unsigned char*)b64, strlen(b64)) != 0) { + if (b64[0] && mbedtls_base64_decode(chunkBuf, sizeof(chunkBuf), &outLen, + (const unsigned char*)b64, strlen(b64)) != 0) { Logger::w("[serialcfg] bad base64 chunk, dropping request id=%u", (unsigned)id); return; } diff --git a/src/web/WebServer.h b/src/web/WebServer.h index a984b564..b8ebf4f5 100644 --- a/src/web/WebServer.h +++ b/src/web/WebServer.h @@ -1330,8 +1330,8 @@ class BatteryWebServer { if (_sceneSync) _sceneSync->onSceneChanged(st->id.c_str(), st->prevHash); if (_onSceneSaved) _onSceneSaved(st->id.c_str()); } else { - Logger::e("[scene] save failed: %s (failed=%d written=%d)", - st->error ? st->error : "?", st->failed, st->written); + Logger::e("[scene] save failed: %s (failed=%d written=%d)", st->error ? st->error : "?", + st->failed, st->written); } s.status = ok ? 200 : 500; From 405f61e361cf6800652f1893a5d04752e019da34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:23:09 +0000 Subject: [PATCH 4/6] ci: add GitHub Pages deploy for standalone USB-serial config page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes data/index.html to GitHub Pages so it's reachable without any device serving it — the standalone entry point for USB-serial config (#355). Requires the repo's Pages source to be set to "GitHub Actions" in Settings > Pages, a one-time manual step this workflow can't do itself. --- .github/workflows/pages-deploy.yml | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/pages-deploy.yml diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml new file mode 100644 index 00000000..7d75694f --- /dev/null +++ b/.github/workflows/pages-deploy.yml @@ -0,0 +1,50 @@ +name: "Pages: deploy config page" + +# Publishes data/index.html to GitHub Pages so it's reachable without any +# device serving it — the standalone entry point for USB-serial config +# (#355), when the device has no WiFi and isn't reachable over its own AP. +# The identical file is also baked into the firmware's LittleFS image +# (see data/); the page detects at runtime which context it's running in +# (see the startup probe in data/index.html) and behaves the same either way. +# +# Requires the repository's Pages source to be set to "GitHub Actions" +# (Settings > Pages) — a one-time manual step, not something this workflow +# can configure itself. +on: + push: + branches: [main] + paths: + - 'data/index.html' + - '.github/workflows/pages-deploy.yml' + workflow_dispatch: + +concurrency: + group: pages-deploy + cancel-in-progress: false + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + + - name: Prepare Pages content + run: | + mkdir -p _site + cp data/index.html _site/index.html + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - id: deployment + uses: actions/deploy-pages@v4 From 960c0855eef250820eae817f70ad61eb604059be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:24:31 +0000 Subject: [PATCH 5/6] docs: document the USB serial config protocol (#355) Adds docs/serial-config.md (wire protocol, design rationale, constraints), a README section pointing users at the GitHub Pages entry point, and an architecture.md update noting SerialConfigServer alongside BatteryWebServer. --- README.md | 17 +++++++ docs/architecture.md | 2 +- docs/serial-config.md | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 docs/serial-config.md diff --git a/README.md b/README.md index a1c6949f..d3d3e2d3 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,23 @@ After reboot the device joins your WiFi and is reachable at **http://\ device: + @BLCFG HELLO + @BLCFG REQ {"id":1,"method":"GET","path":"/api/config"} + @BLCFG REQ {"id":2,"method":"POST","path":"/api/wifi/add","body":{"ssid":"...","password":"..."}} + @BLCFG REQ {"id":3,"method":"POST","path":"/api/storage/upload", + "query":"song.wav","index":0,"total":123,"chunk":""} + @BLCFG BYE + +Device -> browser: + @BLCFG READY {"proto":1,"device":"","version":""} + @BLCFG RES {"id":1,"status":200,"body":{...}} + @BLCFG RES {"id":3,"ack":true,"index":0} (mid-upload) + @BLCFG RES {"id":4,"status":200,"index":0,"total":9000,"chunk":"","final":false} + @BLCFG BYE +``` + +- `id` is chosen by the browser and echoed back so a response can be + matched to its request. +- `path`/`method` map directly onto the same paths the REST API uses + (`/api/config`, `/api/wifi/add`, ...). +- `query` carries the one query-string parameter a couple of endpoints take + outside the JSON body (`id` for `GET /api/scenes/get`, `name` for + `POST /api/storage/upload`). +- Streaming endpoints (`POST /api/scenes/save`, `POST /api/storage/upload`) + split their body into ≤2048-byte chunks (`SerialConfigServer::CHUNK_BYTES` + / `SERIAL_CHUNK_BYTES` in `data/index.html`), base64-encoded, one chunk + per `REQ` frame. The device acks each non-final chunk (`{"ack":true}`) so + the browser paces the next one — there's no window/pipelining, only one + chunk in flight at a time. The response is only sent after the final + chunk (`index + len(chunk) >= total`). +- A large **response** body (`GET /api/scenes/get`'s raw scene file, or any + oversized JSON body) is sent back the same chunked way — `chunk`/`index`/ + `total`/`final` — instead of one inline `body` frame. Everything else + (the overwhelming majority of endpoints) fits in a single frame. + +## Constraints + +- **One request in flight at a time.** The browser must wait for a response + (or upload ack) before sending the next request or chunk — this matches + one browser tab talking to one device over one serial port; it is not a + pipelined/concurrent protocol. +- **Out of scope today:** live push (peers/groups/log) has no serial + equivalent — `data/index.html` falls back to polling `/api/config` and + `/api/peers` every 5s while connected this way instead of the WebSocket + push HTTP mode gets. Live device log streaming is likewise unavailable + in serial mode (the log sink is suspended for the whole session). +- Baud rate is the existing `115200` used for logging — fine for config + JSON and reasonably sized scenes, but slow for multi-megabyte `.wav` + uploads. Not changed here; revisit if real-world upload times turn out + to matter. From e894a93668053cde110d31f6cdb5581d811b7dfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:57:22 +0000 Subject: [PATCH 6/6] config,web,serial: fix CI build failure and missing request body over serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens Config.h's deserialize* helpers from JsonVariant to JsonVariantConst (they were always read-only) — WebServer.h's transport-agnostic handlers pass ApiRequest::body as JsonVariantConst, which doesn't implicitly convert to the mutable JsonVariant these previously required, and failed to build on esp32s3/esp32dev CI. Also fixes SerialConfigServer::_handleRequest, which built every ApiRequest without ever copying the incoming frame's "body" field into it — every non-streaming POST over serial was silently dispatched with an empty body. --- src/config/Config.cpp | 10 +++++----- src/config/Config.h | 10 +++++----- src/web/SerialConfigServer.h | 1 + 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/config/Config.cpp b/src/config/Config.cpp index b21dbe11..354c3d9d 100644 --- a/src/config/Config.cpp +++ b/src/config/Config.cpp @@ -37,7 +37,7 @@ void serializeLightConfig(JsonObject o, const LightConfig& l) { o["time24h"] = l.time24h; } -LightConfig deserializeLightConfig(JsonVariant j, const LightConfig& def) { +LightConfig deserializeLightConfig(JsonVariantConst j, const LightConfig& def) { LightConfig l = def; l.mode = (GroupMode)(uint8_t)(j["mode"] | (uint8_t)def.mode); strlcpy(l.sceneId, j["sceneId"] | def.sceneId, sizeof(l.sceneId)); @@ -65,7 +65,7 @@ void serializeGroup(JsonObject o, const GroupConfig& g) { serializeLightConfig(o, g.light); } -void deserializeGroup(JsonVariant o, GroupConfig& g) { +void deserializeGroup(JsonVariantConst o, GroupConfig& g) { g.id = o["id"] | (uint8_t)0; g.exists = o["exists"] | false; g.syncEnabled = o["syncEnabled"] | true; @@ -84,7 +84,7 @@ void serializeButtonAction(JsonObject o, const ButtonAction& a) { o["b"] = a.params.colorValue.b; } -ButtonAction deserializeButtonAction(JsonVariant j, const ButtonAction& def) { +ButtonAction deserializeButtonAction(JsonVariantConst j, const ButtonAction& def) { ButtonAction a = def; a.action = (ActionId)(uint8_t)(j["action"] | (uint8_t)def.action); a.groupId = j["groupId"] | def.groupId; @@ -109,7 +109,7 @@ void serializeButton(JsonObject o, const ButtonHardwareConfig& b) { serializeButtonAction(o["onDoubleClick"].to(), b.onDoubleClick); } -void deserializeButton(JsonVariant o, ButtonHardwareConfig& b) { +void deserializeButton(JsonVariantConst o, ButtonHardwareConfig& b) { strlcpy(b.name, o["name"] | "", sizeof(b.name)); b.pin = o["pin"] | (uint8_t)0; b.activeLow = o["activeLow"] | true; @@ -134,7 +134,7 @@ void serializeSound(JsonObject o, const SoundHardwareConfig& s) { o["exists"] = s.exists; } -void deserializeSound(JsonVariant o, SoundHardwareConfig& s) { +void deserializeSound(JsonVariantConst o, SoundHardwareConfig& s) { strlcpy(s.name, o["name"] | "", sizeof(s.name)); s.chip = (SoundChip)(uint8_t)(o["chip"] | (uint8_t)SoundChip::ES8311); s.i2cAddress = o["i2cAddress"] | (uint8_t)0x18; diff --git a/src/config/Config.h b/src/config/Config.h index ac93789b..c8a58ad5 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -256,9 +256,9 @@ struct GroupConfig { // Config.cpp (full config load/save) and WebServer.h (REST API request/response), // so the field list only needs to be maintained in one place. void serializeLightConfig(JsonObject o, const LightConfig& l); -LightConfig deserializeLightConfig(JsonVariant j, const LightConfig& def = LightConfig{}); +LightConfig deserializeLightConfig(JsonVariantConst j, const LightConfig& def = LightConfig{}); void serializeGroup(JsonObject o, const GroupConfig& g); -void deserializeGroup(JsonVariant o, GroupConfig& g); +void deserializeGroup(JsonVariantConst o, GroupConfig& g); // Sentinel for an unused/unconfigured GPIO pin field (button pin, sound I2S // pin, device I2C bus pin, ...). @@ -302,9 +302,9 @@ struct ButtonHardwareConfig { }; void serializeButtonAction(JsonObject o, const ButtonAction& a); -ButtonAction deserializeButtonAction(JsonVariant j, const ButtonAction& def = ButtonAction{}); +ButtonAction deserializeButtonAction(JsonVariantConst j, const ButtonAction& def = ButtonAction{}); void serializeButton(JsonObject o, const ButtonHardwareConfig& b); -void deserializeButton(JsonVariant o, ButtonHardwareConfig& b); +void deserializeButton(JsonVariantConst o, ButtonHardwareConfig& b); // Per-light physical hardware configuration, stored in DeviceConfig. struct LightHardwareConfig { @@ -372,7 +372,7 @@ struct SoundHardwareConfig { }; void serializeSound(JsonObject o, const SoundHardwareConfig& s); -void deserializeSound(JsonVariant o, SoundHardwareConfig& s); +void deserializeSound(JsonVariantConst o, SoundHardwareConfig& s); static constexpr uint8_t MAX_SOUNDS = 1; diff --git a/src/web/SerialConfigServer.h b/src/web/SerialConfigServer.h index dfb92a71..1d5b5fdf 100644 --- a/src/web/SerialConfigServer.h +++ b/src/web/SerialConfigServer.h @@ -159,6 +159,7 @@ class SerialConfigServer { ApiRequest req; req.method = m; req.path = path.c_str(); + req.body = doc["body"].as(); ApiResponse resp; String query = doc["query"] | "";