What It Does
How a $5 chip and a magnet replace a trip across the shop.
You're in a woodshop. You flip on the table saw. Within a fraction of a second — before sawdust even hits the air — the dust collector across the room roars to life.
No buttons. No apps. No WiFi network. How?
Fine sawdust is an invisible hazard. It causes respiratory disease, and suspended particles are an explosion risk. Every second of unfiltered air counts. Manually running to toggle a dust collector every time you switch tools? Nobody does it consistently.
Fine dust particles hang in the air for hours and settle in your lungs. Some hardwoods are toxic. The collector needs to be on before you cut.
The dust collector sits across the shop. Walking over to flip it on and off for every cut breaks your flow and wastes time.
What if the system just knew? You turn on a tool, the dust collector fires up. You turn it off, the collector shuts down. Zero effort.
How It Works: The 6-Step Journey
Think of it like air traffic control. One central receiver (the tower) coordinates multiple transmitters (the aircraft). When any plane needs the runway, the tower keeps it active.
A small magnet attached to the tool's power switch moves, and a reed switch mounted nearby detects the change.
An ESP32 microcontroller wired to the reed switch notices the state change and prepares a message.
Using ESP-NOW, the transmitter fires a tiny message (just 13 bytes — smaller than a text message) directly to the receiver over radio.
A second ESP32 (the receiver) is always listening. It decodes the message and decides what to do.
It sends a brief electrical pulse through an optoisolator to safely flip the dust collector's power relay on.
The reed switch detects the magnet moved back. The transmitter sends another message. The receiver waits 4 seconds (in case you're just switching tools), then turns the collector off.
The entire round trip — magnet moves, chip detects, radio sends, receiver acts — takes less than 1 millisecond. That's faster than the blink of an eye (which takes 300 milliseconds).
The Hardware: Four Key Components
The entire system uses cheap, simple parts. Here's what's physically in the box.
A $5 microcontroller the size of a postage stamp. It runs the code, reads sensors, and sends/receives radio messages. One in each transmitter, one in the receiver.
A tiny glass tube with two metal contacts inside. When a magnet is nearby, the contacts touch (circuit closed). Magnet moves away, contacts separate (circuit open). That's how the transmitter knows a tool is on or off.
A safety bridge between the tiny ESP32 (3.3 volts) and the dust collector's power relay (120/240 volts). It uses an internal LED and light sensor so the two circuits never physically touch.
A tiny 128×64 pixel OLED screen on the receiver. It shows which transmitters are connected, their current state, and system status. Optional but helpful for debugging.
The entire system — two ESP32 boards, a reed switch, magnet, optoisolator, and OLED display — costs under $25 in parts. A commercial wireless dust collection system costs $200+.
The Codebase Map
The software that runs on these chips lives in a single project with a clean structure. Here's the bird's-eye view.
The transmitter and receiver live in the same project but are compiled separately. They share protocol.h so they always speak the same language — like two departments using the same form template.
You have three woodworking tools in your shop — a table saw, a band saw, and a router. Each has its own transmitter. You turn on the table saw, start cutting, then switch to the band saw without turning off the table saw first.
What does the dust collector do when you turn on the band saw while the table saw is still running?
Meet the Cast
Every file has a position. They share a playbook.
Think of this codebase like a sports team roster. Each file plays a specific position, and they all follow the same playbook — protocol.h.
The Playbook — 46 lines. Defines every message format, timing constant, and state code. Both the transmitter and receiver include this file, guaranteeing they speak the same language.
The Scout — 230 lines. Detects when a tool turns on or off via the reed switch, then sends radio alerts to the receiver.
The Commander — 322 lines. Listens for messages from all transmitters, decides when to turn the dust collector on or off, and manages the peer roster.
The Scoreboard — 150 lines. Draws status information on the tiny OLED screen: which transmitters are connected, their states, and system health.
The Roster — 13 lines. A tiny struct that defines how the receiver tracks each connected transmitter: its address, last message number, state, and whether it's still alive.
The Rulebooks — Each device (transmitter and receiver) has its own config file with pin assignments, channel settings, and hardware-specific constants.
How They're Connected: The Build Process
All these files live in one project, but the transmitter and receiver are built separately. PlatformIO reads a config file and decides which code to compile for which device.
Here's what the actual platformio.ini looks like. It defines two separate environments — one for the transmitter, one for the receiver.
[env:transmitter]
build_src_filter = +<transmitter/src/>
build_flags = ${env.build_flags} -Itransmitter/include
[env:receiver]
build_src_filter = +<receiver/src/>
lib_deps = adafruit/Adafruit SSD1306@^2.5.7
The Config Files: Hardware Wiring Docs
Each device has a config.h file that maps physical wiring to named constants. Think of it as the blueprint that says "the LED is connected to pin 7."
static const char TRANSMITTER_NAME[] = "transmitter_1";
static constexpr uint8_t ESPNOW_CHANNEL = 1;
static constexpr int PIN_LED_CLOSED = 7;
static constexpr int PIN_LED_OPEN = 8;
Now the receiver's config file — it has more settings because it manages the display and multiple inputs.
static constexpr uint8_t ESPNOW_CHANNEL = 1;
static constexpr int OLED_SDA_PIN = 6;
static constexpr int OLED_SCL_PIN = 5;
static constexpr uint32_t DISPLAY_REFRESH_MS = 250;
static constexpr uint32_t DISPLAY_SCROLL_MS = 3000;
You could write digitalRead(7) everywhere in your code. But what happens when you rewire the LED to a different pin? You'd have to find and change every 7. With a constant like PIN_LED_CLOSED, you change it in one place and the whole program updates. This is a universal software pattern called "Don't Repeat Yourself" (DRY).
The Peer Tracker: peers.h
The receiver needs to track multiple transmitters at once. peers.h defines the "roster card" for each connected device.
constexpr int MAX_PEERS = 32;
struct PeerEntry {
uint8_t mac[6];
uint32_t seq;
uint8_t state;
uint32_t last_seen;
bool used;
};
Most workshops have 3-6 tools. So why reserve space for 32? Two reasons: (1) memory is cheap on an ESP32, and (2) over-provisioning means you never have to worry about running out of slots. It's like a restaurant having 100 reservation slots even though they've never had more than 50 guests — it costs nothing and prevents edge-case failures.
A transmitter sends a message, but the receiver already has 32 transmitters in its roster. All 32 slots are occupied.
What happens if a 33rd transmitter tries to join when the roster is full?
The Shared Language
46 lines of code that both sides agree on — before a single message is sent.
Two devices need to talk wirelessly. Without a shared format, it's like shouting across a room in different languages — lots of noise, zero understanding.
protocol.h is the Rosetta Stone of this system. Both the transmitter and receiver include this one file. It defines exactly what a message looks like, what each field means, and how fast things should happen.
The transmitter sends "TOOL ON" as text. The receiver expects a number. Nothing works. Every change on one side breaks the other side.
Both sides include the same file. They agree on the exact struct layout, the same message types, the same timing. Change it in one place, both sides update.
Every international traveler fills out the same form. Name goes in box 1, passport number in box 2, country in box 3. The border agent doesn't have to guess where to look — every card is identical. protocol.h is that form for radio messages.
The Message Types
Before defining the message itself, the protocol lists every kind of message that can be sent. This is done with an enum.
enum MsgType : uint8_t {
UPDATE = 1,
QUERY = 2,
REPLY = 3,
ACK = 4,
HEARTBEAT = 5,
JOIN = 6,
LEAVE = 7
};
Each message type serves a specific purpose. Here's the roster:
The workhorse. Sent every time a tool changes state. "My table saw just turned ON" or "My table saw just turned OFF."
A polite question-and-answer pair. "Hey, what's your current state?" → "I'm currently ON." Used for status checks.
A receipt. "Got your UPDATE, confirmed." Without ACKs, the sender wouldn't know if its message was received or lost.
A periodic "I'm still here" ping. If the receiver stops getting heartbeats, it knows the transmitter went offline.
Arrival and departure announcements. "I'm new, add me to the roster" and "I'm shutting down, remove me."
The 13-Byte Message
This is the deep dive. Every single message sent between devices fits into this tiny structure — just 13 bytes total.
struct __attribute__((packed)) Msg {
uint8_t version; // 1 byte
uint8_t type; // 1 byte
uint8_t sender_mac[6]; // 6 bytes
uint32_t seq; // 4 bytes
uint8_t state; // 1 byte
};
A typical text message is 140+ bytes. An HTTP web request is 500+ bytes. An email header alone can be 2,000 bytes. This message packs everything the receiver needs to know — who sent it, what kind it is, which message number, and what state the tool is in — into just 13 bytes. That's smaller than the word "extraordinary."
Compilers like to add invisible padding between struct fields for performance reasons (called alignment). The __attribute__((packed)) tells the compiler: "Don't do that. I need these 13 bytes to be exactly 13 bytes, with no gaps, because the device on the other end will read them byte-by-byte in this exact order."
Protocol version (currently 1)
Message type (UPDATE=1, QUERY=2, etc.)
6-byte hardware address of the sender
4-byte sequence number (counts up with each message)
Tool state: 0 = closed/off, 1 = open/on
The Timing Constants
The protocol doesn't just define what gets sent — it defines when and how fast. These constants control the system's heartbeat.
constexpr uint32_t DEBOUNCE_MS = 20;
constexpr uint32_t ACK_TIMEOUT_MS = 300;
constexpr uint32_t INITIAL_RETRY_MS = 200;
constexpr uint8_t MAX_RETRIES = 4;
constexpr uint32_t TRANSMITTER_TTL_S = 60;
constexpr uint32_t BEACON_INTERVAL_MS = 2000;
constexpr uint32_t HEARTBEAT_INTERVAL_MS = 15000;
Pin Definitions
The last section of protocol.h maps physical GPIO pin numbers to named constants. These are the pins that are physically wired to components on the receiver board.
constexpr int PIN_RECEIVER_ON = 10;
constexpr int PIN_RECEIVER_OFF = 8;
constexpr int PIN_TX_A = 2;
constexpr int PIN_TX_B = 4;
Notice that turning the dust collector on and off use different GPIO pins (10 and 8). That's because the optoisolator design uses momentary pulses — a brief flash on pin 10 turns the collector ON, a brief flash on pin 8 turns it OFF. This is a safety feature: if the ESP32 crashes or loses power, the pins go low and the dust collector stays in its current state rather than going haywire. Each pin connects to a separate optoisolator channel.
This entire file — the message types, the message struct, all the timing constants, and the pin definitions — fits in 46 lines. Yet it's the single most important file in the project. Every other file depends on it. If you change anything here, both the transmitter and receiver automatically get the update the next time they're compiled.
You're adding a new message type called EMERGENCY (value 8) that would tell the receiver to immediately shut everything down. You also need the receiver to respond within 100ms instead of the usual 300ms.
Which file(s) would you need to modify to add this new message type and its faster timeout?
The Transmitter
A tiny field reporter stationed at every tool — watching, reporting, and dealing with bad phone lines.
Each transmitter is a tiny field reporter stationed at one tool. Its job is simple but critical: watch for changes, and report them reliably. It doesn't make decisions about the dust collector — it just files reports to headquarters (the receiver) and trusts HQ to act on them.
The transmitter has exactly two responsibilities:
Read the reed switch to figure out if the tool is ON or OFF. Filter out false readings from mechanical bounce.
Transmit state changes via ESP-NOW radio. Retry if the message doesn't get acknowledged. Give up and re-discover the receiver if it seems to have disappeared.
A field reporter doesn't decide whether to mobilize troops — they just observe what's happening on the ground and file accurate reports back to headquarters. If the phone line goes dead, they find a new way to call in. That's exactly what the transmitter does: observe, report, and keep the line open.
Reading the Reed Switch
The reed switch has two contacts: NC (Normally Closed) and NO (Normally Open). The ESP32 reads both contacts and uses their combination to figure out the tool's state.
int a = digitalRead(PIN_A);
int b = digitalRead(PIN_B);
// NC closed (LOW) → ON; NO closed (LOW) → OFF.
int logicalOn = -1;
if (a == LOW && b == HIGH) logicalOn = 1;
else if (a == HIGH && b == LOW) logicalOn = 0;
You might expect "closed circuit = HIGH voltage." But these pins use pull-up resistors. When the switch closes, it connects the pin directly to ground, pulling the voltage down to LOW. So LOW = closed = contact is touching. It's backwards from what you'd guess, but it's the standard way to wire switches to microcontrollers.
The two contacts give us three possible states:
NC closed (LOW), NO open (HIGH). The magnet is near the switch. The tool is running. logicalOn = 1
NC open (HIGH), NO closed (LOW). The magnet moved away. The tool is stopped. logicalOn = 0
Both open or both closed. Something weird is happening — the switch might be mid-transition. logicalOn = -1 (ignore this reading)
Debouncing: Ignoring the Noise
Mechanical switches don't flip cleanly. When the metal contacts come together, they bounce — rapidly opening and closing for a few milliseconds before settling. Without debouncing, the code would see a single switch flip as a rapid fire of ON-OFF-ON-OFF events.
if (millis() - lastSampleMs >= 10) {
lastSampleMs = millis();
if (logicalOn != -1) {
if (lastState == -1) { lastState = logicalOn; updateLEDs(lastState); }
if (logicalOn == lastState) stableCount = 0;
else stableCount++;
if (stableCount >= 3) {
lastState = logicalOn;
stableCount = 0;
seqNo++;
updateLEDs(lastState);
stateChanged = true;
}
}
}
Here's the process as a step-by-step timeline:
The code checks the reed switch no more than once every 10 milliseconds. This prevents reading mid-bounce.
If the new reading matches what we already believe, reset the counter to zero — nothing interesting happened.
If the reading differs from the current state, increment a counter. One disagreement could be noise. Two could be a coincidence.
Three consecutive different readings? Now we believe the switch genuinely changed. Update the state, bump the sequence number, and flag for sending.
That's how long the code waits to be sure the switch actually changed, not just bounced. 30 milliseconds is imperceptible to a human but long enough to filter out mechanical noise. A typical switch bounce lasts 1–5ms, so 30ms gives a very comfortable safety margin.
Sending with Retry
Radio isn't reliable. Messages get lost, interference happens, and the receiver might be momentarily busy. The transmitter can't just fire-and-forget — it needs to confirm delivery. This function sends a message and waits for an ACK. If no ACK arrives, it retries with increasing delays.
bool sendUpdateWithRetry(const Protocol::Msg &m) {
ackReceived = false;
uint8_t attempt = 0;
uint32_t retryDelay = Protocol::INITIAL_RETRY_MS;
while (attempt < Protocol::MAX_RETRIES) {
esp_now_send(receiverMac, (const uint8_t *)&m, sizeof(m));
uint32_t t0 = millis();
while (millis() - t0 < Protocol::ACK_TIMEOUT_MS) {
if (ackReceived && ackSeq == m.seq) return true;
delay(5);
}
if (ackReceived && ackSeq == m.seq) return true;
attempt++;
delay(retryDelay);
retryDelay = min<uint32_t>(retryDelay * 2, 2000);
}
return false;
}
If the receiver is temporarily overwhelmed, hammering it with retries every 200ms would make things worse — like calling someone back immediately every time they don't pick up. By doubling the wait each time (200ms, 400ms, 800ms, 1600ms), you give the receiver breathing room to recover. This pattern is used everywhere in networking — your web browser does the same thing when a website is down.
When the Receiver Disappears
What happens when the transmitter keeps sending messages but never gets an ACK? Maybe the receiver lost power, got unplugged, or moved out of radio range. The transmitter needs to recognize this and recover.
void recordSendResult(bool acked) {
if (acked) { consecFailures = 0; return; }
consecFailures++;
Serial.printf("Send failed, consecutive=%d\n", consecFailures);
if (consecFailures >= Protocol::MAX_CONSEC_FAILURES) {
Serial.println("Lost receiver — re-entering discovery");
esp_now_del_peer(receiverMac);
paired = false;
consecFailures = 0;
}
}
Notice there's no "error screen" or "call tech support" path. If the receiver disappears, the transmitter automatically drops back to discovery mode and starts listening for beacons again. When the receiver comes back (or a new one appears), it re-pairs automatically. No human intervention needed.
Check Your Understanding
Your transmitter has been working fine for hours, reliably reporting tool state changes. Suddenly, 3 messages in a row fail to get ACKed (each message tried all 4 retry attempts before giving up).
What happens next?
The Receiver
One brain tracking every tool, making one decision: on or off.
The receiver is the brain of the operation. While each transmitter only worries about one tool, the receiver must track ALL tools and make a single decision: should the dust collector be on or off?
Think of it like an air traffic controller. Multiple planes (transmitters) are in the sky, each reporting their status. The controller tracks them all on a board, and decides when the runway (dust collector) needs to be active. One plane in the air? Runway stays open. All planes landed? Runway can close.
Maintain a table of up to 32 transmitters — their MAC address, current state, last message number, and when they last checked in.
Apply simple OR logic: if any transmitter says ON, the dust collector stays on. It only turns off when every transmitter reports OFF (or has gone silent).
Send brief electrical pulses through an optoisolator to turn the dust collector on or off. Manage timing carefully to prevent thrashing.
Every 2 seconds, shout "I'm here!" on the broadcast channel so any transmitter in range can find and pair with the receiver automatically.
Peer Tracking: The Roster Board
The receiver keeps a table of every transmitter it has heard from — like an air traffic controller's flight board. Each slot in the table holds one transmitter's info. When a new transmitter checks in, the receiver either finds its existing slot or assigns it a new one.
int findPeer(const uint8_t *mac) {
for (int i = 0; i < MAX_PEERS; i++)
if (peers[i].used && memcmp(peers[i].mac, mac, 6) == 0) return i;
return -1;
}
int addOrFindPeer(const uint8_t *mac) {
int idx = findPeer(mac);
if (idx >= 0) return idx;
for (int i = 0; i < MAX_PEERS; i++) {
if (!peers[i].used) {
memcpy(peers[i].mac, mac, 6);
peers[i].seq = 0;
peers[i].state = Protocol::State::UNKNOWN;
peers[i].last_seen = millis();
peers[i].used = true;
return i;
}
}
return -1;
}
On a regular computer, you'd use a dynamic list that expands as needed. But on a microcontroller with limited memory, allocating memory at runtime is risky — it can fragment memory and cause crashes. A fixed-size array of 32 slots is predictable, fast, and uses a known amount of memory from the start.
The OR Logic: One "Yes" Wins
This is the single most important function in the receiver. It answers one question: should the dust collector be on right now?
bool computeOverallOn() {
uint32_t now = millis();
for (int i = 0; i < MAX_PEERS; i++) {
if (!peers[i].used) continue;
if ((now - peers[i].last_seen) > Protocol::TRANSMITTER_TTL_S * 1000UL) continue;
if (peers[i].state == Protocol::State::ON) return true;
}
return false;
}
This is like a voting system where a single "yes" overrides any number of "no" votes. If you have 10 transmitters and 9 say OFF but 1 says ON, the dust collector stays on. It only turns off when every single active transmitter reports OFF. This is exactly the right logic for safety — you never want the collector to shut down while any tool is still running.
There's a subtle but critical detail here: the TTL check. A transmitter that has gone silent (crashed, lost power, moved out of range) gets automatically excluded from the vote after 60 seconds. Without TTL, a transmitter that crashed while reporting ON would keep the dust collector running forever.
Loop through all 32 peer slots.
Ignore unused slots and transmitters that haven't checked in within 60 seconds.
The moment we find any active transmitter reporting ON, return true immediately. No need to check the rest.
If we check every slot and nobody is ON, return false. The dust collector can turn off.
The Output State Machine
The receiver doesn't just flip a switch. It uses a state machine with four states to control the output pins carefully. Two pins drive two separate optoisolator channels — one for ON, one for OFF.
enum class OutState : uint8_t {
IDLE, // both pins LOW, waiting
PULSING_ON, // GPIO10 HIGH, pulse in progress
OFF_WAITING, // 4s delay before OFF pulse
PULSING_OFF, // GPIO8 HIGH, pulse in progress
};
Here's how the states flow:
A tool turns on. The receiver sends a 200ms electrical pulse on GPIO pin 10. The optoisolator fires, the dust collector starts.
After 200ms, the pulse ends. Pin 10 goes LOW. Back to idle — the dust collector stays running on its own.
All tools report OFF. But instead of immediately killing the dust collector, start a 4-second timer. This prevents thrashing when you're switching between tools.
4 seconds passed and still no tool is ON? OK, now send a 200ms pulse on GPIO pin 8 to turn the dust collector off.
Pulse done. Pin 8 goes LOW. System is idle again, waiting for the next tool to start.
switch (outState) {
case OutState::PULSING_ON:
if (now - outTimerMs >= PULSE_MS) {
digitalWrite(PIN_ON, LOW);
outState = OutState::IDLE;
Serial.println("ON pulse done");
}
break;
case OutState::OFF_WAITING:
if (now - outTimerMs >= OFF_DELAY_MS) {
lastKnownOn = false;
outState = OutState::PULSING_OFF;
outTimerMs = now;
digitalWrite(PIN_OFF, HIGH);
Serial.println("OFF pulse start");
}
break;
case OutState::PULSING_OFF:
if (now - outTimerMs >= PULSE_MS) {
digitalWrite(PIN_OFF, LOW);
outState = OutState::IDLE;
Serial.println("OFF pulse done");
}
break;
case OutState::IDLE:
default:
break;
}
Imagine you turn off the table saw and immediately turn on the router. Without the delay, the dust collector would turn off (200ms pulse), then immediately turn back on (another 200ms pulse). That rapid cycling — called thrashing — is hard on the dust collector's motor and relay. The 4-second buffer absorbs normal tool-switching behavior.
Handling Incoming Messages
When a message arrives from a transmitter, the receiver doesn't blindly accept it. It checks the sequence number to classify the message into one of three categories.
if (msg.type == Protocol::MsgType::UPDATE) {
int idx = addOrFindPeer(msg.sender_mac);
if (idx >= 0) {
if (msg.seq > peers[idx].seq) {
peers[idx].seq = msg.seq;
peers[idx].state = msg.state;
peers[idx].last_seen = millis();
sendAckTo(mac, msg.seq);
targetState = computeOverallOn() ? Protocol::State::ON
: Protocol::State::OFF;
} else if (msg.seq == peers[idx].seq) {
// Heartbeat resend — refresh TTL, ACK it
peers[idx].last_seen = millis();
sendAckTo(mac, msg.seq);
} else {
// Old / duplicate
sendAckTo(mac, peers[idx].seq);
}
}
}
Even when the receiver gets an outdated duplicate, it sends an ACK. Why? Because the transmitter is stuck in a retry loop, waiting for confirmation. If we ignore the old message, the transmitter will keep retrying until it exhausts all attempts. Sending an ACK costs almost nothing and stops the transmitter from wasting energy on retries.
Boot Safety: The First Four Lines
Before the receiver does anything else — before connecting to the radio, before initializing the display, before checking for transmitters — it does this:
// Drive both output pins LOW immediately — before anything else.
// This prevents GPIO8 (OFF opto) from floating HIGH during boot.
pinMode(PIN_ON, OUTPUT);
pinMode(PIN_OFF, OUTPUT);
digitalWrite(PIN_ON, LOW);
digitalWrite(PIN_OFF, LOW);
When the ESP32 first powers on, its GPIO pins are in an undefined state for a few milliseconds before the code starts running. Pin 8 (the OFF optoisolator) has a tendency to float HIGH on this particular chip. If that happens, the optoisolator fires and sends an OFF signal to the dust collector before the code even starts. These four lines are the very first thing in setup(), pinning both outputs LOW before anything can go wrong.
This is a textbook example of defensive programming. The developer didn't discover this problem in a manual — they found it by testing. The ESP32-S3 datasheet doesn't warn about GPIO8 floating HIGH. But the real hardware does it. So the first four lines of code exist purely to slam the door shut on a hardware quirk that could cause unexpected behavior. Always test on real hardware, not just in theory.
Check Your Understanding
You have 3 transmitters in your shop. Transmitter A (table saw) reports ON. Transmitter B (band saw) reports OFF. Transmitter C (router) hasn't sent any message in 2 minutes.
What does the dust collector do?
Finding Each Other
No config files, no IP addresses, no pairing button. They find each other over radio.
Before a transmitter can report tool status, it needs to find the receiver. There's no configuration file, no IP address, no pairing button. The devices find each other automatically over radio.
Think of it like lost hikers with flares. The receiver periodically fires a flare (a beacon) into the sky. Any transmitter that sees the flare knows where to send its reports. If a transmitter stops seeing flares, it assumes the base camp moved and starts looking for flares again.
The receiver broadcasts a HEARTBEAT message every 2 seconds to the broadcast address. It's a flare that says "I'm here, talk to me."
An unpaired transmitter listens for beacon messages. When it hears one, it saves the receiver's MAC address and starts sending updates to it.
Every 15 seconds, the transmitter re-sends its current state as a heartbeat. This refreshes the receiver's TTL timer so it knows the transmitter is still out there.
If contact is lost, the transmitter drops the receiver and starts listening for beacons again. When it hears one, it re-pairs automatically. No human needed.
Beacon Broadcasting
Every 2 seconds, the receiver broadcasts a HEARTBEAT message to the special broadcast address (FF:FF:FF:FF:FF:FF). This address means "everyone" — any device in radio range will hear it. It's like shouting "I'm here!" into the void.
void sendBeacon() {
Protocol::Msg beacon{};
beacon.version = Protocol::VERSION;
beacon.type = Protocol::MsgType::HEARTBEAT;
beacon.seq = 0;
beacon.state = lastKnownOn ? Protocol::State::ON : Protocol::State::OFF;
memcpy(beacon.sender_mac, myMac, 6);
esp_now_send(BROADCAST_MAC, (uint8_t *)&beacon, sizeof(beacon));
}
Two seconds is a sweet spot. Fast enough that a new transmitter will discover the receiver quickly (worst case, it waits 2 seconds). Slow enough that it doesn't flood the airwaves with beacon traffic. Remember, ESP-NOW messages are tiny (13 bytes), so sending one every 2 seconds uses negligible bandwidth.
Discovery: Hearing the Flare
On the transmitter side, discovery happens inside the receive callback — the function that runs whenever a radio message arrives. If the transmitter isn't paired yet and it hears a HEARTBEAT, it knows it found the receiver.
// Discovery: receiver beacon
if (msg.type == Protocol::MsgType::HEARTBEAT && !paired) {
memcpy(receiverMac, mac, 6);
if (!esp_now_is_peer_exist(mac)) {
esp_now_peer_info_t info = {};
memcpy(info.peer_addr, mac, 6);
info.channel = ESPNOW_CHANNEL;
info.encrypt = false;
esp_now_add_peer(&info);
}
paired = true;
Serial.printf("Discovered receiver: %02X:%02X:%02X:%02X:%02X:%02X\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
return;
}
Here's the full discovery process as step-by-step:
paired = false. The transmitter doesn't know the receiver's address yet. It's listening for any incoming radio message.
Every 2 seconds, the receiver sends a HEARTBEAT to FF:FF:FF:FF:FF:FF. This goes to every ESP-NOW device in range.
The receive callback fires. It sees a HEARTBEAT message and paired is false — this is the receiver we've been looking for!
The transmitter copies the receiver's MAC address and registers it as an ESP-NOW peer. Now it has a direct line to the receiver.
Discovery is complete. From now on, the transmitter ignores beacons and starts sending tool-state updates to the receiver.
Heartbeat Keep-Alive
Discovery gets the transmitter connected. But the receiver has a 60-second TTL timer — if it doesn't hear from a transmitter for 60 seconds, it treats it as dead. What if the tool hasn't changed state in a while? The transmitter needs to periodically say "I'm still here."
if (lastState != -1 &&
millis() - lastHeartbeat >= Protocol::HEARTBEAT_INTERVAL_MS) {
lastHeartbeat = millis();
Protocol::State s = (lastState == 1) ? Protocol::State::ON
: Protocol::State::OFF;
Protocol::Msg m = buildUpdate(seqNo, s);
bool acked = sendUpdateWithRetry(m);
recordSendResult(acked);
if (acked) Serial.printf("Heartbeat seq=%u (ACKed)\n", seqNo);
}
The heartbeat fires every 15 seconds. The receiver's TTL expires after 60 seconds. That means the transmitter gets 4 chances to check in before the receiver gives up on it. Even if one or two heartbeats are lost to radio interference, the connection survives. This generous margin (4x) makes the system resilient to occasional message loss.
When Contact Is Lost
What happens when something goes wrong? Maybe the receiver reboots, or a transmitter moves out of range, or there's a power outage. The system is designed to self-heal without any human intervention. Here's the full disconnect-and-reconnect lifecycle:
The transmitter sends an UPDATE or heartbeat, but no ACK comes back after all 4 retry attempts. consecFailures increments.
After 3 failed sends in a row, the transmitter calls esp_now_del_peer(), sets paired = false, and re-enters discovery mode.
On the receiver side, 60 seconds pass with no heartbeat from the transmitter. computeOverallOn() starts skipping that transmitter in its calculations.
The receiver doesn't care about the disconnect. It keeps sending beacons every 2 seconds, as always.
When the transmitter is back in range (or the receiver reboots), it hears a beacon, saves the address, registers the peer, and sets paired = true. Connection restored.
Notice there's no special "reconnect" or "re-pair" message type. The transmitter just discovers the receiver again using the exact same beacon mechanism as the very first connection. The receiver treats it like any new transmitter — adds it to the peer table and starts tracking its state. The system doesn't distinguish between "first time connecting" and "reconnecting after a failure." Simplicity is a feature.
Check Your Understanding
Your workshop is running smoothly: 2 transmitters are paired with the receiver, reporting tool states. Suddenly, the receiver loses power and reboots. It comes back up 5 seconds later and starts broadcasting beacons again. Both transmitters were paired with the old receiver instance.
What happens to the two transmitters?
The Display
128 pixels wide. 64 pixels tall. Everything you need to know at a glance.
The OLED display is the only human-visible part of the receiver. It doesn't make decisions, doesn't control anything, doesn't affect how the system works. It's purely a scoreboard — like the big board at a stadium that doesn't play the game but tells everyone in the stands what's happening.
128 pixels wide, 64 pixels tall — barely enough to show a few lines of text. But it tells you everything:
Shows SYS: ON or SYS: OFF, plus the current output phase (PULSE, WAIT) if the state machine is active. The top-level answer: is the dust collector running?
Shows Gates:3/5 ON:1 — meaning 3 of 5 registered transmitters are alive, and 1 of those 3 has its tool running. A quick health check at a glance.
A thin horizontal line across the screen, visually separating the summary header from the gate list below. Small detail, big readability improvement.
Up to 4 gates per page, each showing: gate number, ON/OFF/--- status, and how many seconds since the last message. Example: #1 ON 3s.
When there are more than 4 gates, the display auto-scrolls through pages every 3 seconds. A small pg 1/3 in the corner tells you which page you're seeing.
Initialization: Wiring Up the Screen
Before the display can show anything, the receiver needs to establish a connection to it. The OLED screen communicates over I2C, a two-wire protocol that uses a data line (SDA) and a clock line (SCL).
void Display::init() {
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
displayOk = oled.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDR);
if (!displayOk) {
Serial.println("OLED not found - display disabled");
return;
}
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(16, 20);
oled.print("Blast Gate RX");
oled.setCursor(28, 36);
oled.print("Starting...");
oled.display();
}
displayOk becomes true. If not, it becomes false."Notice the displayOk flag. If the OLED is disconnected, broken, or simply not installed, the receiver keeps working perfectly. Every call to Display::update() starts with if (!displayOk) return; — silently skipping all display logic. The dust collector doesn't care about the scoreboard. This is intentional: the display is a convenience, not a dependency.
The Update Loop: Counting Who's Alive
Every 250 milliseconds, the receiver calls Display::update(). The first thing the display does is walk through the entire peer table and count three things: how many transmitters exist, how many are alive, and how many are ON.
void Display::update(PeerEntry *peers, int maxPeers,
bool systemOn, uint8_t outStateVal) {
if (!displayOk) return;
uint32_t now = millis();
int totalUsed = 0;
int activeCount = 0;
int onCount = 0;
for (int i = 0; i < maxPeers; i++) {
if (!peers[i].used) continue;
totalUsed++;
bool alive = (now - peers[i].last_seen)
<= Protocol::TRANSMITTER_TTL_S * 1000UL;
if (alive) {
activeCount++;
if (peers[i].state == Protocol::State::ON) onCount++;
}
}
}
The display gets a pointer to the same peer array the receiver uses. It reads the data but never modifies it — it's a read-only observer.
Walk all 32 slots. Every slot with used = true is a transmitter that has connected at some point.
Check each registered transmitter's last_seen timestamp. If it's within 60 seconds of now, it's alive. Otherwise it's expired — still registered but not counted as active.
Of the alive transmitters, how many have state == ON? This becomes the "ON:X" number on the display.
The receiver's computeOverallOn() already walks the peer table. Why does the display do it again? Because the display needs three separate numbers (total, active, ON), while the receiver only needs a single yes/no answer. Different questions require different counting logic. The display does its own math because it has its own needs.
Auto-Scroll: When 4 Lines Aren't Enough
The OLED screen only has room for 4 gate entries at a time. But what if you have 8 transmitters? Or 12? The display handles this with automatic pagination — it cycles through pages of 4 gates every 3 seconds, like a rotating billboard.
static const int GATES_PER_PAGE = 4;
static int scrollPage = 0;
static uint32_t lastScrollTime = 0;
// Build an index of used slots
int usedIdx[MAX_PEERS];
int usedCount = 0;
for (int i = 0; i < maxPeers; i++) {
if (peers[i].used) usedIdx[usedCount++] = i;
}
int totalPages = (usedCount + GATES_PER_PAGE - 1)
/ GATES_PER_PAGE;
if (totalPages < 1) totalPages = 1;
if (usedCount > GATES_PER_PAGE
&& (now - lastScrollTime >= DISPLAY_SCROLL_MS)) {
lastScrollTime = now;
scrollPage = (scrollPage + 1) % totalPages;
}
Divide the number of registered gates by 4 (rounding up). 6 gates = 2 pages. 10 gates = 3 pages.
Compare the current time to lastScrollTime. The interval is DISPLAY_SCROLL_MS = 3000 milliseconds.
Increment scrollPage by 1. The modulo operator wraps it back to 0 when it reaches the last page.
Calculate startIdx = scrollPage * 4 and draw up to 4 gates starting from that index.
The receiver has no buttons. It's a sealed box mounted near the dust collector. You can't press "next page." So the display scrolls itself. Every 3 seconds, it advances to the next page. In a typical workshop with 5-8 tools, you'd see all your gates within 6-9 seconds — fast enough to glance at and get the full picture.
What the Screen Actually Shows
Let's see what the OLED actually looks like. Here's a representation of the screen when 3 transmitters are connected, one tool is running, and the dust collector is on:
┌─────────────────────────────────────────────────┐
│ SYS: ON PULSE │
│ Gates:3/3 ON:1 │
│─────────────────────────────────────────────────│
│ #1 ON 3s │
│ #2 OFF 12s │
│ #3 OFF 5s │
│ │
└─────────────────────────────────────────────────┘
Now here's the code that renders each gate line:
int startIdx = scrollPage * GATES_PER_PAGE;
for (int row = 0;
row < GATES_PER_PAGE && (startIdx + row) < usedCount;
row++) {
int pi = usedIdx[startIdx + row];
int y = 20 + row * 9;
uint32_t ageSec = (now - peers[pi].last_seen) / 1000;
bool expired = ageSec > Protocol::TRANSMITTER_TTL_S;
char ageBuf[6];
if (expired)
snprintf(ageBuf, sizeof(ageBuf), ">60");
else
snprintf(ageBuf, sizeof(ageBuf), "%lus",
(unsigned long)ageSec);
oled.setCursor(0, y);
oled.printf("#%-2d %s %s",
startIdx + row + 1,
stateStr(peers[pi].state, expired),
ageBuf);
}
>60. Otherwise show the actual age like 3s or 12s."ON , OFF becomes OFF, and expired gates show --- regardless of their last known state."#1 ON 3s."And when a gate has expired (no message for over 60 seconds), the display changes:
┌─────────────────────────────────────────────────┐
│ SYS: OFF │
│ Gates:1/3 ON:0 │
│─────────────────────────────────────────────────│
│ #1 OFF 8s │
│ #2 --- >60 │
│ #3 --- >60 │
│ │
└─────────────────────────────────────────────────┘
--- (unknown/expired) and its age shows >60."An expired gate still appears on the display with --- and >60. It's not removed from the peer table. The slot stays occupied forever (or until a reboot). This is intentional — it lets you see that a transmitter was once connected but has gone silent, which is useful for diagnosing problems. "Why does gate #3 show ---? Oh, the battery died."
Check Your Understanding
You have 6 transmitters connected to the receiver. All 6 are alive and reporting. You glance at the OLED display.
What does the display show at any given moment, and how does it handle showing all 6 gates?
When Things Break
Wireless systems fail. This codebase doesn't prevent failure — it survives it.
Wireless systems fail. Radio signals get blocked. Chips reboot. Power flickers. Sawdust gets everywhere. This codebase doesn't try to prevent failure — it assumes failure will happen and designs every layer to recover automatically.
Think of it like a building's fire safety system. There isn't one magic device that stops fires. Instead, there are multiple independent safety mechanisms — sprinklers, alarms, fire doors, backup power — all designed so that no single failure can cause a catastrophe. The blast gate controller works the same way: five overlapping reliability patterns, each protecting against a different kind of failure.
When a message fails, retry — but wait longer each time. Prevents flooding the radio channel when things are already congested.
If a transmitter goes silent for 60 seconds, treat it as dead. Prevents a crashed transmitter from keeping the dust collector on forever.
The very first lines of code slam all output pins LOW before anything else runs. Prevents the hardware from doing something unexpected during startup.
After 3 consecutive failures, the transmitter drops the receiver and re-enters discovery mode. When it hears a beacon, it re-pairs. No human needed.
Every message has a sequence number. The receiver uses it to detect old, duplicate, and out-of-order messages — preventing stale data from overwriting fresh data.
Exponential Backoff: Retry Smarter, Not Harder
When a message doesn't get ACKed, the transmitter retries. But it doesn't just blast the message again immediately. It uses exponential backoff — doubling the wait time between each retry.
bool sendUpdateWithRetry(const Protocol::Msg &m) {
ackReceived = false;
uint8_t attempt = 0;
uint32_t retryDelay = Protocol::INITIAL_RETRY_MS;
while (attempt < Protocol::MAX_RETRIES) {
esp_now_send(receiverMac,
(const uint8_t *)&m, sizeof(m));
uint32_t t0 = millis();
while (millis() - t0 < Protocol::ACK_TIMEOUT_MS) {
if (ackReceived && ackSeq == m.seq)
return true;
delay(5);
}
if (ackReceived && ackSeq == m.seq)
return true;
attempt++;
delay(retryDelay);
retryDelay = min<uint32_t>(retryDelay * 2, 2000);
}
return false;
}
Here's what the timing looks like across 4 attempts:
Send the message immediately. Wait for ACK. No reply.
First retry. The delay gives the radio channel time to clear. Wait for ACK. No reply.
Second retry. Doubled the delay. Maybe whatever was blocking the channel has cleared. Wait for ACK. No reply.
Last try. Delay doubled again. If this fails too, the function returns false and the transmitter records a consecutive failure.
Imagine 5 transmitters all lose contact with the receiver at the same moment (maybe it rebooted). If they all retry instantly and simultaneously, their messages collide on the radio channel and all fail again. By doubling the delay each time, the devices naturally spread out their retries, reducing collisions. This is called avoiding the thundering herd problem.
TTL Expiry: The Dead Man's Switch
What happens when a transmitter crashes? It can't send a "goodbye, I'm dying" message — it just goes silent. The receiver needs to figure out on its own that a transmitter is gone. It does this with a TTL (Time To Live) check.
static uint32_t lastHouse = 0;
if (now - lastHouse > 1000) {
lastHouse = now;
bool nowOn = computeOverallOn();
if (nowOn != lastKnownOn
&& targetState == Protocol::State::UNKNOWN) {
targetState = nowOn ? Protocol::State::ON
: Protocol::State::OFF;
}
}
A dead man's switch defaults to OFF unless actively kept alive. The dust collector only stays on as long as at least one transmitter is actively saying "my tool is ON." If every transmitter goes silent — crashed, unplugged, out of range — the system defaults to OFF. It would rather turn the dust collector off unnecessarily than leave it running unattended.
Boot Safety: Taming the Hardware
When a microcontroller first powers on, there's a brief window (a few milliseconds) where the GPIO pins are in an undefined state — they might be HIGH, LOW, or somewhere in between. That's a problem when those pins are connected to an optoisolator that controls a dust collector.
pinMode(PIN_ON, OUTPUT);
pinMode(PIN_OFF, OUTPUT);
digitalWrite(PIN_ON, LOW);
digitalWrite(PIN_OFF, LOW);
These four lines are the very first thing in setup() — before WiFi, before the display, before anything else. They slam the door shut on a hardware quirk before it can cause damage. But boot safety is just one layer. Here are all four defense layers that protect the output:
The first lines of setup() force both output pins LOW before any other code runs. Prevents accidental signals during the boot window.
Pin 10 controls ON. Pin 8 controls OFF. They're independent — a stuck pin can't cause a contradictory state. If pin 10 gets stuck HIGH, pin 8 can still turn the collector off.
Output signals are brief pulses, not sustained HIGH signals. Even if something goes wrong, the pin goes LOW automatically after 200 milliseconds. Limits exposure to any single glitch.
Before sending an OFF pulse, the system waits 4 seconds. If a tool turns back on during that window, the OFF pulse is cancelled. Prevents rapid on-off-on thrashing that could damage the dust collector.
You might wonder: why not use a single pin that's HIGH for ON and LOW for OFF? Because a single pin has a dangerous failure mode. If the chip crashes while the pin is HIGH, the dust collector stays on with no way to turn it off. With separate pins, the "on" signal and the "off" signal are independent. Even if the chip reboots mid-pulse, the output returns to a safe state (both pins LOW) immediately.
Self-Healing: The Full Recovery Cycle
All the patterns we've seen — backoff, TTL, boot safety — handle individual problems. But what happens when the transmitter completely loses contact with the receiver? This is where self-healing discovery ties everything together.
void recordSendResult(bool acked) {
if (acked) { consecFailures = 0; return; }
consecFailures++;
Serial.printf("Send failed, consecutive=%d\n",
consecFailures);
if (consecFailures >= Protocol::MAX_CONSEC_FAILURES) {
Serial.println("Lost receiver - re-entering discovery");
esp_now_del_peer(receiverMac);
paired = false;
consecFailures = 0;
}
}
Here's the full failure-and-recovery cycle, dramatized:
Count the patterns in that recovery: exponential backoff (spread out the retries), consecutive failure tracking (decided when to give up), self-healing discovery (re-paired via beacon), and TTL expiry (the receiver would have dropped the silent transmitter after 60 seconds too). Four independent mechanisms, all working together seamlessly.
Sequence Validation: Trusting the Right Message
Radio messages can arrive out of order, get duplicated, or be retransmitted after the receiver has already processed a newer update. Sequence numbers let the receiver sort this out. When a message arrives, the receiver compares its sequence number to the last one it recorded for that transmitter, creating a three-way branch:
if (msg.seq > peers[idx].seq) {
// NEW: higher sequence number
peers[idx].seq = msg.seq;
peers[idx].state = msg.state;
peers[idx].last_seen = millis();
sendAckTo(mac, msg.seq);
targetState = computeOverallOn()
? Protocol::State::ON : Protocol::State::OFF;
} else if (msg.seq == peers[idx].seq) {
// DUPLICATE: same seq (heartbeat resend)
peers[idx].last_seen = millis();
sendAckTo(mac, msg.seq);
} else {
// OLD: lower sequence number
sendAckTo(mac, peers[idx].seq);
}
last_seen timestamp (resets the TTL timer) and ACK it."Accept the update. Save state. Refresh timestamp. ACK it. Recompute dust collector state. This is the happy path — a real state change.
It's a heartbeat or resend. Don't change state, but refresh the TTL timer and ACK. Keeps the connection alive without causing false state changes.
Stale message. Ignore the data entirely. But still ACK it (with our latest seq) to stop the transmitter from wasting energy retrying a message we've already moved past.
It costs almost nothing to send an ACK (13 bytes). But if the receiver ignores the old message, the transmitter will keep retrying it — up to 4 times with exponential backoff — wasting radio bandwidth and battery. A quick ACK says "I heard you, and I'm actually ahead of you, here's my latest sequence number." The transmitter can then move on.
One More Pattern: Fail-Safe Boot
There's one more reliability pattern worth seeing. When the ESP-NOW radio stack fails to initialize, the device doesn't just crash. It tries again. And if it fails twice, it reboots itself entirely.
if (esp_now_init() != ESP_OK) {
Serial.println(
"ESP-NOW init failed - retrying in 1 s");
delay(1000);
if (esp_now_init() != ESP_OK) {
Serial.println(
"ESP-NOW init failed twice - rebooting");
ESP.restart();
}
}
Final Check: Putting It All Together
A transmitter sends UPDATE (seq=5, state=ON). The receiver processes it, ACKs it, and turns the dust collector on. Then the receiver reboots due to a power glitch. When it comes back up, the transmitter — which didn't know about the reboot — sends a heartbeat with the same seq=5. Meanwhile, the receiver's peer table is empty because the reboot cleared it.
What happens when the heartbeat (seq=5, state=ON) arrives at the freshly rebooted receiver?
You've now walked through every line of code in this system. From a magnet moving near a reed switch, to a 13-byte radio message, to a 200-millisecond pulse that turns on a dust collector — you understand how it all works. The detection, the protocol, the state machines, the discovery, the display, and every reliability pattern that keeps it running when things go wrong.
This ~950 lines of C++ runs 24/7 in a dusty workshop. No crashes, no manual restarts, no babysitting. It survives power outages, radio interference, and transmitters going offline. It recovers automatically from every failure mode it encounters. That's what good embedded code looks like — not clever, not fancy, just relentlessly reliable.