01

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?

⚠️
Why This Matters

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.

💨
The Danger

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 Annoyance

The dust collector sits across the shop. Walking over to flip it on and off for every cut breaks your flow and wastes time.

The Dream

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.

1
You flip on a tool

A small magnet attached to the tool's power switch moves, and a reed switch mounted nearby detects the change.

2
A tiny chip detects the change

An ESP32 microcontroller wired to the reed switch notices the state change and prepares a message.

3
It sends a 13-byte radio message

Using ESP-NOW, the transmitter fires a tiny message (just 13 bytes — smaller than a text message) directly to the receiver over radio.

4
The central receiver hears the message

A second ESP32 (the receiver) is always listening. It decodes the message and decides what to do.

5
The receiver turns on the dust collector

It sends a brief electrical pulse through an optoisolator to safely flip the dust collector's power relay on.

6
You turn off the tool → collector shuts down

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 Big Idea

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.

🧠
ESP32-S3 Zero

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.

🧲
Reed Switch

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.

🔒
Optoisolator

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.

📺
SSD1306 OLED Display

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.

💰
Total Cost

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.

blast-gate-controller/
common/
protocol.h — The shared language. Both sides include this file so they agree on message format.
transmitter/
src/
main.cpp — The sensor brain. Detects tool on/off, sends radio messages.
include/
config.h — Pin numbers, channel, device name.
receiver/
src/
main.cpp — The commander. Listens for messages, controls the dust collector.
display.cpp — The scoreboard. Draws status info on the OLED screen.
include/
config.h — Pin numbers, display settings, channel.
peers.h — Tracks connected transmitters.
platformio.ini — Build config. Tells the compiler which code to use for each device.
💡
One Project, Two Devices

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.

Scenario

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?

02

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.

📖
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.

🔍
transmitter/main.cpp

The Scout — 230 lines. Detects when a tool turns on or off via the reed switch, then sends radio alerts to the receiver.

🏆
receiver/main.cpp

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.

📈
display.cpp

The Scoreboard — 150 lines. Draws status information on the tiny OLED screen: which transmitters are connected, their states, and system health.

📋
peers.h

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.

📜
config.h files

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.

📄
platformio.ini
🎯
Environment
⚙️
Source Files
📖
protocol.h
ESP32 Chip
Click "Next Step" to begin

Here's what the actual platformio.ini looks like. It defines two separate environments — one for the transmitter, one for the receiver.

