loc kettering, uk
grid 142 gCO₂/kWh
solar 3.8 kW export
last deploy 46b219f

On-Air LED sign, part 1: ESP32-C6 firmware and AWS IoT Core

How the sign firmware works — JSON state over MQTT, WS2812 animations, and the local-first design that makes cloud optional.

The sign is a WS2812 LED strip mounted behind a laser-cut panel. It glows amber when I’m on a call, pulses when a meeting is about to start, and goes dark otherwise. The trigger is a Chrome extension running in my browser — more on that in a separate post. This post is about the firmware side: what runs on the ESP32-C6, how it talks to AWS IoT Core, and why the cloud component is deliberately thin.

Hardware

ESP32-C6 on a custom board. WS2812B strip on GPIO8, driven by the RMT peripheral — no bit-banging, no timing jitter. The C6 was the right choice here: Wi-Fi 6, native USB for flashing, and a proper hardware peripheral for the LED protocol.

State machine

The firmware is a single state machine with five states:

StateLED behaviour
IDLEoff
STANDBYslow amber pulse
ON_AIRsolid amber
WARNINGfast amber flash
OTAblue sweep

State transitions are driven by MQTT messages. The payload is a small JSON object:

{ "state": "ON_AIR", "source": "meet" }

The source field is informational — it doesn’t affect the LED behaviour but it’s logged, which helps when debugging why the sign lit up unexpectedly.

AWS IoT Core

The device connects to IoT Core over TLS with a per-device X.509 certificate. Certificates are provisioned at flash time and stored in NVS. The topic structure is flat:

onair/{device_id}/state      ← firmware subscribes
onair/{device_id}/shadow     ← device shadow (retained state)

IoT Core’s device shadow is the source of truth for the last-known state. On reconnect, the firmware fetches the shadow document and restores the correct LED state without waiting for a new publish. If the Wi-Fi drops mid-meeting and comes back, the sign recovers correctly.

Local-first

The sign can also receive state updates over plain HTTP on the local network — POST /state with the same JSON body. This matters because the Chrome extension tries the local endpoint first and only falls back to the IoT Core path when the device isn’t reachable directly (i.e., when I’m working remotely and the laptop isn’t on the home network). The firmware runs a small HTTP server alongside the MQTT client; both write to the same state machine.

No cloud dependency for the common case. The AWS path exists for the less-common case, not the other way around.

OTA

Firmware updates go over the air using the ESP-IDF OTA partition scheme — two app partitions, atomic swap on successful boot. The OTA LED state gives visual confirmation that an update is in progress. Rollback on boot failure is automatic.

Part 2

The missing piece is the HTTP-to-MQTT bridge: an API Gateway endpoint backed by a Lambda that publishes to IoT Core. The Chrome extension needs a stable HTTPS URL to hit when the device isn’t on the local network. That’s the next post.