OXRecorder::OXAPIManager
The protocol layer: inbound REST routing + Bluetooth commands, and an outbound REST client with streaming
Overview
OXAPIManager is the protocol/business layer that sits on top
of OXWirelessManager (the
transport). The transport moves raw bytes on and off the radios;
OXAPIManager decides what those bytes mean, in both
directions:
- Inbound REST (device as server). Register routes with
on(method, path, handler); when a request arrives over Wi-Fi the matching handler fills the response. Unmatched requests hitonNotFoundor a default 404. - Inbound Bluetooth commands (provisioning). Register
handlers with
onCommand(name, handler); a phone writes a text message such assaveconfig {…}orlight on, the first word selects the handler, and the rest is the argument. - Outbound REST (device as client). Call a remote server
with
get()/post()/request()and read back its response, or stream a large body to a server in chunks withbeginUpload()/writeUpload()/finishUpload()— e.g. uploading a recorded audio clip without buffering it all in RAM.
begin(transport) wires this object in as the transport's
single inbound consumer, so the transport just moves bytes and this class
routes them. The outbound side talks through an injected
IHttpClient (real EspHttpClient on-device, a fake
in tests), keeping the whole class host-testable with no radios or sockets
present — the same injectable-seam pattern the transport uses for its
radios.
Declared in include/OXAPIManager.h; portable core in
src/OXAPIManager.cpp; the outbound HTTP backend in
include/IHttpClient.h +
src/http/EspHttpClient.cpp.
Value types
A route handler is void(const WifiRequest&, WifiResponse&, void *ctx);
a command handler is void(const char *args, BluetoothReply&, void *ctx).
These — along with WifiRequest/WifiResponse/
BluetoothReply — are shared with the transport and live in
include/OXWirelessTypes.h.
HttpHeader & HttpClientResult (outbound)
| Type | Field | Meaning |
|---|---|---|
HttpHeader | name, value | One outbound request header (borrowed pointers). |
HttpClientResult | statusCode | HTTP status (e.g. 200), or a negative kHttpErr* code if the request never produced a response. |
bodyLen | Response bytes copied into the caller's buffer. | |
truncated | True if the response was larger than the buffer and the tail was dropped. |
Error codes (negative, in the same statusCode field):
kHttpErrNotConnected (-1), kHttpErrConnectFailed (-2),
kHttpErrTimeout (-3), kHttpErrBadUrl (-4),
kHttpErrStreamState (-5). result.ok() is true for
2xx.
API reference
bool begin(OXWirelessManager &transport)
Attach to a transport: register this object as the consumer of inbound HTTP requests and Bluetooth messages (via setHttpRequestHandler/setBtMessageHandler), and obtain the outbound HTTP client (injected, else from the registered default factory on-device). Call after transport.begin().
void poll()
Reserved for future asynchronous work (e.g. completing a non-blocking upload). Inbound dispatch happens inside transport.poll(); this is a no-op today but kept for symmetry so call sites can pump both managers.
Inbound REST routing
bool on(HttpMethod method, const char *path, WifiRouteHandler cb, void *ctx = nullptr)
Registers a handler for an exact method+path. Re-registering the same pair replaces it. Returns false if the route table is full (max 16) or the path is too long (max 48 chars). Paths are matched without the query string.
void onNotFound(WifiRouteHandler cb, void *ctx = nullptr)
Handler for requests that match no route. If unset, unmatched requests get a default 404 Not Found text/plain response.
Inbound Bluetooth commands
bool onCommand(const char *name, BtCommandHandler cb, void *ctx = nullptr)
Registers a handler for a command word (case-insensitive, e.g. "saveconfig"). Re-registering replaces it. Returns false if the table is full (max 16) or the name is too long (max 24 chars). The argument after the first space arrives intact (a JSON body rides along).
void onUnknownCommand(BtCommandHandler cb, void *ctx = nullptr)
Handler for command words that match no registered command (it receives the command word as args). If unset, unknown commands get no reply.
Outbound REST client — buffered
HttpClientResult request(HttpMethod, const char *url, const HttpHeader *headers, size_t headerCount, const uint8_t *body, size_t bodyLen, uint8_t *respBuf, size_t respCap)
Make a buffered request to a remote server. Headers and request body are sent; the response body is copied into respBuf (capacity respCap, the caller owns it). Returns kHttpErrNotConnected if no client is available.
HttpClientResult get(url, respBuf, respCap, headers = nullptr, headerCount = 0)
HttpClientResult post(url, body, bodyLen, respBuf, respCap, headers = nullptr, headerCount = 0)
put(...) · del(...) · patch(...)
Convenience wrappers over request() for each verb.
Outbound REST client — streaming upload (device → server)
bool beginUpload(HttpMethod, const char *url, size_t contentLength, const HttpHeader *headers = nullptr, size_t headerCount = 0)
Open a chunked upload: sends the request line + headers + a Content-Length of contentLength, then expects exactly that many body bytes via writeUpload() before finishUpload(). Returns false if no client is available, a stream is already open, or the connection failed.
size_t writeUpload(const uint8_t *data, size_t len)
Push one chunk of the upload body. Returns bytes accepted (0 on error / no open stream). Counts toward outboundBytesSent.
HttpClientResult finishUpload(uint8_t *respBuf, size_t respCap) · bool uploading() const
Finish the open stream: flush, read the server's response into respBuf, close the connection, return the result. uploading() is true while a stream is in progress.
Statistics
const ApiStats &stats() const · void resetStats()
Semantic counters: httpRequests, btCommands, notFound, unknownCommand (inbound), and outboundRequests, outboundBytesSent, outboundBytesRecv (outbound client). These count meaning; the raw byte movement through the radios is counted separately by OXWirelessManager::WirelessStats.
Method helpers & client injection
static HttpMethod methodFromString(const char *) · static const char *methodName(HttpMethod)
Convert between the verb string and the enum (used by the outbound backend, tests, and docs).
void setHttpClient(IHttpClient *) · static void setDefaultHttpClientFactory(...)
Inject an outbound client (not owned) for tests/advanced use, or register the default factory the ESP32 backend installs at startup. On the host no factory is registered, so tests inject FakeHttpClient.
Examples
Try it
Routing, command dispatch, the outbound client, and streaming uploads are all covered on the host by pio test -e native (via FakeWifiDevice/FakeBluetoothDevice/FakeHttpClient) — no hardware needed for logic changes.
1. Inbound: REST API + Bluetooth provisioning
#include <Arduino.h>
#include "OXWirelessManager.h"
#include "OXAPIManager.h"
#include "OXLog.h"
using namespace OXRecorder;
OXWirelessManager wireless;
OXAPIManager api;
// --- REST handlers ---
void getStatus(const WifiRequest &req, WifiResponse &res, void *ctx) {
res.json("{\"recording\":false,\"battery\":87}");
}
// --- Bluetooth command handlers ---
void saveConfig(const char *args, BluetoothReply &reply, void *ctx) {
// args is the JSON body, e.g. {"ssid":"home","pw":"secret123"}
wireless.setWifiCredentials("home", "secret123");
wireless.setWifiMode(WifiMode::Station);
wireless.startWifi();
reply.set("{\"ok\":true}");
}
void light(const char *args, BluetoothReply &reply, void *ctx) {
OXLog::info("light command: %s", args); // "on", "off", "pulse" ...
reply.set("ok");
}
void setup() {
OXLog::begin(115200);
WirelessConfig cfg; // BT auto-starts; Wi-Fi waits
strcpy(cfg.bluetooth.pairingName, "OX-Recorder-01");
wireless.begin(cfg);
api.begin(wireless); // attach the protocol layer
api.on(HttpMethod::Get, "/api/status", getStatus);
api.onCommand("saveconfig", saveConfig);
api.onCommand("light", light);
}
void loop() {
wireless.poll(); // pump radios -> dispatch into api
api.poll();
delay(5);
}
2. Outbound: call a remote server
uint8_t resp[256];
HttpHeader headers[] = {{"Content-Type", "application/json"}};
const char *bodyText = "{\"deviceId\":\"ox-01\",\"battery\":87}";
HttpClientResult r = api.post(
"https://api.example.com/v1/telemetry",
(const uint8_t *)bodyText, strlen(bodyText),
resp, sizeof(resp), headers, 1);
if (r.ok()) {
OXLog::info("server said: %.*s", (int)r.bodyLen, resp);
} else {
OXLog::error("telemetry failed: status=%d", r.statusCode); // may be negative kHttpErr*
}
3. Streaming: upload a recorded clip in chunks
// Push a large body to the server without holding it all in RAM.
const size_t clipLen = recordingSize();
if (api.beginUpload(HttpMethod::Post, "https://api.example.com/v1/clips", clipLen)) {
uint8_t chunk[512];
while (size_t n = readClipChunk(chunk, sizeof(chunk))) {
api.writeUpload(chunk, n); // stream each block as it's read
}
uint8_t resp[128];
HttpClientResult r = api.finishUpload(resp, sizeof(resp));
OXLog::info("upload status=%d", r.statusCode);
}
4. End-to-end: join Wi-Fi and stream a download to the SD card
A complete main.cpp that brings up the stack, joins a router in
Station mode, then uses download() to stream an HTTP response
straight to the SD card through a single open
OXFileStream — the body is never
held in RAM, so it works for a file of any size. The notes inline call out the
non-obvious pitfalls (loop-task stack under TLS, open-once vs. per-chunk append)
that bite on real ESP32-S3 hardware.
#include <Arduino.h>
#include "OXWirelessManager.h"
#include "OXAPIManager.h"
#include "OXFileManager.h"
#include "OXLog.h"
using namespace OXRecorder;
// loopTask's default stack is 8 KB, which a TLS (HTTPS) handshake can exhaust on
// its own. Give setup()/loop() a larger stack so the outbound https GET fits.
SET_LOOP_TASK_STACK_SIZE(16 * 1024);
OXWirelessManager wireless;
OXAPIManager api;
OXFileManager files;
// Sink for the streaming download: write each chunk to an already-open SD file
// stream as it comes off the socket, so the body is never held in RAM. The file
// is opened ONCE (not per chunk) — reopening + sync()ing on every ~4 KB chunk
// overwhelms the SD card over SPI and fails partway through a large transfer.
// Return false to abort the transfer (here, only if a disk write fails).
struct SdSink {
OXFileStream *file = nullptr;
size_t total = 0;
bool failed = false;
};
static bool writeChunkToSd(const uint8_t *data, size_t len, void *ctx) {
auto *s = static_cast<SdSink *>(ctx);
if (s->file->write(data, len) != len) {
s->failed = true;
return false; // stop the download; the link is fine, the disk isn't
}
s->total += len;
// Periodically commit to media so a long transfer doesn't sit entirely in the
// card's write buffer; not on every chunk, which is what was too aggressive.
if ((s->total & 0xFFFF) < len) s->file->flush(); // ~every 64 KB
return true;
}
void setup() {
OXLog::begin(); // initialize logging (e.g. open the serial port)
wireless.begin(); // Wi-Fi waits; no auto-start
api.begin(wireless); // outbound HTTP client lives here
if (!files.beginExternal()) { // mount the SD card (default validated wiring)
OXLog::error("SD card mount failed — cannot save download");
return;
}
// --- Point the radio at your router and join it -------------------
wireless.setWifiMode(WifiMode::Station);
wireless.setWifiCredentials("MyHomeNetwork", "sup3r-secret-pw");
if (!wireless.startWifi()) {
OXLog::error("Wi-Fi failed to start");
return;
}
// Joining a router is asynchronous — poll until the link is up.
for (int i = 0; i < 100 && !wireless.isWifiUp(); ++i) {
wireless.poll();
delay(100); // ~10s budget to associate + get a DHCP lease
}
if (!wireless.isWifiUp()) {
OXLog::error("Wi-Fi did not connect");
return;
}
OXLog::info("connected, ip=%s", wireless.wifiIp());
// --- Download a file of unknown size, streaming it straight to the SD card.
// download() drains the response body off the socket in `scratch`-sized
// chunks, handing each to writeChunkToSd(); RAM stays flat (one small
// scratch buffer) no matter how big the file is. -------------------
const char *url = "https://www.google.com";
const char *path = "/google.html";
static uint8_t scratch[4096]; // static: keep this off loopTask's stack
// Open the destination once (false = truncate any existing file).
OXFileStream out = files.openWrite(Storage::External, path, /*append=*/false);
if (!out.isOpen()) {
OXLog::error("could not open %s for writing", path);
return;
}
SdSink sink;
sink.file = &out;
HttpClientResult r = api.download(HttpMethod::Get, url, scratch,
sizeof(scratch), writeChunkToSd, &sink);
out.flush(); // commit the tail
out.close(); // release the file handle
if (sink.failed) {
OXLog::error("SD write failed after %u bytes", (unsigned)sink.total);
} else if (r.statusCode < 0) {
OXLog::error("download failed: %d", r.statusCode); // kHttpErr* (link/TLS/...)
} else if (!r.ok()) {
OXLog::error("server returned HTTP %d", r.statusCode);
} else {
OXLog::info("downloaded %u bytes to %s", (unsigned)sink.total, path);
}
}
void loop() {
wireless.poll();
api.poll();
delay(5);
}
Notes
- Attach order. Call
wireless.begin()first, thenapi.begin(wireless). The latter registers the inbound consumers and grabs the outbound client. - Fixed buffers, no heap. Inbound responses cap at 1 KB (
WifiResponse::kMaxBody) and replies at 512 B (BluetoothReply::kMaxLen); outbound responses are bounded by the caller's buffer with atruncatedflag. Up to 16 routes and 16 commands. - TLS is currently insecure. The outbound HTTPS path uses
WiFiClientSecure::setInsecure()— the server certificate is not validated. This is acceptable for a trusted-LAN backend but is an egress security limitation; certificate pinning or a CA bundle is the intended follow-up. - Inbound security. An open access point and an unauthenticated REST API expose the device to anyone in range. For production, set a WPA2 password and add an auth check (e.g. a token header) in your handlers or a shared
onNotFoundgate. The BLE provisioning link is likewise unauthenticated. - Flash footprint. Pulling in the TLS stack for the outbound client pushes the device image to ~94% of flash. If you don't need outbound HTTPS, an HTTP-only backend would reclaim significant space.