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:
OXWirelessManager(this class) — the transport. It owns the Wi-Fi and Bluetooth chips, their lifecycle and connection state, the raw send/receive paths, and the byte counters.OXAPIManager— the protocol/business layer. It interprets the bytes: inbound REST routing, inbound BLE command dispatch, and the outbound HTTP client.
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:
forwardHttpaddsreq.bodyLentowifiRxBytesbefore the consumer andres.bodyLen()towifiTxBytesafter.forwardBtaddsmsg.lentobtRxBytesbefore andreply.len()tobtTxBytesafter.notify()adds the bytes the transport accepted tobtTxBytes— the one tx path that isn't a reply to an inbound message.
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.