platformio.ini
[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
Plain English
[env:transmitter] — "When building for a transmitter..."
build_src_filter — "...only compile the files inside transmitter/src/"
build_flags — "...and look for header files in transmitter/include/"
[env:receiver] — "When building for a receiver..."
build_src_filter — "...only compile the files inside receiver/src/"
lib_deps — "...and download the Adafruit SSD1306 library (for the OLED screen)"

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."

transmitter/config.h
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;
Plain English
TRANSMITTER_NAME — "My name is 'transmitter_1' — that's how the receiver will identify me."
ESPNOW_CHANNEL = 1 — "I'll talk on radio channel 1. The receiver must listen on the same channel."
PIN_LED_CLOSED = 7 — "The GPIO pin 7 is wired to the 'gate closed' LED."
PIN_LED_OPEN = 8 — "GPIO pin 8 is wired to the 'gate open' LED."

Now the receiver's config file — it has more settings because it manages the display and multiple inputs.

receiver/config.h
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;
Plain English
ESPNOW_CHANNEL = 1 — "Listen on radio channel 1 — same as the transmitter."
OLED_SDA_PIN = 6 — "The display's data wire (SDA) is on pin 6."
OLED_SCL_PIN = 5 — "The display's clock wire (SCL) is on pin 5."
DISPLAY_REFRESH_MS = 250 — "Redraw the screen every 250 milliseconds (4 times per second)."
DISPLAY_SCROLL_MS = 3000 — "If there are more transmitters than fit on screen, scroll to the next page every 3 seconds."
💡
Why Constants Instead of Numbers?

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.

receiver/include/peers.h
constexpr int MAX_PEERS = 32;
struct PeerEntry {
  uint8_t  mac[6];
  uint32_t seq;
  uint8_t  state;
  uint32_t last_seen;
  bool     used;
};
Plain English
MAX_PEERS = 32 — "We can track up to 32 different transmitters at once."
struct PeerEntry — "Here's the roster card for each transmitter:"
mac[6] — "Its unique 6-byte hardware address (MAC address) — like a serial number."
seq — "The last sequence number we received from this transmitter."
state — "Is the tool on (gate open) or off (gate closed) right now?"
last_seen — "Timestamp of the last message we got. If this is too old, the transmitter might be dead."
used — "Is this slot in the roster occupied, or is it empty and available?"
🤔
Why 32 Peers?

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.

Scenario

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?

03

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.

Without a Protocol

The transmitter sends "TOOL ON" as text. The receiver expects a number. Nothing works. Every change on one side breaks the other side.

With a Protocol

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.

💡
Think: Customs Declaration Card

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.

protocol.h
enum MsgType : uint8_t {
  UPDATE    = 1,
  QUERY     = 2,
  REPLY     = 3,
  ACK       = 4,
  HEARTBEAT = 5,
  JOIN      = 6,
  LEAVE     = 7
};
Plain English
enum MsgType : uint8_t — "Here's a numbered list of every kind of message, stored as a single byte:"
UPDATE = 1 — "Something changed (a tool turned on or off)."
QUERY = 2 — "I'm asking: what's your current status?"
REPLY = 3 — "Here's the answer to your question."
ACK = 4 — "Got your message, confirmed." (ACK)
HEARTBEAT = 5 — "I'm still alive. Just checking in."
JOIN = 6 — "I'm a new device. Add me to your roster."
LEAVE = 7 — "I'm shutting down. Remove me from your roster."

Each message type serves a specific purpose. Here's the roster:

🔄
UPDATE

The workhorse. Sent every time a tool changes state. "My table saw just turned ON" or "My table saw just turned OFF."

QUERY & REPLY

A polite question-and-answer pair. "Hey, what's your current state?" → "I'm currently ON." Used for status checks.

ACK

A receipt. "Got your UPDATE, confirmed." Without ACKs, the sender wouldn't know if its message was received or lost.

💓
HEARTBEAT

A periodic "I'm still here" ping. If the receiver stops getting heartbeats, it knows the transmitter went offline.

👋
JOIN & LEAVE

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.

protocol.h
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
};
Plain English
__attribute__((packed)) — "Don't add any padding between fields. Pack them tightly — we need every byte to land exactly where the other side expects it."
version (1 byte) — "Protocol version number. If we ever change the format, old and new devices can detect the mismatch."
type (1 byte) — "What kind of message is this? (UPDATE, HEARTBEAT, ACK, etc. — the enum from above.)"
sender_mac[6] (6 bytes) — "Who sent this? The sender's unique hardware MAC address."
seq (4 bytes) — "Message sequence number. Increments with every message so the receiver can detect missed or duplicate messages."
state (1 byte) — "The current state of the tool: open (on) or closed (off)."
💡
Why 13 Bytes Is Remarkable

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."

🧰
What Does "packed" Mean?

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."

V
Byte 0: version

Protocol version (currently 1)

T
Byte 1: type

Message type (UPDATE=1, QUERY=2, etc.)

M
Bytes 2-7: sender_mac

6-byte hardware address of the sender

S
Bytes 8-11: seq

4-byte sequence number (counts up with each message)

Byte 12: state

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.

protocol.h
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;
Plain English
DEBOUNCE_MS = 20 — "Wait 20ms after a switch change before acting. This filters out bounce noise."
ACK_TIMEOUT_MS = 300 — "If we don't get a confirmation within 300ms, assume the message was lost."
INITIAL_RETRY_MS = 200 — "Wait 200ms before the first retry attempt."
MAX_RETRIES = 4 — "Try up to 4 times before giving up on a message."
TRANSMITTER_TTL_S = 60 — "If a transmitter is silent for 60 seconds, consider it dead and free its roster slot." (TTL)
BEACON_INTERVAL_MS = 2000 — "Send a 'looking for receivers' beacon every 2 seconds during startup."
HEARTBEAT_INTERVAL_MS = 15000 — "Send an 'I'm still alive' heartbeat every 15 seconds."

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.

protocol.h
constexpr int PIN_RECEIVER_ON  = 10;
constexpr int PIN_RECEIVER_OFF = 8;
constexpr int PIN_TX_A         = 2;
constexpr int PIN_TX_B         = 4;
Plain English
PIN_RECEIVER_ON = 10 — "GPIO pin 10 is wired to the 'turn dust collector ON' optoisolator."
PIN_RECEIVER_OFF = 8 — "GPIO pin 8 is wired to the 'turn dust collector OFF' optoisolator."
PIN_TX_A = 2 — "GPIO pin 2 is wired to reed switch A (first transmitter input)."
PIN_TX_B = 4 — "GPIO pin 4 is wired to reed switch B (second transmitter input)."
⚠️
Why ON and OFF Are Separate Pins

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.

💡
46 Lines. That's It.

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.

Scenario

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?

04

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:

👁
1. Detect Tool State

