OXAPIManager — how it works

Internals: the inbound router, the command dispatcher, and the outbound client seam

Why it exists

The wireless stack used to be one class that both drove the radios and interpreted the bytes. That worked, but it meant a change to "how a phone provisions Wi-Fi" sat in the same file as "how the BLE chip advertises." OXAPIManager is the half that owns meaning: everything about routes, commands, and server calls. The transport below it just moves bytes.

The win is that each layer is small and independently testable. The transport's tests need no notion of REST; this layer's tests need no radios — they attach to a real transport whose chips are fakes, and inject a fake HTTP client for the outbound side.

Attaching to the transport

begin(transport) is where the two layers join. OXAPIManager registers two static trampolines as the transport's single inbound consumer, passing this as the context — the same "C callback + void* ctx" idiom used everywhere in the codebase:

bool OXAPIManager::begin(OXWirelessManager &transport) {
  transport_ = &transport;
  transport.setHttpRequestHandler(&OXAPIManager::httpThunk, this);
  transport.setBtMessageHandler(&OXAPIManager::btThunk, this);
  if (http_ == nullptr && httpFactory_ != nullptr) http_ = httpFactory_();
  return true;
}

From then on, every inbound request/message the transport pumps in poll() lands in dispatchHttp / dispatchBt — with the transport having already counted the bytes on the way through.

Inbound: the REST router

Routes live in a fixed array of {method, path, handler, ctx} (no heap; kMaxRoutes = 16). on() either replaces a matching {method, path} entry or claims a free slot. Dispatch is a linear scan for an exact match: find a route whose method and path both match and call it; otherwise call onNotFound, or synthesize a default 404. Each step bumps the matching ApiStats counter (httpRequests, notFound).

A linear scan is the right call: with a 16-route ceiling and requests arriving at human/network speed, a hash map would add complexity and RAM for no measurable gain. Matching is exact (no path parameters) — a handler that needs /files/:id reads the id from req.query or a fixed prefix, which keeps the matcher trivial and allocation-free.

Inbound: the command dispatcher

A Bluetooth write is one short text payload. The dispatcher splits it into a command word (up to the first space) and an argument (everything after one separating space, verbatim). That "verbatim rest" rule is what lets a JSON body ride along intact:

saveconfig {"ssid":"home","pw":"123"}
└────┬────┘ └──────────────┬─────────┘
  command            args (untouched JSON)

The command word is matched case-insensitively against the command table, and the NUL-terminated argument is handed to the handler. The handler fills a BluetoothReply; a non-empty reply is sent back over the notify characteristic (the transport handles the actual send). Leading whitespace is skipped, and a command word longer than kMaxCommandLen is truncated for matching while the scan still advances past the full word so the argument split stays correct. Unknown commands route to onUnknownCommand (which receives the command word as args) or are silently ignored.

Outbound: the IHttpClient seam

The server-facing side mirrors the transport's radio seam exactly. An abstract IHttpClient (in include/IHttpClient.h, no Arduino dependencies so it compiles on the host) defines three modes:

OXAPIManager holds an IHttpClient * and counts bytes around each call (outboundBytesSent / outboundBytesRecv). Because the client is injected, the entire outbound path is exercised on the host with a FakeHttpClient that scripts responses and captures what was sent — including reassembling the streamed chunks to assert the right bytes went out.

On-device the real EspHttpClient (under src/http/, excluded from the native build) wraps Arduino's HTTPClient for buffered calls and drives a raw WiFiClient / WiFiClientSecure connection for streaming — writing the request line, Host, Content-Length, and caller headers, then the body chunks. It registers itself as the default client via a static-init installer in src/http/OXAPIManagerDevice.cpp, the same pattern the radio backends use.

TLS is currently insecure

The HTTPS path calls WiFiClientSecure::setInsecure() — the server certificate is not validated. That's fine for a trusted-LAN backend but is a real egress security limitation; certificate pinning or a CA bundle is the intended follow-up. It's flagged here and in the API reference so it isn't forgotten.

Why download() exists: the getString() trap

The buffered request() path reads the response with Arduino's HTTPClient::getString(). That call tries to reserve() a single String the size of the entire response body before copying it — and if that one contiguous allocation fails, getString() silently returns an empty string. No error code, no exception: the request "succeeds" with HTTP 200 and a body of zero bytes.

On the ESP32-S3 this is not a corner case. Fetching even a modest web page (the public Google homepage is ~80 KB) means asking for one ~80 KB contiguous block — and an active WiFiClientSecure connection has already carved the heap into fragments for its TLS buffers. The reservation fails, the body comes back empty, and anything downstream (e.g. writing the "page" to the SD card) saves nothing. The buffered path is fundamentally capped at "responses small enough to duplicate in one allocation, on top of TLS" — too low a ceiling for downloading files.

download() sidesteps the trap by never materializing the body. It sends the request, then asks HTTPClient for the underlying socket (getStreamPtr()) and reads the body in a loop into a small caller-owned scratch buffer, invoking the caller's HttpBodySink once per chunk. The only RAM in play is that one scratch buffer (a few KB), no matter whether the file is 4 KB or 4 MB. The loop uses Content-Length when the server sends it and otherwise drains until the connection closes, with a stall timeout that resets on every byte of progress so a slow-but-alive transfer isn't killed.

The sink contract is deliberately tiny: bool sink(const uint8_t *data, size_t len, void *ctx). The data pointer is valid only for that call — copy or write it before returning. Returning true keeps the body flowing; returning false aborts the transfer and closes the connection (the client reports this back as truncated == true, with bodyLen equal to the total handed to the sink). That single boolean is what lets a downstream consumer — a disk write that failed, a quota that's full — stop a multi-megabyte download immediately instead of reading it all just to discard it.

OXAPIManager::download() itself is a thin wrapper: it forwards to the injected client and tallies outboundBytesRecv by the returned bodyLen, exactly like the buffered and upload paths, so the streamed bytes still show up in ApiStats. On the host the FakeHttpClient implements download() by feeding its scripted body to the sink in scratch-sized chunks — so chunked delivery, early sink-abort, and the no-client error path are all covered by pio test -e native with no sockets present.

Two stats structs, one rule

The split draws a clean line through statistics, too. OXWirelessManager::WirelessStats counts bytes through the radios — it sees every inbound/outbound body and tallies it without interpreting it. OXAPIManager::ApiStats counts meaning — how many requests were routed, how many commands matched, how many outbound calls were made. The same inbound request therefore increments wifiRxBytes (transport) and httpRequests (API), each exactly once, in exactly one place. For the outbound client there is no radio-level counterpart, so this layer owns those byte counters end to end.

Testing on the host

test/test_oxapimanager/ attaches an OXAPIManager to a real OXWirelessManager whose radios are FakeWifiDevice/FakeBluetoothDevice, and injects a FakeHttpClient. A test scripts a request, message, or server response and asserts the routing, the JSON-argument split, case-insensitive command matching, the unknown-command path, the outbound verbs, the chunked upload (open/write/finish with byte assertions), response truncation, the offline/connect-failure errors, and every ApiStats counter — all with no radios or sockets. These run alongside the transport's own seam tests in the pio test -e native suite.