00. What This Is
GHOSTWAVES is a thin C++ library that lets you send and receive raw 802.11 frames on ESP32,
bypassing the standard Wi-Fi stack. Instead of connecting to a router, authenticating, getting
an IP, and going through TCP/IP, you construct a frame in memory and hand it directly to the
radio hardware via esp_wifi_80211_tx().
The tradeoff is straightforward: you skip all the overhead (faster, simpler, no infrastructure needed), but you also lose everything that overhead provides (encryption, reliability, delivery guarantees, error correction). This is useful for specific scenarios where speed and simplicity matter more than reliability.
No Connection Overhead
Standard Wi-Fi requires scanning, auth, association, DHCP, and TCP handshakes before data moves. GHOSTWAVES skips all of that. Construct frame, transmit. This makes it practical for deep-sleep sensor nodes where wake time matters.
Not on the Network
These frames don't go through a router and won't appear on standard network tools. However, anyone with a monitor-mode adapter on the correct channel can capture them in plaintext. There is no built-in encryption.
Flexible Topology
Without a router, there's no forced star topology. You can do broadcast (one-to-many), many-to-one collection, or peer-to-peer. The library doesn't enforce any topology — it just sends and receives frames.
Full Frame Access
Unlike ESP-NOW (which is a closed binary blob), you control the entire frame: MAC header, category, OUI, type byte, and payload. This is useful for learning how 802.11 works at the wire level, and for building custom protocols.
01. Architecture
GHOSTWAVES is a C++ library for the Arduino-ESP32 ecosystem. It uses
esp_wifi_80211_tx() for frame injection and promiscuous mode for reception.
The library handles frame construction, OUI filtering, and thread-safe queuing between
the Wi-Fi task and your application loop. Developed by OPT-OUT.
Transmission
Constructs a packed C struct in memory and hands it directly to
esp_wifi_80211_tx() — the ESP-IDF function that bypasses the entire Wi-Fi
stack and injects raw bytes into the radio hardware.
Reception
Promiscuous mode captures every 802.11 frame on the channel. A fast
IRAM_ATTR callback filters by frame control, category, and OUI — three
memory checks before touching the payload.
Thread Safety
The promiscuous callback runs on the Wi-Fi task. Blocking it triggers
Watchdog Timer panics. We use xQueueSendFromISR() to safely bridge the
high-priority radio context to your loop().
02. The Frame
GHOSTWAVES uses Vendor-Specific Action Frames — a legitimate 802.11
management
frame type (subtype 0x0D, frame control 0x00D0). Category 0x7F marks the
body as vendor-specific, followed by a 3-byte OUI that acts as a namespace. Our OUI is
0xBE 0xEF 0x00 — the magic bytes.
Reading the frame:
FC: 0x00D0 — "I am an Action Frame"
DEST/SRC/BSSID — Who, from whom, where
CAT: 0x7F — "I am Vendor-Specific"
OUI: BE:EF:00 — "I am a Ghost"
TYPE: 0x01-0xFF — Your custom frame type
PAYLOAD — Your data. Raw. Unfiltered.
03. Channels & Frequencies
When you call GhostWaves.begin(6), you select Wi-Fi Channel 6.
The ESP32 is a Wi-Fi chip, not a software-defined radio — you pick a channel number,
and the hardware maps it to a fixed center frequency defined by the 802.11 standard.
All nodes must be on the same channel to communicate.
| Channel | Frequency | Channel | Frequency | Channel | Frequency |
|---|---|---|---|---|---|
| 1 | 2.412 GHz | 6 | 2.437 GHz | 11 | 2.462 GHz |
| 2 | 2.417 GHz | 7 | 2.442 GHz | 12 | 2.467 GHz |
| 3 | 2.422 GHz | 8 | 2.447 GHz | 13 | 2.472 GHz |
| 4 | 2.427 GHz | 9 | 2.452 GHz | Ch 14 (Japan) not available | |
| 5 | 2.432 GHz | 10 | 2.457 GHz | ||
Overlap
Each channel is 22 MHz wide, spaced 5 MHz apart — they overlap. Channels 1, 6, and 11 are the only three that don't overlap at all. Pick one of these for best results.
Interference
Your home router sits on one of these channels. Every neighbor's router too.
Put GHOSTWAVES on a different channel to avoid noise. Or use
scanQuietestChannel() to find it automatically.
Channel Hopping
setChannel() retunes the radio instantly — no restart, no
re-initialization. Broadcast a HOP command on the current channel, then switch. The entire
swarm migrates together.
Live Channel Switch
// Start on channel 6
GhostWaves.begin(6);
// Later: scan for the quietest channel
uint8_t best = GhostWaves.scanQuietestChannel(100); // 100ms dwell per channel
Serial.printf("Quietest channel: %d\n", best);
// Retune instantly — no restart needed
GhostWaves.setChannel(best);
// Check where we are
Serial.printf("Now on channel %d\n", GhostWaves.getChannel());
Coordinated Migration
A Leader node scans, picks the best channel, broadcasts a HOP command, then switches itself. Followers listen for the HOP command and follow. See the GhostHop example for the full implementation.
// Leader: broadcast HOP command, then switch
#define GHOST_TYPE_HOP 0xF0
struct __attribute__((packed)) HopCommand {
uint8_t new_channel;
uint8_t reason;
};
HopCommand cmd = { .new_channel = best, .reason = 0 };
GhostWaves.send(broadcast, (const uint8_t*)&cmd, sizeof(cmd), GHOST_TYPE_HOP);
delay(20);
GhostWaves.setChannel(best);
// Follower: listen and follow
if (pkt.oui_type == GHOST_TYPE_HOP) {
HopCommand* cmd = (HopCommand*)pkt.payload;
GhostWaves.setChannel(cmd->new_channel);
}
04. API Reference
GHOSTWAVES exposes a singleton GhostWaves (like Serial or
Wire) with a minimal, deliberate API.
bool begin(uint8_t channel)
Initialize the radio on a Wi-Fi channel (1–13). Sets station mode, enables promiscuous capture, and creates the receive queue. All devices must share the same channel to communicate.
bool send(const uint8_t* dest_mac, const uint8_t* payload, size_t len, uint8_t oui_type)
Construct and transmit a Ghost Frame.
dest_mac is 6 bytes (use FF:FF:FF:FF:FF:FF for broadcast).
payload is your raw data (up to 230 bytes). oui_type tags the
frame type (defaults to 0x01). Returns true on success.
bool setChannel(uint8_t channel)
Retune the radio to a new channel (1–13) on the fly.
No restart, no re-initialization. Both TX and RX switch instantly.
Returns false if the channel is out of range.
uint8_t getChannel()
Returns the current channel number.
uint8_t scanQuietestChannel(uint16_t dwell_ms = 50)
Scan all 13 channels and return the one with the least
traffic. Dwells on each channel for dwell_ms milliseconds counting all packets.
Restores the original channel when done. Blocking — takes
~13 * dwell_ms total.
bool available()
Returns true if one or more Ghost Frames are
waiting in the receive queue.
bool read(GhostRxPacket* packet)
Dequeue the oldest received frame. Non-blocking —
returns false if the queue is empty.
void end()
Disable promiscuous mode and free the receive queue. Call before deep sleep or radio shutdown.
GhostRxPacket Structure
| Field | Type | Description |
|---|---|---|
| sender_mac | uint8_t[6] | MAC address of the transmitting node |
| oui_type | uint8_t | Custom frame type identifier (0x00–0xFF) |
| payload | uint8_t[230] | Raw payload data |
| length | size_t | Actual payload length in bytes |
| rssi | int8_t | Received signal strength in dBm (useful for distance estimation) |
05. Custom Frame Types
The oui_type byte is your protocol multiplexer. Define
0x01 as a heartbeat, 0x02 as sensor data, 0x20 as chat,
0x30 as relay — whatever your application needs. On receive,
GhostRxPacket.oui_type tells you exactly what kind of frame arrived.
Your Protocol, Your Rules
Think of frame types like channels within the channel. Each type can carry a completely different struct, a different meaning, a different handler. One byte gives you 256 possible message types — enough to build any protocol you can imagine.
// Define your protocol
#define GHOST_TYPE_PING 0x01
#define GHOST_TYPE_SENSOR 0x02
#define GHOST_TYPE_CMD 0x03
#define GHOST_TYPE_CHAT 0x20
#define GHOST_TYPE_RELAY 0x30
// Send with a specific type
GhostWaves.send(dest, payload, len, GHOST_TYPE_SENSOR);
// Receive and dispatch
GhostRxPacket pkt;
if (GhostWaves.read(&pkt)) {
switch (pkt.oui_type) {
case GHOST_TYPE_PING: handlePing(pkt); break;
case GHOST_TYPE_SENSOR: handleSensor(pkt); break;
case GHOST_TYPE_CMD: handleCmd(pkt); break;
}
}
Assigned Types
| Type | Name | Description |
|---|---|---|
| 0x01 | PING | Heartbeat / presence beacon. Default type. |
| 0x02 | SENSOR | Telemetry payload. Struct-packed sensor readings. |
| 0x03 | CMD | Remote command. Motor control, LED state, actuators. |
| 0x10 | SWARM | Swarm beacon. Position, intent, peer discovery. |
| 0x20 | CHAT | Text message. Nickname + body. |
| 0x30 | RELAY | Multi-hop relay frame. TTL + deduplication. |
| 0xF0 | HOP | Channel hop command. Coordinated network migration. |
| 0x40-0xEF | USER | Yours. Define whatever you need. |
06. Limitations
Bypassing the Wi-Fi stack means giving up everything it provides. These are real tradeoffs, not edge cases. Understand them before choosing GHOSTWAVES over established protocols.
No encryption.
Payloads are transmitted in plaintext. Anyone with a monitor-mode adapter on the correct channel can capture and read every byte. If you need confidentiality, you must implement your own encryption (e.g. AES over the payload before sending). The library does not do this for you.
No delivery guarantees.
There are no ACKs, no retries, no sequence numbers. If a frame collides with another transmission, gets corrupted, or the receiver isn't listening at that moment, the data is lost. You will lose packets. For many use cases (sensor telemetry, presence beacons) this is acceptable. For others (commands, critical data) you need to build your own retry logic.
Not actually invisible.
These frames won't appear on standard network tools or
routers, but they are standard 802.11 management frames. Any device in monitor mode
(Wireshark, airodump-ng, a Raspberry Pi with the right adapter) will see them.
The OUI 0xBEEF00 is not registered, which makes the frames easy to
identify.
ESP32 only.
This library depends on esp_wifi_80211_tx()
and ESP-IDF promiscuous mode APIs. It only runs on ESP32 variants (ESP32, S2, S3,
C3, C6). Other platforms with monitor-mode capability can receive Ghost Frames
but would need separate tooling.
230 byte payload limit.
Action frames have a practical maximum payload of around 230 bytes per frame. For larger data, you need to implement your own fragmentation and reassembly. This is not built into the library.
Single channel at a time.
The ESP32 radio can only be tuned to one channel. You can
switch channels with setChannel(), but during the switch you will miss
frames on the old channel and the new one. Coordinated hopping (GhostHop) mitigates
this but doesn't eliminate it.
Queue overflow under load.
The receive queue holds 10 packets. If your
loop() doesn't drain it fast enough (heavy processing, slow serial
output), incoming frames are silently dropped. In high-traffic environments
this can be a real problem.
07. Ghostwaves vs ESP-NOW
ESP-NOW is a mature, Espressif-maintained protocol that handles encryption, retries, and peer management for you. For most ESP32-to-ESP32 communication, ESP-NOW is the better choice. GHOSTWAVES only makes sense when you specifically need something ESP-NOW can't do.
| Feature | ESP-NOW | GHOSTWAVES |
|---|---|---|
| Encryption | CCMP built-in | None (bring your own) |
| Reliability | ACKs + retries | None (fire and forget) |
| Peer Management | Registration required, max 20 encrypted | None needed, no limit |
| Frame Access | Closed-source, fixed format | Full control, every byte |
| Multi-hop | Not possible | Possible (see GhostRelay) |
| Channel Hopping | Not built-in | Built-in scan + hop |
| Protocol Versioning | Not available | oui_type byte (256 types) |
| Payload | 250 bytes | 230 bytes |
| Maintenance | Espressif (battle-tested) | Community / experimental |
When to use which:
Use ESP-NOW when you need reliable point-to-point communication between a known set of ESP32 devices. It handles the hard parts (encryption, retries, peer tracking) and is maintained by the chip manufacturer.
Use GHOSTWAVES when you need unlimited broadcast receivers, custom frame formats, multi-hop relay, protocol experimentation, or when you want to understand how raw 802.11 works. Accept that you'll need to handle reliability and security yourself.
08. Examples
Example: GhostPing — Multi-Type Demo
Basic demo. Sends pings and sensor data on different frame types, receives and dispatches by type with a switch statement. Requires 2+ ESP32s on the same channel.
#include <GhostWaves.h>
#define GHOST_TYPE_PING 0x01
#define GHOST_TYPE_SENSOR 0x02
const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
unsigned long lastPing = 0;
void setup() {
Serial.begin(115200);
GhostWaves.begin(6);
Serial.println("GhostPing active on Channel 6.");
}
void loop() {
// Broadcast a ping every 2 seconds
if (millis() - lastPing > 2000) {
lastPing = millis();
const char* msg = "alive";
GhostWaves.send(broadcast, (const uint8_t*)msg, strlen(msg), GHOST_TYPE_PING);
}
// Receive and dispatch by type
if (GhostWaves.available()) {
GhostRxPacket pkt;
if (GhostWaves.read(&pkt)) {
switch (pkt.oui_type) {
case GHOST_TYPE_PING:
Serial.printf("[PING] from %02X:%02X RSSI:%d\n",
pkt.sender_mac[4], pkt.sender_mac[5], pkt.rssi);
break;
case GHOST_TYPE_SENSOR:
Serial.printf("[SENSOR] %d bytes\n", pkt.length);
break;
}
}
}
}
Example: GhostSensor — Ultra-Low-Power
Deep sleep pattern. Wakes, reads sensors, sends one frame, sleeps. Awake time is around 50ms depending on hardware. Useful for battery-powered nodes, though actual battery life depends on sleep interval, sensor draw, and board quiescent current.
#include <GhostWaves.h>
#define GHOST_TYPE_SENSOR 0x02
#define SLEEP_SECONDS 30
struct __attribute__((packed)) SensorPayload {
uint8_t node_id;
float temperature;
float humidity;
float battery_voltage;
};
const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
void setup() {
GhostWaves.begin(6);
SensorPayload data = {
.node_id = 1,
.temperature = 23.5,
.humidity = 61.2,
.battery_voltage = 3.1
};
// One shot. No handshake. No ACK needed.
GhostWaves.send(broadcast, (const uint8_t*)&data, sizeof(data), GHOST_TYPE_SENSOR);
delay(10);
GhostWaves.end();
esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
}
void loop() {
// Never reaches here.
}
Example: GhostSwarm — Decentralized Coordination
Peer discovery pattern. Each node broadcasts its state and maintains a table of nearby peers with RSSI. Peers are evicted after a timeout. No coordinator required, but also no guarantee that all peers are seen.
#include <GhostWaves.h>
#define GHOST_TYPE_SWARM 0x10
#define MAX_PEERS 20
struct __attribute__((packed)) SwarmBeacon {
uint8_t node_id;
float pos_x;
float pos_y;
uint8_t state; // 0=idle, 1=moving, 2=seeking
};
struct Peer {
uint8_t node_id;
float pos_x, pos_y;
int8_t rssi;
unsigned long last_seen;
bool active;
};
Peer peers[MAX_PEERS];
const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
void setup() {
Serial.begin(115200);
GhostWaves.begin(6);
memset(peers, 0, sizeof(peers));
}
void loop() {
// Broadcast our position every 500ms
static unsigned long lastTx = 0;
if (millis() - lastTx > 500) {
lastTx = millis();
SwarmBeacon b = { .node_id = 42, .pos_x = 1.5, .pos_y = -3.2, .state = 0 };
GhostWaves.send(broadcast, (const uint8_t*)&b, sizeof(b), GHOST_TYPE_SWARM);
}
// Receive peer beacons and update table
if (GhostWaves.available()) {
GhostRxPacket pkt;
if (GhostWaves.read(&pkt) && pkt.oui_type == GHOST_TYPE_SWARM) {
SwarmBeacon* b = (SwarmBeacon*)pkt.payload;
// Update peer table...
}
}
}
Example: GhostChat — Invisible Messenger
Serial-to-air text bridge. Type in the Serial Monitor, broadcast as a raw frame. Other GhostChat nodes on the same channel print it. Messages are unencrypted and can be captured by any monitor-mode device.
#include <GhostWaves.h>
#define GHOST_TYPE_CHAT 0x20
struct __attribute__((packed)) ChatMessage {
char nick[8];
char text[200];
};
const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
void loop() {
// Read serial input, pack into ChatMessage, send:
// GhostWaves.send(broadcast, (uint8_t*)&msg, len, GHOST_TYPE_CHAT);
// Receive and print:
if (GhostWaves.available()) {
GhostRxPacket pkt;
if (GhostWaves.read(&pkt) && pkt.oui_type == GHOST_TYPE_CHAT) {
ChatMessage* msg = (ChatMessage*)pkt.payload;
Serial.printf("[%s] %s\n", msg->nick, msg->text);
}
}
}
Example: GhostRelay — Multi-Hop Mesh
Multi-hop relay with TTL and deduplication. A message propagates from node to node, decrementing its TTL at each hop. Duplicate messages are dropped via a ring buffer. This is a basic flooding approach — not a proper mesh with routing, but a starting point for extending range.
#include <GhostWaves.h>
#define GHOST_TYPE_RELAY 0x30
#define MAX_TTL 5
struct __attribute__((packed)) RelayFrame {
uint16_t msg_id;
uint8_t origin_mac[6];
uint8_t ttl;
uint8_t hop_count;
char payload[180];
};
// On receive: check if already seen (ring buffer), print, decrement TTL, rebroadcast.
// Messages ripple outward like waves on water.
// Each node is both receiver and transmitter.
// The network has no center.
Example: GhostHop — Channel Hopping
Coordinated channel migration. A Leader node scans all 13 channels, picks the quietest one, broadcasts a HOP command, and switches. Followers listen and follow. HOP commands are sent 3x for redundancy since there are no ACKs. Followers that miss the command will be stranded on the old channel.
#include <GhostWaves.h>
#define GHOST_TYPE_HOP 0xF0
#define HOP_INTERVAL 30000 // Scan every 30s
struct __attribute__((packed)) HopCommand {
uint8_t new_channel;
uint8_t reason;
};
void loop() {
// LEADER: periodic scan and migrate
if (millis() - lastHop > HOP_INTERVAL) {
lastHop = millis();
uint8_t best = GhostWaves.scanQuietestChannel(100);
if (best != GhostWaves.getChannel()) {
// Broadcast HOP command 3x for reliability
HopCommand cmd = { .new_channel = best, .reason = 0 };
for (int i = 0; i < 3; i++) {
GhostWaves.send(broadcast, (const uint8_t*)&cmd, sizeof(cmd), GHOST_TYPE_HOP);
delay(5);
}
delay(20);
GhostWaves.setChannel(best);
}
}
// FOLLOWER: listen for HOP commands and follow
if (GhostWaves.available()) {
GhostRxPacket pkt;
if (GhostWaves.read(&pkt) && pkt.oui_type == GHOST_TYPE_HOP) {
HopCommand* cmd = (HopCommand*)pkt.payload;
GhostWaves.setChannel(cmd->new_channel);
}
}
}
09. Use Cases & Roadmap
Where It Makes Sense
GHOSTWAVES is suited for scenarios where the Wi-Fi connection overhead is the bottleneck, where you need broadcast to many receivers, or where you want to define your own protocol. It's not suited for anything requiring reliability, security, or interoperability with non-ESP32 devices (without custom tooling).
Art Installations
Multiple ESP32 nodes coordinating without infrastructure. Packet loss is tolerable when the output is visual/audio and the data is continuous (sensor streams, presence beacons). The lack of setup makes rapid deployment practical.
Prototyping & Education
Understanding how 802.11 works at the frame level. The library exposes every byte of the frame construction and reception process. Useful for teaching networking concepts, radio fundamentals, and embedded protocol design.
Sensor Collection
Many battery-powered nodes sending readings to one receiver. The deep-sleep pattern minimizes awake time. Data loss is acceptable if readings are periodic (a missed temperature reading every few minutes is fine).
Live Performance
Wearable ESP32s with accelerometers sending gesture data. Low latency matters more than guaranteed delivery. Some dropped frames are imperceptible when data refreshes at 50Hz+. Bluetooth pairing overhead is avoided.
Peer Discovery
Nodes that need to find each other without a coordinator. The swarm pattern (broadcast presence, build a peer table from RSSI) works well for local-area awareness. RSSI gives rough distance estimation, not precise positioning.
Protocol Experimentation
Building and testing custom communication protocols. The type byte gives you 256 message types. You define the payload format, the routing rules, the reliability layer (or lack of one). The library is a foundation, not a finished product.
Roadmap
These are features that would address the current limitations. Some are straightforward, others are significant engineering efforts. No timeline — contributions welcome.
Encryption Layer
AES-128 or ChaCha20 over the payload. Bring-your-own-crypto.
Mesh Routing
Evolve GhostRelay into a proper mesh with route tables and path optimization.
Channel Hopping DONE
setChannel(), getChannel(), scanQuietestChannel() — frequency-agile communication with coordinated network migration.
Acknowledgement Protocol
Optional reliability layer for when fire-and-forget isn't enough.
Cross-Platform Receivers
Any device with monitor-mode capability (Linux + rtl8812au, Raspberry Pi) can receive Ghost Frames.
Frame Type Registry
A community-maintained list of oui_type assignments for interoperability.
GHOSTWAVES is a low-level tool. It gives you raw frame access and gets out of the way. What you build with it — and whether it's the right tool for your project — is up to you.