Read the reed switch to figure out if the tool is ON or OFF. Filter out false readings from mechanical bounce.

📡
2. Send Messages Reliably

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.

💡
Think: War Correspondent

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.

transmitter/main.cpp
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;
Plain English
int a = digitalRead(PIN_A) — "Read the NC contact. Is it HIGH (open) or LOW (closed)?"
int b = digitalRead(PIN_B) — "Read the NO contact. Is it HIGH (open) or LOW (closed)?"
int logicalOn = -1 — "Start with 'I don't know' (-1). We'll figure it out from the two readings."
a == LOW && b == HIGH → 1 — "NC closed + NO open = magnet is near = tool is ON."
a == HIGH && b == LOW → 0 — "NC open + NO closed = magnet is away = tool is OFF."
⚠️
LOW Means Closed — That's Counter-Intuitive!

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:

ON

NC closed (LOW), NO open (HIGH). The magnet is near the switch. The tool is running. logicalOn = 1

OFF

NC open (HIGH), NO closed (LOW). The magnet moved away. The tool is stopped. logicalOn = 0

UNKNOWN

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.

transmitter/main.cpp
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;
    }
  }
}
Plain English
millis() - lastSampleMs >= 10 — "Only check the switch every 10 milliseconds. Don't look at it more often than that."
if (logicalOn != -1) — "Only proceed if we got a valid ON or OFF reading (skip UNKNOWN)."
if (lastState == -1) — "First time reading? Just accept whatever we see and set the LEDs."
logicalOn == lastState → stableCount = 0 — "Reading matches what we already believe? Reset the counter. Nothing changed."
else → stableCount++ — "Reading differs from what we believe? Increment the suspicion counter."
stableCount >= 3 — "Three readings in a row that disagree with our current state? OK, now we believe it actually changed."
seqNo++ — "Bump the sequence number so the receiver knows this is a new event."
stateChanged = true — "Set a flag so the main loop knows to send a radio message."

Here's the process as a step-by-step timeline:

1
Sample every 10ms

The code checks the reed switch no more than once every 10 milliseconds. This prevents reading mid-bounce.

2
Compare to last known state

If the new reading matches what we already believe, reset the counter to zero — nothing interesting happened.

3
Count consecutive disagreements

If the reading differs from the current state, increment a counter. One disagreement could be noise. Two could be a coincidence.

4
Accept after 3 consecutive

Three consecutive different readings? Now we believe the switch genuinely changed. Update the state, bump the sequence number, and flag for sending.

💡
3 samples × 10ms = 30ms

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.

transmitter/main.cpp
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;
}
Plain English
ackReceived = false — "Reset the flag. We haven't gotten a confirmation yet."
attempt = 0, retryDelay = 200ms — "Start fresh: zero attempts, initial wait of 200ms between retries."
while (attempt < MAX_RETRIES) — "Keep trying, up to 4 attempts total."
esp_now_send(...) — "Fire the message over radio to the receiver's MAC address."
while (millis() - t0 < ACK_TIMEOUT_MS) — "Wait up to 300ms, checking every 5ms for an ACK."
if (ackReceived && ackSeq == m.seq) return true — "Got the right ACK? Great, we're done. Message delivered!"
attempt++; delay(retryDelay) — "No ACK. Wait before retrying."
retryDelay = retryDelay * 2 (max 2000) — "Double the wait for next time: 200ms → 400ms → 800ms → 1600ms. This is exponential backoff."
return false — "All 4 attempts failed. Give up on this message."
📡
Transmitter
🛰
Receiver
📨
Click "Next Step" to begin
🤔
Why Exponential Backoff?

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.

transmitter/main.cpp
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;
  }
}
Plain English
if (acked) { consecFailures = 0; return; } — "Message got through? Great. Reset the failure counter and move on."
consecFailures++ — "Message failed. Add one to the consecutive failure count."
if (consecFailures >= MAX_CONSEC_FAILURES) — "Three failures in a row? That's not bad luck — the receiver is probably gone."
esp_now_del_peer(receiverMac) — "Delete the receiver from our peer list. Forget its address."
paired = false — "Go back to 'unpaired' mode. Start listening for the receiver's beacon again."
consecFailures = 0 — "Reset the counter for a clean start on the next pairing."
💡
Self-Healing by Design

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

Scenario

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?

05

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.

📋
Track All Peers

Maintain a table of up to 32 transmitters — their MAC address, current state, last message number, and when they last checked in.

⚖️
Compute the Decision

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).

Drive the Output

Send brief electrical pulses through an optoisolator to turn the dust collector on or off. Manage timing carefully to prevent thrashing.

🔔
Broadcast Beacons

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.

