GHOSTWAVES

OPT-OUT

Brussels 2026

Bare-Metal 802.11 Frame Injection Library for ESP32

Build v1.1.0 Custom Frame Types Channel Hopping Connectionless
Download Latest Release GitHub

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.

NODE A
GhostWaves
RAW 802.11 ≈≈≈≈≈≈≈> Action Frames
NODE B SENSOR
NODE C RELAY
NODE N SWARM

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.

FC2B
DUR2B
DEST MAC6B
SRC MAC6B
BSSID6B
SEQ2B
CAT1B
OUI3B
TYPE1B
YOUR PAYLOAD0-230B

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.

01

Encryption Layer

AES-128 or ChaCha20 over the payload. Bring-your-own-crypto.

02

Mesh Routing

Evolve GhostRelay into a proper mesh with route tables and path optimization.

03

Channel Hopping DONE

setChannel(), getChannel(), scanQuietestChannel() — frequency-agile communication with coordinated network migration.

04

Acknowledgement Protocol

Optional reliability layer for when fire-and-forget isn't enough.

05

Cross-Platform Receivers

Any device with monitor-mode capability (Linux + rtl8812au, Raspberry Pi) can receive Ghost Frames.

06

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.