An iroh powered smart fan

Learn how to build a decentralized smart fan using an ESP32 and the Iroh networking protocol, completely bypassing the cloud.
If you live in ~~Europe~~ the northern hemisphere, you are probably suffering through a heat wave right now. Let's do something to bring back some chill, using iroh.
The previous ESP32 examples demonstrated echo protocols. But typically ESP32s are used for more than just echoing data; you use an ESP32 as a cheap means to read sensors and drive actuators.
So we are going to write a very simple end-to-end example using an ESP32 to measure temperature and control a fan. Unlike most IoT devices, there won't be any cloud component. Just a tiny website that you can use from anywhere in the world using any browser that supports WebAssembly.
As the base, we are going to use an ESP32-WROVER devkit with 4 MiB of PSRAM, so we have all of iroh's networking capabilities available, including a relay connection, and remote control it from anywhere in the world. You can also use a M5StickC-Plus2, but you will have to adapt the GPIO pins.
As the first step, we are going to copy over an echo example from iroh-esp32-examples. We will use server-esp32-psram for the ESP32 binary.
For the client side we just use client, it runs on a desktop PC and is as vanilla as it gets.
This is going to be a smart fan example, so we just rename server-esp32-psram to server-smart-fan, and client to smart-fan-cli.
Note that we need different toolchains and want to keep the option to use a patch of iroh for the ESP32 variant, so the two directories are completely separate Rust projects. We do not use a workspace.
Let's try it out once before we do modifications. cargo run on the server project will search for an ESP32 connected via USB and flash it. So we just connect our ESP32 with a USB-C cable.
The initial release build will take some time, since we are compiling not just iroh, but also the operating system to the xtensa architecture. Subsequent builds will be faster, since the compilation results are cached in the .embuild directory.
Flashing itself will never be really fast, because the data rate to the chip is very limited. We can make it go a bit faster by setting the ESPFLASH_BAUD environment variable. My chip supports 230400 baud, but YMMV. If it doesn't work just run without the environment variable set, then it will use safe defaults.
We need to tell the ESP32 how to connect to WLAN. In the example we just use another environment variable WIFI_CONFIG=SSID:PASSWORD. Set this to your local WLAN.
You can do a single export WIFI_CONFIG=SSID:PASSWORD so you don't have to pass it every single time.
ESPFLASH_BAUD=230400 WIFI_CONFIG=myap:mypass cargo run --release
❯ cargo run --release
Finished `release` profile [optimized] target(s) in 0.56s
Running `espflash flash --monitor target/xtensa-esp32-espidf/release/esp32-psram`
[2026-07-01T07:14:23Z INFO ] 🚀 A new version of espflash is available: v4.4.0
[2026-07-01T07:14:23Z INFO ] Serial port: '/dev/cu.usbserial-210'
[2026-07-01T07:14:23Z INFO ] Connecting...
[2026-07-01T07:14:30Z INFO ] Using flash stub
Chip type: esp32 (revision v3.1)
Crystal frequency: 40 MHz
Flash size: 4MB
Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None
MAC address: 00:70:07:19:c8:4c
App/part. size: 3,953,296/4,128,768 bytes, 95.75%
[00:00:00] [========================================] 17/17 0x1000 Skipped! (checksum matches) [00:00:00] [========================================] 1/1 0x8000 Skipped! (checksum matches) [00:04:11] [========================================] 2295/2295 0x10000 Verifying... OK! [2026-07-01T07:18:43Z INFO ] Flashing has completed!
As you can see from the flash output, we are pretty close to the limit of the flash size.
App/part. size: 3,953,296/4,128,768 bytes, 95.75%
You might think that every single added line of code will get you over the limit, but that is not the case. Additional pure Rust dependencies such as irpc add very little size.
What we should have now is a simple echo server running on the ESP32.
Endpoint Id
First of all, how do we assign the endpoint id? We want the ability to assign an endpoint id, but even if we don't do so we want the endpoint id to be stable after reboots. So the ESP32 should not generate a random one on each startup.
Instead we generate and store the secret key in non volatile memory on first startup and reuse it on subsequent startups. Non volatile memory is not overwritten by flashing, so we will get the same endpoint id for the same device unless we explicitly delete non volatile memory.
Startup
On startup the device will try to connect to WiFi using the given credentials. If that doesn't work it will hang. This happens before any iroh endpoint setup.
Once the endpoint on the ESP32 starts up, we get very familiar output:
I (7413) server_esp32_psram: Iroh endpoint bound
I (7413) server_esp32_psram: Listening on: 192.168.0.186:51831
I (7413) server_esp32_psram: Endpoint ID: 03b43add965a3eaa2d20d3b60dcb1aa2fa8fdd36cdc1544511af11c26f45fd4b
I (7423) server_esp32_psram: Short ticket: endpointaab3iow5sznd5krnedj3mdoldkrpvd65g3g4cvcfcgxrdqtpix6uwaa
I (7433) server_esp32_psram: Long ticket: endpointaab3iow5sznd5krnedj3mdoldkrpvd65g3g4cvcfcgxrdqtpix6uwaibadakqaf266kag
I (7443) server_esp32_psram: Router started, accepting connections
The device has been assigned a local IP address 192.168.0.186:51831 by the DHCP of the router. It prints both a long and short ticket, but for now is only reachable locally using the long ticket that contains the IP address.
Next it tries figuring out its location in the world using QAD.
W (7473) iroh::net_report: QADv4; relay_url=https://aps1-1.relay.n0.iroh.link./
W (7473) iroh::net_report: QADv4; relay_url=https://euc1-1.relay.n0.iroh.link./
W (7483) iroh::net_report: QADv4; relay_url=https://use1-1.relay.n0.iroh.link./
W (7493) iroh::net_report: QADv4; relay_url=https://usw1-1.relay.n0.iroh.link./
Assuming you are connected to the internet, after a short time it will figure out which relay is closest and set that as its home relay.
I (8963) iroh::socket::transports::relay::actor: home is now relay https://euc1-1.relay.n0.iroh.link./, was None
At this point it is reachable from anywhere in the world using the short ticket that contains just the endpoint id.
Connecting
So now let's try it out using the client binary.
❯ cargo run endpointaab3iow5sznd5krnedj3mdoldkrpvd65g3g4cvcfcgxrdqtpix6uwaa
...
Discovery: relay=on, mdns=on
Connecting to ESP32...
Connected!
Sent: Hello from iroh!
Received: Hello from iroh!
Echo OK — crates.io iroh <-> ESP32!
Locally you can use the long ticket and bypass the relay, but as soon as the endpoint has published its home relay it should be reachable globally.
Shutdown
You might think that stopping the cargo run --release will stop the binary. But this is not the case. It just stops the connection to the device. The endpoint will happily continue to run as long as it has power.
You can even disconnect it and plug it into a separate USB-C power supply, and it will boot up again with the same endpoint id. This is the whole point. The ESP32 is a fully self-contained embedded computer. It just needs power.
If you really want to shut it down, unplug it or delete the flash using espflash erase-flash.
Now that we have confirmed that the example works, we can start making it actually do something.
Since we want to build a smart fan, the first thing we need is a temperature sensor. We are going to use a DHT22 temperature and humidity sensor.
Wiring up the sensor is very simple. It has three wires, two for +3.3V (do not use +5V!) and GND and one for data.
If you use the ESP32 dev kit with the extension board and a breadboard, it will power the breadboard rails with +3.3V and +5V from the USB port. You can pull only ~100 mA without an external power supply, but it is enough for the DHT22, which only takes 1.5 mA while measuring and even less when idle.
We need to c
Source: Hacker News
















