// SPDX-License-Identifier: GPL-3.0-only #include "api_http.h" #include #include #include #include "config.h" #include "httprange.h" // pure, host-testable Range parser #include "net_wifi.h" #include "recorder.h" #include "storage.h" namespace { WebServer server(80); const char *FW_VERSION = "0.2.0"; // Format a uint64 (SD sizes exceed 32 bits) into a decimal String. String u64(uint64_t v) { char b[21]; snprintf(b, sizeof(b), "%llu", (unsigned long long)v); return String(b); } void sendJson(int code, const String &body) { server.sendHeader("Access-Control-Allow-Origin", "*"); server.send(code, "application/json", body); } void sendError(int code, const String &msg) { sendJson(code, String("{\"error\":\"") + msg + "\"}"); } // Bearer-token check for mutating endpoints. Empty stored token = auth disabled (open on a // trusted LAN); tightening this to require a token is a security follow-up (see TODO). bool authed() { String tok = config::apiToken(); if (tok.isEmpty()) return true; return server.header("Authorization") == (String("Bearer ") + tok); } const char *recStateStr() { return recorder::state() == recorder::State::Recording ? "recording" : "idle"; } // GET /api/v1/device void handleDevice() { JsonDocument doc; doc["device_id"] = config::deviceId(); doc["firmware_version"] = FW_VERSION; doc["state"] = recStateStr(); doc["power"] = "unknown"; // battery/charge detection lands in M3 doc["battery_pct"] = nullptr; doc["storage_free_bytes"] = storage::freeBytes(); doc["storage_total_bytes"] = storage::totalBytes(); doc["recordings_count"] = storage::countRecordings(); doc["wifi_connected"] = net_wifi::connected(); doc["ip"] = net_wifi::ip(); String out; serializeJson(doc, out); sendJson(200, out); } // GET /api/v1/recordings void handleList() { sendJson(200, storage::listRecordingsJson()); } // GET /api/v1/recordings/{id} void handleGetMeta() { const String id = server.pathArg(0); const String meta = storage::readFile(storage::recMetaPath(id)); if (meta.isEmpty()) return sendError(404, "No such recording"); sendJson(200, meta); } // DELETE /api/v1/recordings/{id} void handleDelete() { if (!authed()) return sendError(401, "Unauthorized"); const String id = server.pathArg(0); if (!storage::removeRecording(id)) return sendError(404, "No such recording"); server.send(204, "application/json", ""); } // GET /api/v1/recordings/{id}/audio (supports HTTP Range) void handleAudio() { const String id = server.pathArg(0); File f = SD.open(storage::recWavPath(id), FILE_READ); if (!f) return sendError(404, "No such recording"); const size_t total = f.size(); server.sendHeader("Accept-Ranges", "bytes"); String rangeHdr = server.header("Range"); oshttp::RangeResult rr = oshttp::parseByteRange(rangeHdr.c_str(), total); if (!rr.satisfiable) { f.close(); return sendError(416, "Range not satisfiable"); } const size_t start = rr.start; const size_t end = rr.end; const size_t len = end - start + 1; f.seek(start); if (rr.partial) { server.sendHeader("Content-Range", "bytes " + String(start) + "-" + String(end) + "/" + String(total)); server.setContentLength(len); server.send(206, "audio/wav", ""); } else { server.setContentLength(total); server.send(200, "audio/wav", ""); } uint8_t buf[1024]; size_t remaining = len; while (remaining > 0) { size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf); size_t n = f.read(buf, want); if (n == 0) break; server.sendContent(reinterpret_cast(buf), n); remaining -= n; } f.close(); } // POST /api/v1/record/start void handleRecordStart() { if (!authed()) return sendError(401, "Unauthorized"); if (!recorder::startRecording()) return sendError(409, "Already recording"); sendJson(200, String("{\"id\":\"") + recorder::currentId() + "\",\"state\":\"recording\"}"); } // POST /api/v1/record/stop void handleRecordStop() { if (!authed()) return sendError(401, "Unauthorized"); const String id = recorder::currentId(); if (!recorder::stopRecording()) return sendError(409, "Not recording"); const String meta = storage::readFile(storage::recMetaPath(id)); sendJson(200, meta.isEmpty() ? String("{\"id\":\"") + id + "\"}" : meta); } // GET /api/v1/device/config (secrets never returned) void handleGetConfig() { JsonDocument doc; doc["device_id"] = config::deviceId(); doc["wifi_ssid"] = config::wifiSsid(); doc["upload_target"] = config::uploadTarget(); doc["codec"] = "wav_pcm_s16le"; doc["sample_rate"] = 16000; String out; serializeJson(doc, out); sendJson(200, out); } // PUT /api/v1/device/config void handlePutConfig() { if (!authed()) return sendError(401, "Unauthorized"); JsonDocument doc; if (deserializeJson(doc, server.arg("plain"))) { return sendError(400, "Invalid JSON"); } if (doc["wifi_ssid"].is()) { config::setWifi(doc["wifi_ssid"].as(), doc["wifi_psk"] | config::wifiPsk()); } if (doc["api_token"].is()) { config::setApiToken(doc["api_token"].as()); } if (doc["upload_target"].is()) { config::setUploadTarget(doc["upload_target"].as()); } // WiFi changes take effect on next reboot/reconnect. sendJson(200, "{\"status\":\"ok\",\"note\":\"reboot to apply WiFi changes\"}"); } } // namespace namespace api_http { void begin() { // WebServer only retains headers we ask for. const char *headers[] = {"Authorization", "Range"}; server.collectHeaders(headers, 2); server.on("/api/v1/device", HTTP_GET, handleDevice); server.on("/api/v1/device/config", HTTP_GET, handleGetConfig); server.on("/api/v1/device/config", HTTP_PUT, handlePutConfig); server.on("/api/v1/recordings", HTTP_GET, handleList); // Register the more specific /audio route before the generic {id} route. server.on("/api/v1/recordings/{}/audio", HTTP_GET, handleAudio); server.on("/api/v1/recordings/{}", HTTP_GET, handleGetMeta); server.on("/api/v1/recordings/{}", HTTP_DELETE, handleDelete); server.on("/api/v1/record/start", HTTP_POST, handleRecordStart); server.on("/api/v1/record/stop", HTTP_POST, handleRecordStop); server.onNotFound([]() { sendError(404, "Not found"); }); server.begin(); } void loop() { server.handleClient(); } } // namespace api_http