receiver/main.cpp
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;
}
Plain English
findPeer(mac) — "Look through the roster for a transmitter with this MAC address."
for i = 0 to MAX_PEERS — "Check every slot in the table (up to 32)."
peers[i].used && memcmp(...) == 0 — "Is this slot occupied AND does its address match? If yes, return the slot number."
return -1 — "Checked all 32 slots, nobody matched. This is a new transmitter."
receiver/main.cpp
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;
}
Plain English
addOrFindPeer(mac) — "Find this transmitter in the roster, or add it if it's new."
int idx = findPeer(mac) — "First, check if we already know this transmitter."
if (idx >= 0) return idx — "Found it? Return its slot number. Done."
for i ... if (!peers[i].used) — "Not found. Scan for the first empty slot in the roster."
memcpy, seq = 0, state = UNKNOWN, ... — "Fill in the new roster card: copy the MAC address, start sequence at 0, state unknown, mark as 'just seen,' flag the slot as occupied."
return -1 — "No empty slots. Roster is full. (Rare — we support 32 transmitters.)"
🤔
Why Not Use a List That Grows?

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?

receiver/main.cpp
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;
}
Plain English
uint32_t now = millis() — "What time is it right now?"
for i = 0 to MAX_PEERS — "Walk through every slot in the roster."
if (!peers[i].used) continue — "Empty slot? Skip it."
if (now - last_seen > TTL) continue — "Haven't heard from this transmitter in over 60 seconds? It's stale. Skip it — treat it as dead."
if (state == ON) return true — "This transmitter says its tool is ON? Stop looking. The answer is YES — dust collector should be on."
return false — "Checked everyone. Nobody is ON (or they're all stale). Dust collector can turn off."
💡
One "Yes" Overrides Any Number of "No" Votes

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.

1
Walk the roster

Loop through all 32 peer slots.

2
Skip empties and stales

Ignore unused slots and transmitters that haven't checked in within 60 seconds.

3
First ON wins

The moment we find any active transmitter reporting ON, return true immediately. No need to check the rest.

4
All OFF (or dead) → false

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.

receiver/main.cpp
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
};
Plain English
IDLE — "Nothing happening. Both output pins are LOW (inactive). Waiting for a decision."
PULSING_ON — "Sending a 200ms pulse on pin 10 to turn the dust collector ON."
OFF_WAITING — "We want to turn off, but we're waiting 4 seconds first. Maybe you're just switching tools."
PULSING_OFF — "The 4-second wait is over. Sending a 200ms pulse on pin 8 to turn the dust collector OFF."

Here's how the states flow:

1
IDLE → PULSING_ON

A tool turns on. The receiver sends a 200ms electrical pulse on GPIO pin 10. The optoisolator fires, the dust collector starts.

2
PULSING_ON → IDLE

After 200ms, the pulse ends. Pin 10 goes LOW. Back to idle — the dust collector stays running on its own.

3
IDLE → OFF_WAITING

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
OFF_WAITING → PULSING_OFF

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.

5
PULSING_OFF → IDLE

Pulse done. Pin 8 goes LOW. System is idle again, waiting for the next tool to start.

receiver/main.cpp
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;
}
Plain English
case PULSING_ON — "If we're in the middle of an ON pulse and enough time has passed (200ms), end the pulse and go back to IDLE."
case OFF_WAITING — "If we've been waiting to turn off and 4 seconds have passed, start the OFF pulse. Set pin 8 HIGH."
case PULSING_OFF — "If the OFF pulse has lasted long enough (200ms), end it. Pin 8 goes LOW. Back to IDLE."
case IDLE / default — "Nothing to do. Just wait."
⚠️
Why the 4-Second Delay?

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.

receiver/main.cpp
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);
    }
  }
}
Plain English
addOrFindPeer(msg.sender_mac) — "Find this transmitter in the roster, or add it if it's new."
msg.seq > peers[idx].seq — "New state change! This sequence number is higher than the last one we saw. Accept the update, save the new state, refresh the timestamp, send an ACK, then recompute whether the dust collector should be on."
msg.seq == peers[idx].seq — "Heartbeat resend. Same sequence number as before — no new event, just a keep-alive. Refresh the timestamp (so we know it's still alive) and ACK it."
else (msg.seq < peers[idx].seq) — "Old duplicate. We've already processed a newer message from this transmitter. ACK it anyway (so the transmitter stops retrying) but use our latest sequence number."
📡
Transmitter
📨
Message
🛰
Receiver
📋
Peer Table
⚖️
OR Logic
Output
📨
Click "Next Step" to begin
💡
Why ACK Old Messages?

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:

