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:

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)

TypeFieldMeaning
HttpHeadername, valueOne outbound request header (borrowed pointers).
HttpClientResultstatusCodeHTTP status (e.g. 200), or a negative kHttpErr* code if the request never produced a response.
bodyLenResponse bytes copied into the caller's buffer.
truncatedTrue 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