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.

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.

FieldTypeDefaultMeaning
wifiWifiConfigsee belowWi-Fi bring-up parameters.
bluetoothBluetoothConfigsee belowBluetooth bring-up parameters.
autoStartWifiboolfalseStart Wi-Fi during begin().
autoStartBluetoothbooltrueStart Bluetooth advertising during begin().

WifiConfig

FieldTypeDefaultMeaning
modeWifiModeAccessPointAccessPoint = device hosts its own hotspot (a phone connects directly — the first-boot flow); Station = join an existing router.
ssidchar[33]"OXRecorder"Network name (AP) or the SSID to join (Station). 32 chars max.
passwordchar[65]""Empty = open AP; WPA2 needs ≥ 8 chars.
httpPortuint16_t80Port the HTTP server listens on.
apChanneluint8_t1Wi-Fi channel (AccessPoint mode only).

BluetoothConfig

FieldTypeDefaultMeaning
pairingNamechar[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.

// 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