receiver/main.cpp — setup()
// 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);
Plain English
pinMode(PIN_ON, OUTPUT) — "Tell the chip that pin 10 is an output pin (we'll send signals out of it)."
pinMode(PIN_OFF, OUTPUT) — "Same for pin 8."
digitalWrite(PIN_ON, LOW) — "Force pin 10 LOW immediately. Don't let it do anything unexpected."
digitalWrite(PIN_OFF, LOW) — "Force pin 8 LOW. This is the critical one — pin 8 can float HIGH during boot."
⚠️
Why This Matters: The Floating Pin Problem

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.

🤔
Defensive Programming

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

Scenario

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?

06

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.

🌈
Beacon

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."

👁
Discovery

An unpaired transmitter listens for beacon messages. When it hears one, it saves the receiver's MAC address and starts sending updates to it.

💓
Keep-Alive

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.

🔄
Self-Healing

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.

receiver/main.cpp
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));
}
Plain English
Protocol::Msg beacon{} — "Create a new, empty message. All fields start at zero."
beacon.version = Protocol::VERSION — "Stamp it with our protocol version (so receivers and transmitters on different versions can detect the mismatch)."
beacon.type = HEARTBEAT — "Mark this as a HEARTBEAT message — it's a beacon, not a tool-state update."
beacon.seq = 0 — "Beacons don't need a sequence number. They're just announcements."
beacon.state = lastKnownOn ? ON : OFF — "Include the current dust collector state, so a transmitter can know the system status even before it starts reporting."
memcpy(beacon.sender_mac, myMac, 6) — "Stamp the message with our own MAC address, so transmitters know who to reply to."
esp_now_send(BROADCAST_MAC, ...) — "Send it to FF:FF:FF:FF:FF:FF — the 'everyone' address. Every ESP-NOW device in range will receive this."
💡
Why Every 2 Seconds?

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.

transmitter/main.cpp
// 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;
}
Plain English
msg.type == HEARTBEAT && !paired — "Is this a beacon message AND are we currently looking for a receiver? Both must be true."
memcpy(receiverMac, mac, 6) — "Save the receiver's MAC address. This is who we'll send our updates to."
if (!esp_now_is_peer_exist(mac)) — "Is this receiver already in our peer list? If not, we need to add it."
esp_now_peer_info_t info = {} — "Create a new peer record: MAC address, radio channel, no encryption."
esp_now_add_peer(&info) — "Register the receiver as a peer. Now ESP-NOW will let us send messages directly to it."
paired = true — "We're connected! Stop looking for beacons and start reporting tool state."

Here's the full discovery process as step-by-step:

1
Transmitter boots up

paired = false. The transmitter doesn't know the receiver's address yet. It's listening for any incoming radio message.

2
Receiver broadcasts a beacon

Every 2 seconds, the receiver sends a HEARTBEAT to FF:FF:FF:FF:FF:FF. This goes to every ESP-NOW device in range.

3
Transmitter hears the beacon

The receive callback fires. It sees a HEARTBEAT message and paired is false — this is the receiver we've been looking for!

4
Save the address and register the peer

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.

5
Set paired = true

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."

transmitter/main.cpp
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);
}
Plain English
lastState != -1 — "Only send heartbeats if we've actually read the switch at least once. Don't report before we know anything."
millis() - lastHeartbeat >= 15000 — "Has it been 15 seconds since the last heartbeat? Time to check in."
Protocol::State s = (lastState == 1) ? ON : OFF — "Convert our internal state (1 or 0) to the protocol's ON or OFF value."
buildUpdate(seqNo, s) — "Build an UPDATE message with the current sequence number and state. Note: seqNo doesn't increment — this is the same seq as the last real change."
sendUpdateWithRetry(m) — "Send it with the full retry logic (up to 4 attempts with exponential backoff)."
recordSendResult(acked) — "Track success or failure. 3 consecutive failures will trigger re-discovery."
📡
Transmitter
15s Timer
🛰
Receiver
💓
Click "Next Step" to begin
💡
15 Seconds vs. 60 Seconds

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:

1
Transmitter's messages fail

The transmitter sends an UPDATE or heartbeat, but no ACK comes back after all 4 retry attempts. consecFailures increments.

2
3 consecutive failures → drop receiver

After 3 failed sends in a row, the transmitter calls esp_now_del_peer(), sets paired = false, and re-enters discovery mode.

3
Receiver's TTL expires

On the receiver side, 60 seconds pass with no heartbeat from the transmitter. computeOverallOn() starts skipping that transmitter in its calculations.

4
Receiver keeps broadcasting beacons

The receiver doesn't care about the disconnect. It keeps sending beacons every 2 seconds, as always.

