I've been living in Asia for a few months while my main machine, a desktop with an RTX 5090, sits in an apartment in Russia. I run local LLMs through Ollama and image/video generation through ComfyUI on it, and a laptop doesn't replace that. Leaving it powered on for months was a non-starter: weeks of idle draw, plus an unattended box in an empty flat. Renting a 5090-class GPU in the cloud costs about a dollar an hour, which adds up fast at two or three hours a day.
The setup I ended up with: a $5 ESP32 board on a USB phone charger is the only thing running at home around the clock. One button in a web panel wakes the PC over Wake-on-LAN and confirms it came up. Once Windows boots, the machine connects to an MQTT broker and executes whatever I send, from "shut down" to "run this script and paste me the output". A reverse SSH command turns the home Ollama into what feels like a cloud endpoint for as long as the task takes.
This post covers the part I got wrong the first time: why waking a PC and commanding it must be two separate channels, and how the pieces are wired.
Two channels, because the machine has two states
The classic mistake is putting one program on the PC that "does everything". It can't work: a shut-down PC runs only its BIOS, and the only thing BIOS understands in that state is a Wake-on-LAN packet. Agents need a running OS. So wake-up and control are two channels with different lifetimes:
Phone (browser)
│ HTTPS
Web panel on a VPS
│ │
command file MQTT broker
│ │
ESP32 Windows scripts
(deep sleep, (HTTP :8080 +
5-min poll) MQTT listener)
│ │
Wake-on-LAN arbitrary
│ commands
└────► Home PC ◄────┘
- The wake channel: the panel writes a short command to a file on the VPS. The ESP32 at home wakes on a timer, fetches it over HTTPS, and sends the magic packet. Works when the PC is fully off.
- The command channel: as soon as Windows boots, it connects out to the MQTT broker on the same VPS and listens. Everything I type in the panel arrives through it, including scripts whose output lands in my Telegram.
A side effect worth having: no ports are forwarded into the home network. Both the board and Windows connect out to the VPS; nothing ever connects in.
Firmware: one pass, then sleep
The firmware has no permanent loop(). The board lives in deep sleep, wakes on a timer (5 minutes) or the BOOT button, does one pass, and goes back down. Exiting deep sleep is a hardware reset, so all the logic sits in setup():
void setup() {
// read BOOT as early as possible, while it's still held after ext0 wake
bool buttonAtWake = (digitalRead(0) == LOW);
wakeCount++; // RTC_DATA_ATTR: survives deep sleep
setCpuFrequencyMhz(80); // lowest clock that still runs WiFi
connectWiFi(); // modem sleep + TX power 11dBm
if (WiFi.status() != WL_CONNECTED) { goToSleep(); return; }
if (buttonAtWake) processCommandFromFile(true); // manual
else processCommandFromFile(false); // timed poll
goToSleep(); // timer + ext0(GPIO0) → esp_deep_sleep_start()
}
void loop() { delay(1000); } // never reached
I built the first version without deep sleep: the board just idled in loop() with Wi-Fi always on, and it was warm to the touch all the time. I assumed the radio was at fault and cranked WiFi.setTxPower up. Signal got great, heat didn't change. Three settings together fixed it: 80 MHz clock (the minimum for Wi-Fi), WiFi.setSleep(true) so the radio dozes between beacons, and reduced TX power, more than enough with the router one wall away. The board now barely registers above room temperature.
State that survives sleep
The obvious failure mode of "wake, act, sleep": what if the board sleeps halfway? It sent Wake-on-LAN, the PC is still booting, and the reset wipes every normal variable. RTC RAM solves it: variables marked RTC_DATA_ATTR keep their values through deep sleep.
RTC_DATA_ATTR bool wakingPc = false; // survives deep sleep
// in the 'start' handler:
if (isPcOnline()) { // TCP connect to the agent on :8080
if (wakingPc) {
wakingPc = false;
sendTelegramMessage("✅ PC is up!");
}
return; // already on, no WOL needed
}
sendWakeOnLan(); // off → wake it
wakingPc = true; // and remember
The semantics of start are not "send a packet" but "make sure it's on". The board repeats the WOL cycle every 5 minutes until the agent answers, then clears the flag. Falling asleep halfway is impossible by construction.
Wake-on-LAN: a burst at two broadcast addresses
The magic packet is trivial: six 0xFF bytes and the NIC's MAC repeated 16 times. Two non-obvious details. Some routers and drivers only accept the limited broadcast 255.255.255.255, others only the subnet-directed one (192.168.1.255), so the board sends both. And a single packet gets lost sometimes, ARP hasn't warmed up, the NIC is still waking. Hence a burst with 100 ms gaps.
Instead of an ACK from the NIC, I check the outcome: a TCP connection to the HTTP agent's port. Agent answers, Windows is up.
The honest TLS note
Commands are fetched over HTTPS with WiFiClientSecure::setInsecure(), no certificate validation. For reading a one-word command file the risk is low: an attacker with that much control would rather grab the MQTT transport or the agent token. Still a conscious trade-off; keeping a cert anchor fresh on an ESP32 is its own operational project, and it's item one on my improve-list.
Commands: why a file instead of a port forward
The obvious alternative: forward UDP port 9 into the home network and send WOL from anywhere. I didn't, for three reasons. A forwarded port is a permanent hole in the perimeter. A single packet from outside gets dropped even more often (same ARP problem), and you can't retry from a phone reliably. And there's no feedback: "packet sent" is not "PC on".
The file channel fixes all three. The panel writes a command on the VPS, the board fetches it from inside the network, sends a burst, and confirms the boot. The cost is up to 5 minutes of latency from the poll interval, which doesn't matter for "have it running by evening". When it does matter, the panel kicks the command channel and brings up remote access instead.
Commands for the running PC go through MQTT with the same feedback philosophy.
The Windows side
start.bat in autostart launches the scripts that make the machine reachable.
The MQTT listener. There are no good native MQTT clients for PowerShell, and pulling in a .NET library for one subscription felt heavy. Unix-style answer: run mosquitto_sub.exe as a child process with redirected stdout, and read the topic line by line. Commands arrive as JSON, {"command":"cmd","param1":"Get-Process"}, and there's a 5-second debounce per command:param pair, because brokers can double-deliver, retained messages replay on reconnect, and panel buttons get double-clicked:
if ($line -match "^$([regex]::Escape($MQTT_TOPIC))\s+(.+)$") {
$msg = $matches[1] | ConvertFrom-Json
$cmd = $msg.command
$param = $msg.param1
$cmdKey = "$cmd`:$param"
if ($LastCommands.ContainsKey($cmdKey)) {
if (($now - $LastCommands[$cmdKey]).TotalSeconds -lt 5) { continue }
}
$LastCommands[$cmdKey] = $now
$cmdResult = Execute-Command -cmd $cmd -param $param
}
cmd here is actually full PowerShell, not the command line: [scriptblock]::Create($param) piped through 2>&1 | Out-String. Any script I want, with the first 500 characters of output reported back. Plus keywords (shutdown, restart, lock, sleep, hibernate) and file launching for exe/bat/ps1/python.
One encoding gotcha cost me an evening: Invoke-RestMethod mangles non-ASCII when you pass a plain string. Send UTF-8 bytes with an explicit charset and Telegram stops showing mojibake:
$utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
Invoke-RestMethod -Uri $TELEGRAM_WEBHOOK -Method Post `
-Body $utf8Bytes -ContentType "application/json; charset=utf-8"
monitor.ps1 answers "how is it doing?": CPU load, RAM, GPU stats through nvidia-smi (utilization, temperature, VRAM), disks, uptime. From another continent it's the coziest command in the system. Send it over MQTT, read in Telegram that the 5090 is cold and VRAM is free.
The idle watchdog is a guard rail against myself. A small WinForms tray app checks SystemInformation.IdleTime every 30 seconds. An hour without mouse or keyboard input: balloon warning at 50 minutes, then a modal dialog with a big CANCEL button and a 30-second timer. Miss it and it runs Stop-Computer -Force.
Notifications through my own n8n, not straight to Telegram
Home networks and messaging APIs don't always get along, and there's no VPN client on an ESP32, while Windows right after boot doesn't have one raised yet either. So every notification goes to an n8n webhook on the VPS, which forwards it to Telegram. Same reason the board's firmware holds no bot token: replacing a token or changing the forwarding rules is a workflow edit on the server, not a reflash of a board that's ten thousand kilometers away. Bonus: all system messages land in one place, and the board supports a status command that reports Wi-Fi/RSSI, chip temperature, PC state, and a human-readable reset reason.
Bill of materials
| Component | Role | Cost |
|---|---|---|
| ESP32 DevKit | alarm clock: polls the command, sends WOL | ~$5 |
| USB charger | 24/7 power for the board | already had |
| VPS | web panel + MQTT broker + command file | ~$5/mo, already had |
| PowerShell scripts | command execution | my time |
Leaving the 5090 idling would burn roughly $10–20 of electricity a month; the cloud alternative is about a dollar an hour. The board draws microamps in deep sleep.
Limits, worth knowing before you leave
- Wake-on-LAN is a one-time BIOS/UEFI setting on the NIC. Easy to forget. Test before you leave: shut the PC down, walk out, wake it from the street.
- Home internet has to be alive. Dead router, no wake-ups. The board at least reports Wi-Fi loss in its status.
- A hung Windows is unmanageable. Scripts need a live OS, and WOL can't help a machine that's already powered on. The only cure for a hard hang is cutting the mains, a smart plug as the second echelon.
- Up to 5 minutes of wake latency, by design of the poll interval.
If I started over, I'd add the smart plug from day one as hang insurance, and describe the whole two-channel architecture to my AI assistant up front: the code it generates is fine, but the decisions (state that survives sleep, why two channels, which broadcast forms exist) only came out of debugging in a real flat.
Happy to answer questions in the comments, especially about WOL quirks on different motherboards and alternatives to the file-based wake channel.
Sources for the firmware and scripts live in a private repo; I'll publish them if there's interest. Sergey Rychagov, engineer. RSA Labs, AI products and systems: rsa-labs.dev
Top comments (0)