# Waking the Lab: an ESP32, MQTT, and 102 Bytes

> Wake-on-LAN only works from inside the LAN — so I put a $5 ESP32 on the LAN. It subscribes to an MQTT topic and broadcasts magic packets on demand. The build, the failure modes, and what a hardware watchdog is really for.

URL: https://harshith.in/blog/waking-the-lab-esp32-mqtt-wol/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: embedded, networking, systems
Series: [The Homelab](https://harshith.in/series/homelab/) (part 4 of 5)

The [Proxmox host](https://harshith.in/blog/homelab-proxmox-zfs-lxc/) in my lab runs 24/7.
The machines around it don't: the workstation and the bench boxes sleep
when idle, and [Wake-on-LAN is a layer-2
mechanism](https://harshith.in/blog/wake-on-lan-magic-packets/), so waking one remotely
means something *inside* the LAN has to send the packet. My first wake
path was the obvious one — a container on the hypervisor that publishes
magic packets — and it worked right up until the day the machine I
needed to wake *was* the hypervisor, down for a kernel upgrade. A recovery path that depends on
the thing being recovered isn't a recovery path. (Port-forwarding UDP
from the internet is the other classic answer, and it violates the rule
from [the homelab networking post](https://harshith.in/blog/self-hosting-networking-stack/):
VPN in, don't port-forward.)

So the wake path is its own device — independent of every machine it
might need to rescue, drawing about half a watt, costing less than
lunch: [an ESP32](https://harshith.in/projects/esp32-mqtt-wol/) that
holds one MQTT subscription and broadcasts magic packets on command.

## The shape of the thing

*[Diagram: Phone to MQTT broker to ESP32 to sleeping NIC]*

The ESP32 runs ESP-IDF — FreeRTOS underneath, lwIP for the network stack.
The application is honestly small: connect to Wi-Fi, keep a session to the
broker, and on a message to `wol/wake/<host>`, assemble the 102-byte magic
packet (6×`0xFF` + the target MAC sixteen times) and fire it at the
broadcast address on UDP port 9. The packet format and why the NIC's
pattern-matcher can parse it while the CPU is off is
[the previous post's territory](https://harshith.in/blog/wake-on-lan-magic-packets/); this
post is about what it takes to make the *sender* dependable.

One wrinkle a segmented network adds: broadcasts stop at VLAN
boundaries. Small embedded gadgets normally belong on the quarantined
IoT segment, but a magic packet sent there dies at the VLAN edge — the
wake gateway needs layer-2 adjacency with its targets. In a network
[carved up by trust](https://harshith.in/blog/homelab-proxmox-zfs-lxc/), that placement is
a deliberate, documented exception — the kind of thing you decide once,
on purpose, instead of discovering at 2 AM.

## MQTT choices that matter

The protocol details are in [the MQTT internals post](https://harshith.in/blog/mqtt-protocol-internals/);
here's how they cash out in a real device:

**QoS 1, not 0, not 2.** A wake command that evaporates is a failed
feature — QoS 0 is out. Duplicate delivery, on the other hand, is
harmless: waking an awake machine is a no-op. That's precisely the QoS 1
contract (*at least once*), and it's cheaper than QoS 2's four-way
handshake. Idempotent command + QoS 1 is the sweet spot.

**No retained messages on the command topic.** This is the classic trap.
Retain a `wake` command and every reconnect replays it — the ESP32
reboots, resubscribes, and helpfully wakes the server you just shut down.
Commands are events, not state; retained messages are for state.

**Last Will as a health signal.** The broker publishes
`wol/status/gateway = offline` if the ESP32's session dies. My monitoring
watches that topic — a dead wake-gateway discovered at 2 AM when you need
it is the worst possible discovery time.

## The watchdog is the design

The failure mode of an always-on embedded device isn't crashing — it's
*wedging*: Wi-Fi associated, TCP session zombied, task blocked on a
semaphore that will never post. A crash reboots; a wedge lasts until you
physically walk over, which defeats the entire point of a remote wake
device.

So the design assumption is "this will wedge eventually," and the answer
is the ESP32's hardware task watchdog. The main loop must check in every
few seconds; the check-in is gated on *actual liveness* — broker
connection up, subscription alive — not just "the loop is spinning." If
the session zombies, the check-in stops, the watchdog fires, and the
device is back in a known-good state in about two seconds. Nobody walks
anywhere.

That inversion — design for recovery, not for uptime — is the one idea
from this build that generalizes to every unattended device.

## Takeaways

- WoL's layer-2 constraint means *something* must live on the LAN; make
  it the cheapest, lowest-power thing that can hold an MQTT session —
  and keep the wake path independent of the machines it recovers.
- VLAN segmentation and broadcast-based protocols fight: place the wake
  gateway with L2 adjacency to its targets, as a deliberate exception.
- Idempotent commands + QoS 1 give you reliability without QoS 2's
  overhead. Never retain command messages — retain state, publish events.
- Use MQTT Last Will to monitor the monitor: a wake gateway that fails
  silently fails at the worst time.
- For unattended devices, gate the watchdog check-in on end-to-end
  liveness and let the reset be your recovery path.
- This device backstops [the lab](https://harshith.in/blog/homelab-proxmox-zfs-lxc/) whose
  always-on host runs [the AI coworker](https://harshith.in/blog/an-ai-coworker-in-an-lxc/)
  that maintains [the site you're reading](https://harshith.in/blog/how-this-blog-runs-for-0/).
  Every layer has a recovery story — next up, [the same rule applied to
  logins](https://harshith.in/blog/homelab-identity-and-access/).
