Documentation

Everything you need to build, flash, and use esp32-wol — from first-time setup to understanding the internals.

Prerequisites

Hardware

Software

Quick Start

TL;DR: Clone the repo, run ./scripts/setup.sh, then use python3 scripts/wake-pc <ip> to wake your PC.

1. Clone the repository

git clone https://github.com/medinajaime/esp32-wakeonlan.git
cd esp32-wakeonlan

2. Run the setup script

./scripts/setup.sh

The script will:

4. Note the Tailscale IP

After flashing, open the serial monitor:

. ~/esp/esp-idf/export.sh
idf.py monitor -p /dev/ttyUSB0

Wait for the banner — it shows the ESP32's Tailscale IP:

I wol: ╔══════════════════════════════════════════╗
I wol: ║  ESP32 Wake-on-LAN  —  READY             ║
I wol: ║  Tailscale IP : 100.126.x.x              ║
I wol: ║  Listen port  : 9999                     ║
I wol: ╚══════════════════════════════════════════╝

5. Wake your PC

python3 scripts/wake-pc 100.126.x.x

Enable WoL on Target PC

BIOS / UEFI

  1. Enter BIOS setup (usually Delete or F2 at boot)
  2. Find Wake on LAN — typically under Power Management, Advanced, or Network Boot
  3. Enable it
  4. Disable ErP / EuP Ready — this setting cuts standby power to the NIC and will break WoL
  5. Save and exit

Windows

  1. Open Device Manager → expand Network Adapters
  2. Right-click your Ethernet adapter → Properties
  3. Power Management tab → check Allow this device to wake the computer
  4. Advanced tab → find Wake on Magic Packet → set to Enabled

Linux

# Check WoL status (g = enabled, d = disabled)
sudo ethtool eth0 | grep Wake-on

# Enable temporarily (until reboot)
sudo ethtool -s eth0 wol g

# Persist with systemd-networkd: create /etc/systemd/network/10-wol.link
[Match]
MACAddress=10:FF:E0:08:CC:4C

[Link]
WakeOnLan=magic
Important: WoL only works over wired Ethernet. Most wireless NICs do not support Wake-on-LAN magic packets.

Configuration Reference

All settings are configured once via idf.py menuconfig or the setup script, then compiled into the firmware. There are no runtime config files to manage.

Setting Default Description
WOL_WIFI_SSID empty SSID of your home WiFi network
WOL_WIFI_PASSWORD empty WiFi password (leave blank for open networks)
WOL_TAILSCALE_AUTH_KEY placeholder Auth key from Tailscale admin console. Use a reusable ephemeral key.
WOL_DEVICE_NAME esp32-wol Name shown in the Tailscale admin console
WOL_TARGET_MAC AA:BB:CC:DD:EE:FF Ethernet MAC of the NIC to wake. Find it with ip link show (Linux) or ipconfig /all (Windows)
WOL_LISTEN_PORT 9999 UDP port to listen on (over Tailscale). Range 1024–65535.
WOL_BROADCAST_IP 255.255.255.255 Broadcast address for the magic packet. Use subnet broadcast (e.g. 192.168.1.255) if your router blocks global broadcast.

Tailscale Auth Key

The auth key registers the ESP32 with your Tailscale network. To get one:

  1. Go to tailscale.com/admin/settings/keys
  2. Click Generate auth key
  3. Check Reusable and Ephemeral
  4. Copy the key — it looks like tskey-auth-kXXXXXXX-XXXXXXXXXX
Tip: Use an ephemeral key so the device is automatically removed from your admin panel if it goes offline for more than a few minutes. This keeps your device list clean.

Setup Script

The setup script (scripts/setup.sh) is the recommended way to configure and flash the firmware for the first time.

./scripts/setup.sh

What it does:

  1. Checks for ESP-IDF (looks in ~/esp/esp-idf, ~/esp-idf, and $IDF_PATH)
  2. Sources export.sh to activate the IDF environment
  3. Prompts interactively for all required settings (passwords are hidden)
  4. Validates the MAC address format
  5. Generates a temporary config file and builds with SDKCONFIG_DEFAULTS
  6. Flashes to the connected ESP32 and optionally opens the serial monitor
