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:
- Buffered —
request(): whole request body in, whole response body out, bounded by the caller's buffer. The common case for small JSON calls.get()/post()/etc. are thin wrappers. - Upload stream —
beginStream()/writeChunk()/endStream(): push a large request body to the server in chunks without buffering it all in RAM, then read the response. One stream at a time. - Download stream —
download(): pull a large response body off the socket in fixed-size chunks, handing each to a sink callback, without ever holding the whole body in RAM. The receive-side counterpart of the upload stream (see the section below for why it exists).
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.