5
Transmitter hears a beacon → re-pairs

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.

🤔
No "Reconnect" Message Needed

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

Scenario

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?

07

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:

⚙️
Line 0: System Status

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?

📊
Line 1: Gate Summary

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.

Separator Line

A thin horizontal line across the screen, visually separating the summary header from the gate list below. Small detail, big readability improvement.

📝
Gate List

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.

📄
Page Indicator

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).

display.cpp — init()
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();
}
Plain English
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN) — "Start the I2C bus using pin 6 for data and pin 5 for clock. These two wires are the only connection between the ESP32 and the screen."
displayOk = oled.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDR) — "Try to connect to the OLED screen at address 0x3C. If the screen is there, displayOk becomes true. If not, it becomes false."
if (!displayOk) { ... return; } — "Screen not found? Print a message to the serial log, then bail out. No crash, no error — just quietly disable the display."
oled.clearDisplay() — "Wipe the screen blank."
oled.setTextSize(1), setTextColor(WHITE) — "Use the smallest text size (6x8 pixels per character) in white."
oled.setCursor(16, 20), print("Blast Gate RX") — "Move the cursor to pixel position (16, 20) and write the boot splash text."
oled.display() — "Push everything from the memory buffer to the actual screen. Nothing shows up until you call this."
💡
Defensive Design: The System Works Without a Screen

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.

display.cpp — update()
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++;
    }
  }
}
Plain English
Display::update(peers, maxPeers, systemOn, outStateVal) — "The receiver hands us the full peer table, the system ON/OFF flag, and the current output state machine phase."
if (!displayOk) return — "No screen connected? Do nothing. Bail out immediately."
int totalUsed = 0 — "Counter #1: How many slots in the peer table have ever been used? (Registered transmitters, alive or dead.)"
int activeCount = 0 — "Counter #2: Of those, how many have checked in within the last 60 seconds? (Still alive.)"
int onCount = 0 — "Counter #3: Of the alive ones, how many report their tool is ON?"
for i ... if (!peers[i].used) continue — "Walk every slot. Skip empty ones."
totalUsed++ — "This slot has a transmitter. Increment the 'total registered' count."
bool alive = (now - last_seen) <= TTL * 1000 — "Has this transmitter checked in within the TTL window (60 seconds)? If yes, it's alive."
if (alive) { activeCount++; if (ON) onCount++; } — "Alive? Count it as active. Also ON? Count that too."
1
Receive the peer table

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.

2
Count total registered

Walk all 32 slots. Every slot with used = true is a transmitter that has connected at some point.

3
Filter for alive

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.

4
Count ON among alive

Of the alive transmitters, how many have state == ON? This becomes the "ON:X" number on the display.

🤔
Why Not Just Use the Receiver's Existing Count?

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.

display.cpp — pagination logic
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;
}
Plain English
GATES_PER_PAGE = 4 — "We can fit 4 gate entries on screen at a time. That's our page size."
scrollPage = 0 — "Start on page 0. This variable persists between calls (it's static)."
usedIdx[usedCount++] = i — "Build a compact list of just the occupied slot indices. If slots 0, 3, and 7 are used, usedIdx becomes [0, 3, 7] and usedCount becomes 3."
(usedCount + GATES_PER_PAGE - 1) / GATES_PER_PAGE — "Calculate total pages using ceiling division. 6 gates / 4 per page = 2 pages. 8 gates / 4 = 2 pages. 9 gates / 4 = 3 pages."
if (usedCount > GATES_PER_PAGE && ...) — "Only scroll if there are more gates than fit on one page AND 3 seconds have passed since the last scroll."
scrollPage = (scrollPage + 1) % totalPages — "Advance to the next page. The modulo wraps page 3 back to page 0, creating an endless loop."
1
Count total pages

Divide the number of registered gates by 4 (rounding up). 6 gates = 2 pages. 10 gates = 3 pages.

2
Check if 3 seconds have elapsed

Compare the current time to lastScrollTime. The interval is DISPLAY_SCROLL_MS = 3000 milliseconds.

3
Advance the page

Increment scrollPage by 1. The modulo operator wraps it back to 0 when it reaches the last page.

4
Render the current page

Calculate startIdx = scrollPage * 4 and draw up to 4 gates starting from that index.

💡
Why Auto-Scroll Instead of Buttons?

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:

OLED screen (128 x 64 pixels)
┌─────────────────────────────────────────────────┐
│ SYS: ON  PULSE                         │
│ Gates:3/3  ON:1                         │
│─────────────────────────────────────────────────│
│ #1  ON  3s                              │
│ #2  OFF 12s                             │
│ #3  OFF 5s                              │
│                                         │
└─────────────────────────────────────────────────┘
Reading the screen
SYS: ON PULSE — "The dust collector is ON, and the output state machine is currently in the PULSING phase (sending the electrical signal)."
Gates:3/3 ON:1 — "3 transmitters are alive out of 3 registered. 1 of those 3 has its tool running."
#1 ON 3s — "Gate #1's tool is ON. Last message received 3 seconds ago."
#2 OFF 12s — "Gate #2's tool is OFF. Last heard 12 seconds ago. Still well within the 60-second TTL."
#3 OFF 5s — "Gate #3's tool is OFF. Last heard 5 seconds ago."

Now here's the code that renders each gate line:

display.cpp — gate rendering
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);
}
Plain English
startIdx = scrollPage * GATES_PER_PAGE — "If we're on page 1, start at gate index 4. Page 2, start at gate 8. This is the offset into our sorted gate list."
row < GATES_PER_PAGE && (startIdx + row) < usedCount — "Draw up to 4 rows, but stop early if we run out of gates."
int y = 20 + row * 9 — "Each text row is 9 pixels tall. Start at pixel 20 (below the header and separator). Row 0 = y:20, row 1 = y:29, row 2 = y:38, row 3 = y:47."
ageSec = (now - last_seen) / 1000 — "How many seconds ago did we last hear from this gate? Convert milliseconds to seconds."
if (expired) ">60" else "%lus" — "If the gate is past its 60-second TTL, show >60. Otherwise show the actual age like 3s or 12s."
stateStr(state, expired) — "Convert the state to a display string. ON becomes ON , OFF becomes OFF, and expired gates show --- regardless of their last known state."
"#%-2d %s %s" — "Format: gate number (left-aligned, 2 chars), then state, then age. Produces lines like #1 ON 3s."

And when a gate has expired (no message for over 60 seconds), the display changes:

OLED screen (expired gate example)
┌─────────────────────────────────────────────────┐
│ SYS: OFF                                │
│ Gates:1/3  ON:0                         │
│─────────────────────────────────────────────────│
│ #1  OFF 8s                              │
│ #2  --- >60                             │
│ #3  --- >60                             │
│                                         │
└─────────────────────────────────────────────────┘
Reading the screen
Gates:1/3 — "Only 1 of 3 registered transmitters is still alive. The other 2 have gone silent."
#2 --- >60 — "Gate #2 hasn't reported in over 60 seconds. Its state shows --- (unknown/expired) and its age shows >60."
SYS: OFF — "No output phase shown because the system is idle — no pulse in progress."
⚠️
Expired Doesn't Mean Deleted

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

Scenario

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?

08

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.

🔄
Exponential Backoff

When a message fails, retry — but wait longer each time. Prevents flooding the radio channel when things are already congested.

TTL Expiry

If a transmitter goes silent for 60 seconds, treat it as dead. Prevents a crashed transmitter from keeping the dust collector on forever.

🔒
Boot Safety

The very first lines of code slam all output pins LOW before anything else runs. Prevents the hardware from doing something unexpected during startup.

🚑
Self-Healing Discovery

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.

🔢
Sequence Validation

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.

transmitter/main.cpp
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;
}
Plain English
ackReceived = false — "Clear the ACK flag. We haven't gotten confirmation yet."
attempt = 0, retryDelay = INITIAL_RETRY_MS — "Start at attempt 0 with a 200ms retry delay."
while (attempt < MAX_RETRIES) — "Try up to 4 times (MAX_RETRIES = 4)."
esp_now_send(...) — "Fire the message over the radio."
while (millis() - t0 < ACK_TIMEOUT_MS) — "Wait up to the ACK timeout period, checking every 5ms for a reply."
if (ackReceived && ackSeq == m.seq) return true — "Got the ACK and the sequence number matches our message? Success! We're done."
attempt++; delay(retryDelay) — "No ACK. Increment the attempt counter and wait before retrying."
retryDelay = min(retryDelay * 2, 2000) — "Double the delay for next time, but cap it at 2 seconds. Don't wait forever."
return false — "All 4 attempts failed. Give up on this message."

Here's what the timing looks like across 4 attempts:

1
Attempt 1 — 0ms

Send the message immediately. Wait for ACK. No reply.

2
Attempt 2 — wait 200ms

First retry. The delay gives the radio channel time to clear. Wait for ACK. No reply.

3
Attempt 3 — wait 400ms

Second retry. Doubled the delay. Maybe whatever was blocking the channel has cleared. Wait for ACK. No reply.

4
Attempt 4 — wait 800ms

