How it fits together

XIAO ESP32-S3  --ESP-NOW-->  ESP32 master  --UART-->  ESP32 bridge  --HTTP-->  Controller  --JSON API-->  Blue Iris
(battery, asleep)            (no WiFi)                (WiFi + OTA)             (Python)                   (9 cameras)

A press travels four hops. Two acknowledgements come back the other way to the button's LED: one when the master hears the press (a few milliseconds), and one when Blue Iris has been told to save (about a second).

Most of the interesting problems were in that chain, so that's what this write-up covers first.

The button

The button is a Seeed XIAO ESP32-S3 on a 1S LiPo, asleep almost all the time. It has one GPIO for the switch, one for an LED, no WiFi password and no IP stack.

The board choice decides the battery life. Both a generic ESP32 devkit and the XIAO have chips that sleep at 7 to 10 µA, but the devkit's USB-serial chip and LDO keep drawing 4 to 20 mA in deep sleep. The XIAO has native USB and a buck regulator, and Seeed rates it around 14 µA asleep (fed from the BAT pads; through the 5 V pin it's closer to 300 µA). At twenty presses a day that works out to roughly a year and a half on an 1150 mAh cell, and sleep current is most of the budget until you press more than about five times a day.

The wake path is short on purpose:

  1. Wake on the button pin going low (or a 12-hour timer, which sends a telemetry frame and goes back to sleep).
  2. Debounce before spending any radio time: 5 samples, 5 ms apart, must agree. A wake is a claim until the debouncer confirms it.
  3. Send the PRESS frame to the master, up to 3 tries with the same sequence number.
  4. Wait up to 400 ms for the first ack, then up to 9 s for the Controller's verdict.
  5. Wait for release, lock out for 1.5 s (so an enthusiastic double-tap saves one moment, not two), and sleep.

Three things bit me on the S3 and are easy to mistake for firmware bugs:

  • The XIAO has no onboard antenna. It ships with a loose U.FL antenna. Unplugged, the range collapses and it looks like the master is down.
  • The USB console is the chip. In deep sleep the COM port vanishes and comes back on every wake. I build a bench variant with deep sleep disabled instead of editing code I'd forget to undo.
  • CONFIG_FREERTOS_HZ must be 1000. At the IDF default of 100, pdMS_TO_TICKS(5) rounds to zero and the debounce does nothing.

Also: re-arm the pull-up in the RTC domain before sleeping (the normal GPIO pull-ups power down), and never arm the wake pin while the button is still held, or a stuck button wakes the chip forever on a battery.

ESP-NOW: why three boards instead of one

ESP-NOW and WiFi station mode share one radio, and a station follows its access point's channel. Put the button's receiver on the same chip as the WiFi connection and every ESP-NOW node has to follow the router's channel, including after a reboot picks a new one. That's the failure where the button works for a month and then stops, which is the worst behaviour for a device whose only job is catching something that already happened.

So the work is split across three boards:

  • The button (XIAO ESP32-S3, on the LiPo) wakes, sends one frame, shows the result and goes back to sleep.
  • The master (a plain ESP32 on mains power) is the ESP-NOW receiver for the whole shop. It verifies, dedupes and acks, and never joins WiFi.
  • The bridge (another plain ESP32 on mains) joins WiFi, takes frames from the master over UART, and POSTs them to the Controller.

The extra board costs about $4. In exchange the ESP-NOW channel is fixed forever, the buttons hold no WiFi credentials (lose one and nothing on the network is exposed), a press involves no WiFi association (about 10 ms of radio instead of 1 to 3 s of joining), and the master is about 300 lines that never need updating.

Pick an ESP-NOW channel at least 5 away from your access point (AP on 6, ESP-NOW on 1 or 11). An adjacent channel is worse than sharing one.

The frame

Every frame is a 28-byte packed header plus up to 160 bytes of ASCII payload. The header, byte by...

Read more »