The problem
Architectural lighting on heritage buildings is switched from an outdoor cabinet, often on a wall in a courtyard, sometimes on a bridge. There is no wired network there and usually no Wi-Fi. The client (a lighting company I also work for as technical director) needed every cabinet to follow a sunrise/sunset schedule, to be switchable from a phone, and to report when something is wrong — a tripped breaker, a dead contactor, a dark facade — before a resident calls the city.
Off-the-shelf GSM relays do the switching but not the rest: no confirmation that the light is actually on, no fleet view, no firmware updates in the field. So I built the whole stack.
Hardware: one 87 mm form factor, several boards
All boards share an 87 mm width so they stack inside a standard DIN cabinet. Designed in KiCad, fabricated in several revisions (the silkscreen on the current core says FOURLEDS Online controller v. 2.11.7):
- Core — ESP32-C3-WROOM-02 plus a SimCom A7672E LTE Cat-1 modem, micro-SIM, SMA antenna, twelve SN74LVC1T45 level shifters for the 1.8 V modem UART, MIC29302 LDOs and NSD15 isolated DC-DC so the same board runs from 12 V or 48 V, a DS3231 RTC, a VL53L4CX time-of-flight sensor and Cloud/4G status LEDs. 102.8 × 87 mm, two layers, 75 footprints, 1188 tracks.
- Coil — seven HF32FV-16 16 A relays driving the contactors, MOV protection, DS3231 RTC, HDC1080 temperature/humidity sensor, ADM2582E isolated RS-485. A slim 32.9 × 86.5 mm board.
- Sensor — eight 220 V presence inputs through LTV-817S optocouplers, so the server knows whether the mains feed, the meter output, the contactor and each lighting group are actually live.
- DALI — a bus-powered DALI interface (EL357N optos, MB6S bridge, isolated URB2403S supply), still work in progress.
An earlier monolithic All-in-One (ESP32-S3 + SIM800C + BL0937 energy metering, 229 footprints) and a 2025 Simple LED Controller show where the modular design came from.
Firmware: assume the network will fail
The firmware is ESP-IDF 5.4, C++17, built with PlatformIO. The cellular link is PPP over 3GPP 27.010 CMUX, which leaves a parallel AT channel open for SMS and signal-quality polling while the data session is up. Everything above it is MQTT to an EMQX broker.
The part that took the most field time is the recovery ladder. Carrier networks behind CGNAT drop idle sessions silently; a modem can look connected and be black-holed. The controller escalates in order: MQTT reconnect → PDP context re-activation → re-initialising the modem DCE/DTE → a PWRKEY power cycle → a hardware RESET. Each step is timed and counted.
To see what actually happens in the field, every board keeps a stability log in RTC_NOINIT memory, so it survives resets, and ships it to the server. Multi-week analyses of those logs traced outages to two causes — carrier CGNAT blackholes and heap starvation during reconnect storms — and produced targeted fixes: storm-aware backoff, reconnect-burst suppression and a downlink keepalive.
Between boards inside one cabinet there is no Wi-Fi network at all; the core talks to coil and sensor boards over ESP-NOW with ack/dedupe rings and idempotent command replay, and can push firmware to them (dependency OTA). Coil boards keep a local schedule so the lights keep switching if the core loses the network.
SMS is the transport of last resort: commands and status are sealed with AES-GCM so a spoofed text cannot switch a facade.
Server and dashboard
The fleet server is FastAPI with SQLAlchemy async and Alembic (58 migrations, 40 test modules), PostgreSQL 17 with TimescaleDB for telemetry, EMQX Enterprise for MQTT. It runs an MQTT consumer, a command service with a watchdog, a sunrise/sunset scheduler (astral), an OTA engine whose artifact library is published automatically from the PlatformIO build, and power/relay/compliance analytics that compare what was scheduled with what the sensor board actually saw. A Telegram bot reports and takes commands.
The dashboard is SvelteKit 2 / Svelte 5 with a MapLibre fleet map, per-device pages (ping, stability log, relay history, compliance), a schedule editor, OTA, audit and raw MQTT/ESP-NOW admin views, in two languages.
Numbers
About 109 000 lines across firmware, server and dashboard; nine KiCad projects with gerbers, interactive BOMs and 3D exports; roughly 18 sites in production, including facades of listed buildings in several cities.
What I would do differently
Start with the stability log. I added it after the first winter, and every real fix since came from its data, not from bench tests. And I would put the sensor board into the first revision instead of trusting relay state — knowing the contactor closed is not the same as knowing the facade is lit.