Note: The setup script never writes your credentials to any file that could be committed to git. The temporary config is cleaned up immediately after flashing.

Manual Build

If you prefer to configure manually:

# 1. Activate ESP-IDF
. ~/esp/esp-idf/export.sh

# 2. Configure (opens interactive menuconfig)
idf.py menuconfig
# Navigate to: Wake-on-LAN Configuration

# 3. Build
idf.py build

# 4. Flash
idf.py flash -p /dev/ttyUSB0

Serial Monitor

idf.py monitor -p /dev/ttyUSB0

Expected steady-state output (after ~25 s boot):

I wol: *** Tailscale CONNECTED ***
I wol: ╔══════════════════════════════════════════╗
I wol: ║  ESP32 Wake-on-LAN  —  READY             ║
I wol: ║  Tailscale IP : 100.126.x.x              ║
I wol: ║  Listen port  : 9999                     ║
I wol: ╚══════════════════════════════════════════╝
I wol: UDP socket ready on Tailscale port 9999

wake-pc Script

The scripts/wake-pc script sends triggers every second until the ESP32 confirms receipt with an ACK, then exits immediately. The setup script installs it to ~/.local/bin so you can run it from anywhere.

First use — save your ESP32 IP

After flashing, find the ESP32's Tailscale IP in the serial monitor (or in the Tailscale admin console), then save it:

wake-pc --save 100.126.x.x

This stores the IP in ~/.config/wake-pc/default-ip. From now on, just run:

wake-pc

Options

# Explicit IP (overrides saved default)
wake-pc 100.126.x.x

# Custom port (default: 9999)
wake-pc --port 9999

# Custom timeout (default: 25 s — one full reconnect cycle)
wake-pc --timeout 30

# Save a default IP
wake-pc --save 100.126.x.x

Example output:

Waking PC via ESP32 (100.126.x.x:9999) ...
Done! ESP32 confirmed (attempt 3).

Install on other devices

Copy scripts/wake-pc to any Tailscale-connected machine (laptop, server, Raspberry Pi) and run wake-pc --save <ip>. It works from any device on your tailnet — not just the machine the ESP32 was flashed from. The only requirement is Python 3.

The script uses a single persistent UDP socket (same source port throughout), so when the ESP32 sends back ok, the response is received by the same socket that sent the trigger.

Manual Trigger

You can also trigger WoL manually with nc:

# Single attempt (may need to retry if ESP32 is reconnecting)
echo wake | nc -u 100.126.x.x 9999 -w1

# Retry loop (reliable — covers a full reconnect cycle)
for i in $(seq 1 25); do
  echo wake | nc -u 100.126.x.x 9999 -w1
  sleep 1
done
Note: The manual nc approach doesn't receive the ACK. Use scripts/wake-pc for reliable confirmation.

State Machine

The firmware progresses through these states on boot and after each coordination reconnection:

IDLE REGISTERING FETCHING_PEERS CONFIGURING_WG CONNECTED MONITORING
StateWhat happens
REGISTERINGTLS 1.2 HTTPS POST to controlplane.tailscale.com. Sends WireGuard public key + auth key. Receives Tailscale VPN IP (100.x.x.x).
FETCHING_PEERSHTTP/2 long-poll (/machine/map). Receives peer list as JSON. cJSON parses it — this is the peak memory moment (~16 KB minimum free heap).
CONFIGURING_WGPrograms wireguard-lwip with each peer's public key and allowed IPs.
CONNECTEDWireGuard is up. Transitions to MONITORING immediately.
MONITORINGSteady state. Sends WireGuard keepalives. UDP socket accepts triggers. Transitions back to REGISTERING when the coordination server closes the connection (~10–12 s).

The WireGuard netif is never torn down during a normal coordination reconnect — only if the device enters ERROR state. This means the UDP socket stays valid and can receive triggers even during the ~12-second reconnection window.

WireGuard & DERP

WireGuard

The firmware uses wireguard-lwip, which implements WireGuard as a lwIP network interface (wg0). The handshake uses Noise_IK_25519_ChaChaPoly_BLAKE2s:

DERP relay

When direct UDP between peers is blocked by NAT (the common case for a home ESP32), Tailscale routes traffic through a DERP (Designated Encrypted Relay for Packets) server. The firmware connects to DERP over TLS 1.2 on port 443 — the same as HTTPS — so it passes through almost every firewall.

DERP is transparent to the application. WireGuard-encrypted packets are wrapped in a DERP frame and relayed. The relay server sees only opaque ciphertext; all traffic is still end-to-end encrypted.

Data flow

Your device (any Tailscale peer)
  │  UDP payload ("wake")
  │  encrypted by WireGuard (ChaCha20-Poly1305)
  ▼
DERP relay server
  │  Opaque ciphertext — relay can't read it
  ▼
ESP32 — wireguard-lwip decrypts the frame
  │  Plaintext UDP payload delivered to lwIP socket
  ▼
wol_task — UDP recv returns ESP_OK
  │  Builds 102-byte magic packet
  │  Broadcasts to 255.255.255.255:9 via WiFi STA interface
  ▼
Target PC NIC — recognises its own MAC × 16 — powers on
  │
  ▼
ESP32 sends ACK ("ok") back to sender via WireGuard/DERP

Magic Packet

A WoL magic packet is exactly 102 bytes:

Bytes 0–5   : FF FF FF FF FF FF          (synchronisation stream)
Bytes 6–101 : <target MAC> × 16          (MAC repeated 16 times)

The magic packet is sent as a plain UDP broadcast (not through WireGuard) — it uses a separate BSD socket bound to the WiFi STA interface. This is critical: without binding to the WiFi interface, lwIP might try to route the broadcast through the WireGuard tunnel, where it would be dropped (WireGuard has no concept of broadcasts).

Memory Layout

The NodeMCU-32S has 520 KB SRAM and no PSRAM. Running a full TLS + WireGuard + cJSON stack in this space requires careful tuning.

AllocationSize (approx)
WiFi driver (static RX/TX buffers)~48 KB
mbedTLS handshake + buffers (dynamic)~40 KB
Tailscale state + peer table~20 KB
WireGuard session state~16 KB
FreeRTOS task stacks~20 KB
lwIP buffers~16 KB
cJSON MapResponse parse (peak, freed after)~32 KB
Free at steady state~16 KB

Key tunings in sdkconfig.defaults:

Reconnection Behaviour

The Tailscale coordination server closes the HTTP/2 long-poll connection every 10–12 seconds. This is normal. When it happens:

  1. The firmware transitions MONITORING → REGISTERING
  2. Re-registers and re-fetches the peer list (~12 s total)
  3. Returns to MONITORING

During this window:

The wake-pc script's 25-second retry window is designed to cover one full reconnect cycle, guaranteeing delivery.

Troubleshooting

ESP32 crashes on boot (heap overflow)

Reduce the coordination buffer. In sdkconfig.defaults:

CONFIG_TS_COORD_BUFFER_SIZE_KB=12

Then delete sdkconfig and rebuild.

Device doesn't appear in Tailscale admin console

wake-pc times out (no ACK received)

PC doesn't wake despite "Magic packet sent"

DISCO socket errors in serial output

E ml_disco: disco_send_udp4: socket not initialized!

This is non-fatal. DISCO is used for direct UDP path optimisation (bypassing the DERP relay). If it fails, the firmware falls back to DERP, which works reliably for WoL purposes. You can ignore these messages.

Known Limitations

LimitationDetails
WoL requires Ethernet The target PC must be connected via wired Ethernet. WiFi NICs do not support WoL magic packets.
Single target MAC The firmware wakes one pre-configured PC. Supporting multiple targets would require the trigger payload to encode the MAC — a small firmware change.
No trigger authentication Any device on your Tailscale network can trigger a wake. Use Tailscale ACL rules to restrict access if needed.
Coordination reconnects every ~12 s The Tailscale coordination server closes the long-poll HTTP/2 connection frequently. This is a server-side behaviour; the device handles it gracefully.
No PSRAM support All heap usage must fit in 520 KB SRAM. The firmware runs close to the edge — avoid adding memory-heavy features.