Last try. Delay doubled again. If this fails too, the function returns false and the transmitter records a consecutive failure.

💡
Why Not Retry Immediately?

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.

receiver/main.cpp — housekeeping
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;
  }
}
Plain English
static uint32_t lastHouse = 0 — "Track when we last did housekeeping. This static variable persists between calls."
if (now - lastHouse > 1000) — "Has it been at least 1 second since the last check? Housekeeping runs once per second."
bool nowOn = computeOverallOn() — "Re-run the OR logic. This function already skips transmitters whose TTL has expired (silent for >60 seconds). So a dead transmitter's 'ON' vote automatically stops counting."
if (nowOn != lastKnownOn && targetState == UNKNOWN) — "Has the overall state changed AND are we not already in the middle of a state transition? If both, trigger a new transition."
targetState = nowOn ? ON : OFF — "Tell the output state machine to turn the dust collector on or off."
📡
TX #2
60s TTL
🛰
Receiver
Dust Collector
☠️
Click "Next Step" to begin
⚠️
This Is a "Dead Man's Switch"

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.

receiver/main.cpp — first lines of setup()
pinMode(PIN_ON,  OUTPUT);
pinMode(PIN_OFF, OUTPUT);
digitalWrite(PIN_ON,  LOW);
digitalWrite(PIN_OFF, LOW);
Plain English
pinMode(PIN_ON, OUTPUT) — "Configure pin 10 (the ON optoisolator) as an output. We'll send signals through it."
pinMode(PIN_OFF, OUTPUT) — "Configure pin 8 (the OFF optoisolator) as an output."
digitalWrite(PIN_ON, LOW) — "Force pin 10 LOW. Don't let it accidentally fire the ON signal."
digitalWrite(PIN_OFF, LOW) — "Force pin 8 LOW. This is the critical one — GPIO8 on the ESP32-C6 tends to float HIGH during boot, which would fire the OFF optoisolator."

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:

1
Pins LOW at boot

The first lines of setup() force both output pins LOW before any other code runs. Prevents accidental signals during the boot window.

2
Separate ON/OFF pins

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.

3
200ms pulse duration

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.

4
4-second OFF delay

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.

🤔
Why Two Pins Instead of One?

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.

transmitter/main.cpp
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;
  }
}
Plain English
if (acked) { consecFailures = 0; return; } — "Message was ACKed? Great. Reset the failure counter to zero. Everything's fine."
consecFailures++ — "Message failed. Increment the consecutive failure counter."
if (consecFailures >= MAX_CONSEC_FAILURES) — "Have we failed 3 times in a row? The receiver is probably gone."
esp_now_del_peer(receiverMac) — "Delete the receiver from our ESP-NOW peer list."
paired = false — "Set paired to false. This makes the transmitter start listening for beacons again — the exact same discovery process from its very first boot."
consecFailures = 0 — "Reset the counter. We're starting fresh."

Here's the full failure-and-recovery cycle, dramatized:

💡
Every Pattern Played a Role

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:

receiver/main.cpp — message callback
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);
}
Plain English
msg.seq > peers[idx].seq — "NEW" — "This sequence number is higher than the last one we processed. This is a genuine state change. Save the new state, update the timestamp, ACK it, and re-evaluate whether the dust collector should be on."
msg.seq == peers[idx].seq — "DUPLICATE" — "Same sequence number as before. This is a heartbeat resend — the transmitter is just saying 'I'm still here.' Don't change the state, but refresh the last_seen timestamp (resets the TTL timer) and ACK it."
msg.seq < peers[idx].seq — "OLD" — "This sequence number is lower than what we already have. This is a stale message — maybe a retry that arrived after we already processed a newer update. Ignore the data but still send an ACK (using our latest seq) so the transmitter stops retrying."
New (seq > stored)

Accept the update. Save state. Refresh timestamp. ACK it. Recompute dust collector state. This is the happy path — a real state change.

💓
Duplicate (seq == stored)

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.

🚫
Old (seq < stored)

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.

⚠️
Why ACK Old Messages?

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.

both transmitter and receiver
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();
  }
}
Plain English
esp_now_init() != ESP_OK — "Try to start the ESP-NOW radio system. Did it fail?"
delay(1000) — "Wait 1 second. Sometimes a brief delay is all the hardware needs to settle."
if (esp_now_init() != ESP_OK) — "Try again. Still failing?"
ESP.restart() — "The radio is genuinely broken. Reboot the entire chip. Start everything from scratch. The boot safety code will fire first (pins LOW), then initialization will try again."

Final Check: Putting It All Together

Scenario

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 Seen Every Line

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.

🤔
~950 Lines. Zero Babysitting.

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.