OXRecorder::OXWirelessManager
The transport layer: Wi-Fi + Bluetooth chips, connection status, and raw data exchange
Overview
OXWirelessManager owns the device's two radios — and nothing
else. It is the transport layer: it brings the chips up,
tracks their connection state, moves raw bytes in and out, and counts the
traffic. It deliberately knows nothing about REST routes, command
protocols, or business logic — that all lives one layer up in
OXAPIManager. Splitting the
two keeps hardware control and protocol logic from tangling together.
- Wi-Fi chip. Bring it up as an access point or join a
router (
startWifi/stopWifi), set credentials and mode, report the IP. The HTTP server runs here; each inbound request is forwarded to the registered consumer. - Bluetooth chip. Advertise over BLE
(
startBluetooth), track whether a phone is connected, push notifications out (notify), and forward each inbound write to the registered consumer. - Inbound consumer seam. A single consumer per channel
registered with
setHttpRequestHandler/setBtMessageHandlerreceives every inbound item. Normally that consumer isOXAPIManager, attached in itsbegin(). - Traffic statistics. Every application byte in and out
of both radios is counted (see
stats()).
Like the other OX managers it is cooperative and host-testable: the radios
are injected behind IWifiDevice / IBluetoothDevice
(real ESP32 backends on-device, fakes in tests), and poll() is
pumped from loop().
Shared value types in include/OXWirelessTypes.h; the transport
manager in include/OXWirelessManager.h /
src/OXWirelessManager.cpp; the Wi-Fi backend in
src/wifi/EspWifiDevice.cpp and the BLE backend (NimBLE) in
src/bluetooth/EspBluetoothDevice.cpp.
WirelessConfig
Everything begin() needs. The auto-start flags let the device come up advertising over Bluetooth (the usual provisioning entry point) while Wi-Fi waits until credentials arrive.
| Field | Type | Default | Meaning |
|---|---|---|---|
wifi | WifiConfig | see below | Wi-Fi bring-up parameters. |
bluetooth | BluetoothConfig | see below | Bluetooth bring-up parameters. |
autoStartWifi | bool | false | Start Wi-Fi during begin(). |
autoStartBluetooth | bool | true | Start Bluetooth advertising during begin(). |
WifiConfig
| Field | Type | Default | Meaning |
|---|---|---|---|
mode | WifiMode | AccessPoint | AccessPoint = device hosts its own hotspot (a phone connects directly — the first-boot flow); Station = join an existing router. |
ssid | char[33] | "OXRecorder" | Network name (AP) or the SSID to join (Station). 32 chars max. |
password | char[65] | "" | Empty = open AP; WPA2 needs ≥ 8 chars. |
httpPort | uint16_t | 80 | Port the HTTP server listens on. |
apChannel | uint8_t | 1 | Wi-Fi channel (AccessPoint mode only). |
BluetoothConfig
| Field | Type | Default | Meaning |
|---|---|---|---|
pairingName | char[33] | "OXRecorder" | The name a phone sees when scanning for nearby devices. |
These value types — along with WifiRequest,
WifiResponse, BluetoothMessage,
BluetoothReply, and the transport interfaces — live in
include/OXWirelessTypes.h, shared by the transport and the API
layer so neither header depends on the other.
API reference
bool begin(const WirelessConfig &cfg = WirelessConfig{})
Stores cfg, obtains the radios (injected, else from the registered default factories on-device), and honors the auto-start flags. Returns false if a requested auto-start had no transport available; the manager is still usable for the transport that did come up.
void poll()
Pumps both radios: forwards pending HTTP requests and Bluetooth messages to the registered consumers and counts the bytes, updating stats(). Call regularly from loop(); keep loop() non-blocking.
Wi-Fi control
bool startWifi() · bool startWifi(const WifiConfig &cfg)
Starts Wi-Fi using the stored config (or the supplied one, which also becomes the stored config). Returns false if no Wi-Fi transport is set or bring-up failed.
void stopWifi() · bool isWifiUp() const
Stop the radio + HTTP server; query whether Wi-Fi is currently up.
void setWifiCredentials(const char *ssid, const char *password) · void setWifiMode(WifiMode mode)
Update the stored credentials/mode. Takes effect on the next startWifi() (call stopWifi()/startWifi() to re-apply while running). The typical provisioning flow: an OXAPIManager Bluetooth saveconfig handler calls these, then startWifi().
const char *wifiIp() const
Local IP for diagnostics (e.g. "192.168.4.1" in AP mode); "" if Wi-Fi is down.
Bluetooth control
bool startBluetooth() · bool startBluetooth(const char *pairingName)
Starts advertising under the stored pairing name (or pairingName, which also becomes the stored name). Returns false if no Bluetooth transport is set or bring-up failed.
void stopBluetooth() · bool isBluetoothUp() const · bool isBluetoothConnected() const
Stop advertising; query whether the stack is up; query whether a phone is currently connected.
void setPairingName(const char *pairingName)
Change the advertised name. Takes effect on the next startBluetooth().
size_t notify(const char *text) · size_t notify(const uint8_t *data, size_t len)
Push an unsolicited message to the connected phone (e.g. a status update). Returns bytes sent (0 if not connected); counts toward btTxBytes.
Inbound consumer seam
void setHttpRequestHandler(WifiRequestSink cb, void *ctx = nullptr)
void setBtMessageHandler(BtMessageSink cb, void *ctx = nullptr)
Register the single consumer for inbound HTTP requests / Bluetooth writes. poll() forwards each item to it (counting bytes around the call). This is the seam OXAPIManager::begin() attaches to. With no consumer set, inbound HTTP gets a default 404 Not Found and inbound Bluetooth is dropped. Re-registering replaces the previous consumer.
Statistics
const WirelessStats &stats() const · void resetStats()
Byte counters: wifiRxBytes/wifiTxBytes, btRxBytes/btTxBytes, plus totalRxBytes()/totalTxBytes() helpers. "rx" is data received from the phone (request bodies, command messages); "tx" is data sent to the phone (response bodies, replies, pushes). Application payload only — transport framing/headers are not counted. Semantic counts (how many requests, how many commands) live in OXAPIManager::ApiStats, not here: this layer moves bytes, it does not interpret them.
Device injection
void setWifiDevice(IWifiDevice *) · void setBluetoothDevice(IBluetoothDevice *)
static void setDefaultWifiDeviceFactory(...) · static void setDefaultBluetoothDeviceFactory(...)
Inject a transport (not owned) for tests/advanced use, or register the default factory the ESP32 backends install at startup. On the host no factory is registered, so tests inject fakes.
Examples
Try it
Flash with pio run -e esp32-s3-super-mini -t upload && pio device monitor. The transport seam and byte stats are covered on the host by pio test -e native (via fake transports); routing, commands, and the outbound client are covered in OXAPIManager's tests — no hardware needed for logic changes.
1. Bring up the radios; let OXAPIManager own the protocol
OXWirelessManager manages the chips; OXAPIManager
attaches to it and owns the routes/commands. See the
OXAPIManager reference for the handler side.
#include <Arduino.h>
#include "OXWirelessManager.h"
#include "OXAPIManager.h"
using namespace OXRecorder;
OXWirelessManager wireless; // the radios (transport)
OXAPIManager api; // routes, commands, outbound client
void setup() {
WirelessConfig cfg; // BT auto-starts; Wi-Fi waits
strcpy(cfg.bluetooth.pairingName, "OX-Recorder-01");
wireless.begin(cfg);
api.begin(wireless); // wire API onto the transport
}
void loop() {
wireless.poll(); // pump radios -> forwards inbound items to api
api.poll(); // (reserved for future async work)
delay(5);
}
2. Join a Wi-Fi network (Station mode) and fetch a page to the SD card
The defaults host an open access point; to reach the internet instead, put the
radio in Station mode, hand it an SSID + password, and bring it up.
Once isWifiUp() reports the join succeeded, the outbound HTTP client
on OXAPIManager can call a remote
server. Here we pull https://www.google.com and stream the response
onto the SD card via OXFileManager.
#include <Arduino.h>
#include "OXWirelessManager.h"
#include "OXAPIManager.h"
#include "OXFileManager.h"
using namespace OXRecorder;
OXWirelessManager wireless;
OXAPIManager api;
OXFileManager files;
void setup() {
wireless.begin(); // Wi-Fi waits; no auto-start
api.begin(wireless); // outbound HTTP client lives here
files.beginExternal(); // mount the SD card (default validated wiring)
// --- 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());
// --- Fetch the page; copy each buffer-full straight to the SD -----
uint8_t buf[4096];
HttpClientResult r = api.get("https://www.google.com", buf, sizeof(buf));
if (!r.ok()) {
OXLog::error("GET failed: status=%d", r.statusCode); // negative = kHttpErr*
return;
}
if (files.writeFile(Storage::External, "/google.html", buf, r.bodyLen)) {
OXLog::info("saved %u bytes to /google.html%s", (unsigned)r.bodyLen,
r.truncated ? " (truncated to buffer)" : "");
} else {
OXLog::error("SD write failed (card mounted?)");
}
}
void loop() {
wireless.poll();
api.poll();
delay(5);
}
Heads up
A buffered get() copies the entire response into the
buffer you pass, then sets r.truncated = true and drops the tail
if the body was larger than the buffer. The Google homepage is well over
4 KB, so this 4 KB buffer truncates it. To capture a larger file in
full, stream it to storage instead of buffering — see the next example.
3. Download a file larger than 4 KB (stream straight to the SD card)
A buffered get() reads the whole response into the buffer
you hand it — so a big file would need a buffer as large as the file, which is
impossible when the size is unknown and risky under TLS heap pressure. Use
OXAPIManager::download() instead: it
drains the response body off the socket in fixed-size chunks and hands each to a
sink callback, so the body is never held in RAM. The sink writes every
chunk to the SD card, so RAM stays flat no matter how big the download is.
- Small scratch buffer, off the stack.
download()reads each socket chunk into thescratchbuffer you pass; its size just sets the chunk granularity (4 KB is plenty). Make itstaticso it lives in.bss, not onloopTask's 8 KB stack. - Open the SD file once, write through the open stream. Use an
OXFileStreamopened before the download, and write each chunk to it. Do not call the one-shotappendFile()per chunk — see the warning below. - The sink writes each chunk. It runs once per chunk, in
order; return
trueto keep receiving, orfalseto abort early (e.g. when a disk write fails). Thedatapointer is valid only for that call, so write it out before returning.
// Sink context: the open SD stream + a running total / failure flag.
struct SdSink {
OXFileStream *file = nullptr;
size_t total = 0;
bool failed = false;
};
// Called once per chunk as it arrives — write it to the already-open file.
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;
// Commit periodically (~every 64 KB), NOT every chunk — see warning below.
if ((s->total & 0xFFFF) < len) s->file->flush();
return true; // keep receiving
}
// ... in setup()/a task, with Wi-Fi already up: ...
const char *url = "https://example.com/firmware/clip.bin";
const char *path = "/clip.bin";
static uint8_t scratch[4096]; // static => .bss; sets the chunk size
// 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 (card mounted?)", 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);
}
Open the file once — don't append per chunk
It is tempting to make the sink call
files.appendFile(Storage::External, path, data, len) for each
chunk. That works in a host test but fails on real SD-over-SPI
hardware for large files: each appendFile() does a full
open → write → sync() → close cycle, so
an 80 KB download over a 4 KB chunk size means ~20 reopen/flush/close
cycles hammering the FAT. The card can't keep up and a write fails partway
through (a truncated file plus an SD write failed log). Opening the
file once with openWrite() and writing through the open
OXFileStream — flushing only
periodically — keeps a single handle live and is what the card can sustain. See
OXFileManager — how it works for
the full reasoning.
Why stream instead of one big buffer
get() buffers the whole response, so a single call needs a buffer
as large as the file — impossible when the size is unknown, and the large
contiguous allocation can fail outright under the heap fragmentation a TLS
connection creates. download() only ever holds one
scratch-sized chunk, so it handles a body of any size and any
server — no dependence on Range support or a
Content-Length header. The result's bodyLen is the
total handed to your sink, and truncated is true if
the sink aborted early. download() is the receive-side counterpart
of the beginUpload/writeUpload upload stream on
OXAPIManager; see
OXAPIManager — how it works for the
internals.
4. Reading traffic statistics
const WirelessStats &s = wireless.stats();
OXLog::info("wifi rx=%u tx=%u | bt rx=%u tx=%u",
s.wifiRxBytes, s.wifiTxBytes, s.btRxBytes, s.btTxBytes);
OXLog::info("total in=%u out=%u", s.totalRxBytes(), s.totalTxBytes());
5. Pushing a status update to the phone
if (wireless.isBluetoothConnected()) {
wireless.notify("{\"event\":\"recording_started\"}");
}
6. Bring up Bluetooth and Wi-Fi at the same time
The two radios are independent — nothing stops you running both at once. The
simplest way is to pre-fill a WirelessConfig with Station-mode
credentials and flip both auto-start flags, so begin() advertises
over BLE and joins the router in one call. (The usual flow leaves
autoStartWifi off and waits for credentials to arrive over
Bluetooth — see the note below — but when you already know the SSID this is
the most direct path.)
#include <Arduino.h>
#include "OXWirelessManager.h"
#include "OXAPIManager.h"
using namespace OXRecorder;
OXWirelessManager wireless;
OXAPIManager api;
void setup() {
WirelessConfig cfg;
// Bluetooth: advertise under this name (auto-starts by default).
strcpy(cfg.bluetooth.pairingName, "OX-Recorder-01");
// Wi-Fi: join an existing router instead of hosting an AP.
cfg.wifi.mode = WifiMode::Station;
strcpy(cfg.wifi.ssid, "MyHomeNetwork");
strcpy(cfg.wifi.password, "sup3r-secret-pw");
// Turn BOTH radios on during begin().
cfg.autoStartBluetooth = true; // (already the default)
cfg.autoStartWifi = true;
if (!wireless.begin(cfg)) {
// begin() returns false if a requested auto-start had no transport;
// the radio that did come up is still usable.
OXLog::warn("bt up=%d wifi up=%d",
wireless.isBluetoothUp(), wireless.isWifiUp());
}
api.begin(wireless); // one protocol layer serves both channels
OXLog::info("bt advertising=%d wifi ip=%s",
wireless.isBluetoothUp(), wireless.wifiIp());
}
void loop() {
wireless.poll(); // pumps BOTH radios -> dispatches into api
api.poll();
delay(5);
}
Prefer to stage it instead? Bring Bluetooth up first and add Wi-Fi later with the same effect — handy when credentials arrive at runtime:
WirelessConfig cfg;
strcpy(cfg.bluetooth.pairingName, "OX-Recorder-01");
wireless.begin(cfg); // BT advertising; Wi-Fi idle
// ...later, once you have the SSID/password...
wireless.setWifiMode(WifiMode::Station);
wireless.setWifiCredentials("MyHomeNetwork", "sup3r-secret-pw");
wireless.startWifi(); // now both radios are live
Notes
- Pump regularly. Both radios are serviced from
poll(); keeploop()non-blocking so requests and commands are handled promptly. - One consumer per channel. The HTTP and Bluetooth seams each hold a single handler.
OXAPIManager::begin()registers itself on both; if you attach your own, you replace it. - Provisioning flow. The device boots advertising over Bluetooth (no Wi-Fi yet). The phone sends a
saveconfigcommand; theOXAPIManagerhandler callswireless.setWifiCredentials()+startWifi(). From then on the REST API is reachable. - Byte counts are application payload. Request/response bodies, command messages, and replies — not TCP/HTTP/BLE framing.