OXWirelessManager — how it works

Internals: the transport seam, the inbound consumer forwarding, and byte accounting

The shape of the problem

Two radios, two very different links — but one job: move bytes on and off the chips and tell someone else about it. The original design folded the meaning of those bytes (REST routes, BLE command protocols) into the same class. That tangled hardware control with business logic, so the two were split:

Both must be testable on a laptop. The ESP32 Wi-Fi/BLE stacks can't run on the host, so — exactly like OXAudioManager and OXSwitchManager — the radios sit behind abstract interfaces that the transport talks to, with real backends on-device and fakes in tests.

The transport seam

Two interfaces define the hardware seam: IWifiDevice and IBluetoothDevice (declared in include/OXWirelessTypes.h). Each owns the actual hardware/stack (the HTTP server, the GATT service) and exposes a tiny lifecycle — start(), stop(), isUp() — plus a single poll(sink, ctx) that drains pending work.

The key design choice is the sink trampoline. Rather than have the device know about consumers, poll() receives a function pointer and an opaque context. For each inbound item, the device builds the request/message, calls sink(item, out, ctx), and transmits whatever out the sink filled in. The manager passes this as the context and a static member as the sink, so control lands back inside the manager:

// In OXWirelessManager::poll():
wifi_->poll(&OXWirelessManager::httpSink, this);
bt_->poll(&OXWirelessManager::btSink, this);

// The trampoline casts ctx back to the manager and forwards (not routes):
void OXWirelessManager::httpSink(const WifiRequest &req, WifiResponse &res, void *ctx) {
  static_cast<OXWirelessManager *>(ctx)->forwardHttp(req, res);
}

This is the same "C callback + void* context" idiom the other managers use, applied to the transport boundary. It keeps the backends free of any knowledge about consumers, and keeps the transport free of any knowledge about sockets or BLE internals.

Forwarding to the consumer (the second seam)

Where the old class routed here, the transport now forwards. forwardHttp / forwardBt do exactly three things: count the inbound bytes, hand the item to the single registered consumer, and count the outbound bytes the consumer produced.

void OXWirelessManager::forwardHttp(const WifiRequest &req, WifiResponse &res) {
  stats_.wifiRxBytes += req.bodyLen;             // count in
  if (httpHandler_ != nullptr)
    httpHandler_(req, res, httpHandlerCtx_);     // -> OXAPIManager
  else { res.setStatus(404); res.setContentType("text/plain");
         res.setBody("Not Found"); }       // no consumer attached
  stats_.wifiTxBytes += res.bodyLen();         // count out
}

A consumer is registered through setHttpRequestHandler / setBtMessageHandler — one slot per channel. There is one consumer because the device has one protocol layer; multiplexing would be OXAPIManager's job, not the transport's. The default-404 fallback means a transport with no API attached still behaves correctly (and uses setContentType+setBody rather than text(), which would clobber the 404 back to 200 — a subtlety the tests lock in).

Byte accounting

WirelessStats counts application payload, not wire bytes, and it counts it in exactly one place per direction — the forwarding methods — so it can never drift from what actually flows:

Semantic counts — how many requests were routed, how many commands matched — are a protocol concern, so they live in OXAPIManager::ApiStats. The clean division is: bytes through the radios = transport; request/command meaning = API. Counting payload (not TCP/HTTP/BLE framing) keeps the numbers backend-independent, which is what makes the statistics assertions in the unit tests possible.

The ESP32 backends

EspWifiDevice wraps WiFi.h + WebServer. In AccessPoint mode it brings up a SoftAP (open if the password is shorter than the 8-char WPA2 minimum, secured otherwise) and serves at the gateway IP; in Station mode it joins a router with a cooperative ~10 s association wait so isUp() is meaningful when start() returns. A single catch-all onNotFound handler on the WebServer funnels every request into the transport's forwarding sink — the WebServer does no routing, the API layer does it all.

EspBluetoothDevice exposes a Nordic UART Service (NUS) over NimBLE — the lightweight BLE stack with a much smaller footprint than the bundled Bluedroid on the ESP32-S3 Super Mini. It offers a writable RX characteristic (phone → device) and a notify TX characteristic (device → phone), UUIDs that common BLE terminal apps recognize. BLE callbacks fire on the BLE task, not the loop task, so an inbound write is copied into a single-slot buffer in the onWrite callback and drained on poll() from the loop task — the consumer's handlers never run inside a BLE callback context. On disconnect the backend re-advertises so the next phone can connect.

Why the device scans reliably on iOS and Android

Discoverability hinges on how the advertising packets are built. The 128-bit NUS UUID is 18 bytes; dropping it into the 31-byte primary packet alongside the flags nearly fills it and forces the name into an auto-managed scan response — which works on some scanners and silently fails on others. Instead the backend builds both packets by hand: the primary packet stays small and standard (Flags + a 16-bit Appearance) and the device name rides in the scan response. The full NUS service is discovered after connect from the GATT server, so it never needs to be advertised. The backend also requests max TX power, a 251-byte MTU/data length (so a JSON config fits in fewer packets), and iOS-friendly connection-interval hints. This is the combination that scans dependably across phones.

Both backends live under src/wifi/ and src/bluetooth/, which platformio.ini excludes from the native build, and both register themselves as the default factory via a static-init installer in src/wifi/OXWirelessManagerDevice.cpp — the same "DefaultDeviceInstaller" pattern used by the audio and GPIO backends. The outbound HTTP backend (EspHttpClient) follows the same pattern under src/http/, but it belongs to OXAPIManager, not the transport.

Testing on the host

FakeWifiDevice and FakeBluetoothDevice implement the two interfaces in pure memory. A test scripts one pending request or message, registers a tiny consumer, calls wireless.poll(), and asserts what the transport forwarded plus every byte counter — all with no radios present. The transport-seam tests (forwarding to the consumer, the default-404 fallback, notify() gating on connection state, and the byte counters) run as part of the pio test -e native suite; the routing/command/outbound-client tests live alongside OXAPIManager.