Contents — 26 sections
Project Overview
Room-aware RGBW lighting where every node talks directly to every other over ESP-NOW — no router in the path, sub-10 ms scene changes, and lights that keep working when the Wi-Fi does not.
A lighting system built on MQTT over Wi-Fi has a structural problem: every command travels device → router → broker → router → device. That is four hops through shared infrastructure for what should be an instant local action, and when the router reboots, your lights stop responding to their own switches. For a thermostat that latency is irrelevant. For lighting, where a human is standing at a switch waiting, it is the whole experience.
This build uses ESP-NOW for the light-to-light path. ESP-NOW is Espressif's connectionless protocol that sends frames directly between ESP32s at the 802.11 MAC layer, with no association, no DHCP, no TCP and no broker. A scene change propagates across a room in under 10 ms, and it works with the router unplugged. A single gateway node bridges ESP-NOW to MQTT so the system still integrates with Home Assistant, but that bridge is a convenience rather than a dependency.
The lighting itself is RGBW rather than RGB. That extra white channel matters more than people expect: mixing white from red, green and blue gives a spiky spectrum with poor colour rendering and washed-out skin tones, and it burns three times the power for the same brightness. A dedicated warm-white channel gives genuinely pleasant white light and lets the RGB channels be used for accent and effect.
Two details separate this from a strip-of-LEDs demo. Brightness is applied through a gamma curve, because human brightness perception is roughly a power law and linear PWM produces a dimming ramp where almost all the visible change happens in the bottom 20 %. And presence detection uses a PIR plus a hold timer per zone, with adjacent zones dimming rather than switching, so walking through a house does not feel like a series of abrupt events.
What this project does
- Drives addressable WS2812B RGBW strips with per-zone colour, brightness and effects.
- Synchronises scenes across every node in under 10 ms using ESP-NOW peer-to-peer messaging.
- Continues to work with the router and internet completely down.
- Detects presence per zone and applies occupancy-based brightness with graceful fades.
- Shifts colour temperature through the day — cool at midday, warm after sunset.
- Bridges to MQTT through one gateway node for Home Assistant integration.
- Applies gamma correction so dimming feels linear to the eye.
Real-World Applications
| Setting | How it is used |
|---|---|
| Whole-room accent lighting | Cove and shelf lighting that changes together as one surface rather than as separate strips. |
| Circadian home lighting | Cool bright light in the morning and deep warm light after sunset, which is the evidence-backed part of "human-centric lighting". |
| Home cinema and gaming | Sub-10 ms scene changes make ambient light that follows on-screen content actually feel synchronised. |
| Corridor and stair safety lighting | Low-level presence-triggered light that never fully switches off is safer and less jarring at night. |
| Retail and exhibition display | Many nodes, one scene command, no network infrastructure to install. |
| Photography and video lighting | A repeatable, scriptable colour and brightness setup across multiple fixtures. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- ESP-NOW mesh with broadcast scenes and unicast acknowledgements — no broker in the critical path.
- RGBW output with a dedicated white channel for good colour rendering and efficiency.
- Gamma 2.2 correction applied in a 256-entry lookup table, so fades look smooth.
- Circadian colour temperature computed from local sunrise and sunset.
- Per-zone presence with configurable hold and adjacent-zone dimming.
- Scene persistence in NVS, so a power cut restores the previous state.
- Single gateway node bridging ESP-NOW to MQTT, so the rest of the mesh needs no Wi-Fi association.
- Smooth interpolated transitions at 50 Hz between any two states.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 12–18 hours |
| Indicative build cost | ₹5,800 – ₹8,400 for a three-zone system |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Arduino C++ and fixed-size struct packing
- Understanding of PWM, gamma and human brightness perception
- ESP-NOW basics — MAC addresses, peers, callbacks
- Power supply sizing for LED strips (this is the part people get wrong)
- Basic soldering to strip pads
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| WS2812B addressable RGB LED strip (60 LED/m) Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data. | 5 V, 60 mA per LED at full white, 800 kHz single-wire protocol, 8-bit per channel | 2 | ₹1,800 |
| HC-SR501 PIR motion sensor Give it 60 s to settle after power-up or it fires false triggers. | 3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle | 1 | ₹80 |
| 5 V 3 A regulated SMPS adapter Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 1 | ₹350 |
| LM2596 adjustable buck converter module Set the output voltage with no load connected before wiring the board. | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 1 | ₹90 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| SK6812 RGBW strip, 60 LED/m RGBW rather than RGB — the dedicated white channel is the single biggest quality improvement. | 5 V, 4 channels, 80 mA/LED at full white | 3 | ₹4,200 |
| 1000 µF electrolytic + 330 Ω resistor Standard WS2812 protection: capacitor across the supply, resistor in series with data. | 10 V low ESR; resistor in the data line | 1 | ₹30 |
| 74AHCT125 level shifter Converts 3.3 V data to 5 V. Long strips are unreliable without it. | Quad buffer, 5 V logic | 1 | ₹60 |
| 5 V 20 A power supply Size from the calculation in the equations section, not from optimism. | For 3 m of RGBW at full brightness | 1 | ₹1,600 |
Estimated total: ₹8,980, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| WS2812B addressable RGB LED strip (60 LED/m) | 5 V, 60 mA per LED at full white, 800 kHz single-wire protocol, 8-bit per channel | 5 V | 1-wire timed protocol | Datasheet |
| HC-SR501 PIR motion sensor | 3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle | 4.5–20 V | Digital high on motion | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| WS2812B addressable RGB LED strip (60 LED/m) | 5 V | 120 | Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data. |
| HC-SR501 PIR motion sensor | 4.5–20 V | 0.05 | Give it 60 s to settle after power-up or it fires false triggers. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
Summed typical draw is 3288.05 mA. With a 1.5× design margin the supply should deliver at least 5000 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| FastLED 3.6.x | Timing-exact WS2812B driver with colour-correction and palettes. | Library Manager → "FastLED" |
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| PIR presence sensor | OUT | GPIO 27 | High on motion |
| Wall button | NO | GPIO 32 | Short press cycles scenes |
| Rotary encoder | A / B | GPIO 33 / 25 | Brightness |
| SK6812 RGBW strip | DIN | GPIO 5 → 74AHCT125 → strip | 800 kHz, 330 Ω series |
| Second strip segment | DIN | GPIO 18 | Separate channel for long runs |
| Status LED | Anode | GPIO 2 | Mesh health |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- Inject power at both ends of any strip longer than about 2 m, and at the middle for runs beyond 4 m. Copper traces on LED strip are thin; voltage drop along the run turns pure white at one end into dingy yellow at the other.
- Fit a 1000 µF capacitor across the strip's 5 V and GND at the injection point, and a 330 Ω resistor in series with the data line at the controller. Both are in the WS2812B application notes and both prevent real failures — capacitor for supply transients at power-on, resistor for data-line ringing.
- Use a 74AHCT125 level shifter to convert the ESP32's 3.3 V data to 5 V. The WS2812 datasheet requires a logic high of at least 0.7 × VDD, which is 3.5 V — a 3.3 V signal is out of specification. It often works at short range and fails intermittently at length, which is the worst kind of bug.
- The controller ground and the LED supply ground must be joined. Without a common ground the data line has no reference and the strip shows random colours.
- Never power a long strip through the ESP32 board. Feed the strip from the supply directly and take a separate regulated 5 V feed to the controller.
- Mount the PIR so it does not see the LED strip. Some strips produce enough thermal IR variation at high brightness to false-trigger a nearby PIR.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
ESP-NOW sits below the IP stack. Each ESP32 registers peers by MAC address and then sends frames of up to 250 bytes directly, with the radio staying on a fixed channel. There is no association, no DHCP lease, no TCP handshake and no broker round trip — a send-to-receive latency of 2–4 ms is typical, against 30–150 ms for the same command over Wi-Fi and MQTT. The trade-offs are real: 250 bytes per frame, up to 20 encrypted peers, and no routing beyond direct radio range. For lighting inside one building, all three are acceptable.
The gateway node is the only device that associates with Wi-Fi, and it does so on the same channel the mesh uses. This is the detail that breaks most ESP-NOW-plus-Wi-Fi projects: an ESP32 has one radio, so if the Wi-Fi association puts it on channel 6 while the mesh runs on channel 1, ESP-NOW traffic is simply missed. The gateway therefore reads its Wi-Fi channel after association and the whole mesh is configured to match.
Gamma correction exists because perceived brightness is roughly proportional to physical luminance raised to about 1/2.2. A linear PWM ramp from 0 to 255 therefore looks like a fast bright rise followed by a long flat stretch. Applying out = round(255 × (in/255)^2.2) through a lookup table makes a linear input produce a perceptually linear fade. It also improves low-end resolution where the eye is most sensitive to steps.
RGBW versus RGB is worth understanding properly. Making white from three narrow-band LEDs gives a spectrum with three spikes and deep gaps, so a colour rendering index around 20–40 — objects look wrong and skin looks unpleasant. A phosphor-converted white LED has a broad spectrum and a CRI of 80–90. It is also about three times more efficient: one white LED at 20 mA produces roughly the same white output as three colour LEDs at 20 mA each.
The colour temperature schedule is the one genuinely evidence-backed part of "circadian lighting". Blue-rich light in the evening suppresses melatonin; warm light does not. Scheduling the white channel from around 5000 K at midday to 2200 K after sunset, with the transition tied to actual local sunset rather than a fixed clock time, is a modest and well-supported intervention.
The maths behind it
LED strip power budget
SK6812 RGBW: ~20 mA per channel, 80 mA per LED at full white
3 m at 60 LED/m = 180 LEDs
worst case = 180 × 80 mA = 14.4 A at 5 V = 72 W
Design supply at 1.4x: 14.4 × 1.4 = 20 A
Realistic average at 40 % brightness, warm white only:
180 × 20 mA × 0.4 = 1.44 A
Size the supply for worst case, not for what
you expect to use — a white flash at full brightness
on an undersized supply browns out the controller.
Voltage drop along a strip
Strip copper ~ 0.5 Ω per metre per rail (there and back = 1 Ω/m)
Current at the far end of a 3 m run at 50 % white:
I = 180 × 40 mA = 7.2 A entering the strip
Average drop over the run (current falls linearly):
V_drop ≈ I × R × L / 2 = 7.2 × 1.0 × 3 / 2 = 10.8 V
Which is impossible on a 5 V rail — the far end simply
goes dim and orange. Hence power injection every 2 m,
which cuts the effective L per segment to 1 m.
Gamma correction
out = round(255 × (in / 255)^γ), γ = 2.2
in= 16 → out= 0
in= 64 → out= 12
in= 128 → out= 55
in= 192 → out=137
in= 255 → out=255
Precompute all 256 values into a PROGMEM table —
pow() at 50 Hz across 180 LEDs is far too slow.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Smart Lighting Mesh — ESP32 + SK6812 RGBW + ESP-NOW
Every node runs this sketch. One node is compiled as the gateway
(IS_GATEWAY 1) and additionally bridges ESP-NOW to MQTT. Scenes
propagate peer-to-peer in under 10 ms and survive a router outage.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <esp_now.h>
#include <FastLED.h>
#include <Preferences.h>
#if IS_GATEWAY
#include <PubSubClient.h>
#include <ArduinoJson.h>
#endif
#define ZONE_ID 1 // unique per node: 1, 2, 3 ...
#define IS_GATEWAY 0
#define MESH_CHANNEL 1 // must match the router channel
#define PIN_LEDS 5
#define PIN_PIR 27
#define PIN_BTN 32
#define NUM_LEDS 60
#define FPS 50
CRGB leds[NUM_LEDS];
uint8_t whiteCh[NUM_LEDS]; // SK6812 W channel handled separately
Preferences prefs;
/* Scene packet — must be identical on every node, hence packed. */
typedef struct __attribute__((packed)) {
uint8_t magic; // 0xA7 — reject foreign traffic
uint8_t zone; // 0 = all zones
uint8_t r, g, b, w;
uint8_t brightness;
uint8_t effect; // 0 solid, 1 breathe, 2 chase
uint16_t fadeMs;
uint32_t seq; // de-duplicates re-broadcasts
} Scene;
Scene current = { 0xA7, 0, 255, 180, 90, 200, 128, 0, 600, 0 };
Scene target = current;
uint32_t lastSeq = 0, fadeStart = 0, lastPresence = 0;
Scene fadeFrom;
uint8_t broadcastMac[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
/* ── gamma table ────────────────────────────────────────────── */
uint8_t GAMMA[256];
void buildGamma() {
for (int i = 0; i < 256; i++)
GAMMA[i] = (uint8_t)(powf(i / 255.0f, 2.2f) * 255.0f + 0.5f);
}
/* ── ESP-NOW ────────────────────────────────────────────────── */
void onRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
if (len != sizeof(Scene)) return;
Scene s;
memcpy(&s, data, sizeof(s));
if (s.magic != 0xA7) return; // not ours
if (s.seq <= lastSeq) return; // already applied
if (s.zone != 0 && s.zone != ZONE_ID) return;
lastSeq = s.seq;
fadeFrom = current;
target = s;
fadeStart = millis();
}
void meshSend(const Scene &s) {
esp_now_send(broadcastMac, (const uint8_t *)&s, sizeof(s));
}
void meshBegin() {
WiFi.mode(IS_GATEWAY ? WIFI_AP_STA : WIFI_STA);
#if !IS_GATEWAY
WiFi.disconnect(); // no association needed
esp_wifi_set_channel(MESH_CHANNEL, WIFI_SECOND_CHAN_NONE);
#endif
if (esp_now_init() != ESP_OK) { Serial.println("ESP-NOW init failed"); return; }
esp_now_register_recv_cb(onRecv);
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, broadcastMac, 6);
peer.channel = MESH_CHANNEL;
peer.encrypt = false; // broadcast cannot be encrypted
esp_now_add_peer(&peer);
}
/* ── rendering ──────────────────────────────────────────────── */
uint8_t lerp8(uint8_t a, uint8_t b, float t) {
return (uint8_t)(a + (int)((b - a) * t));
}
void render() {
float t = 1.0f;
if (target.fadeMs) {
uint32_t el = millis() - fadeStart;
t = el >= target.fadeMs ? 1.0f : (float)el / target.fadeMs;
t = t * t * (3 - 2 * t); // smoothstep, not linear
}
current.r = lerp8(fadeFrom.r, target.r, t);
current.g = lerp8(fadeFrom.g, target.g, t);
current.b = lerp8(fadeFrom.b, target.b, t);
current.w = lerp8(fadeFrom.w, target.w, t);
current.brightness = lerp8(fadeFrom.brightness, target.brightness, t);
float bScale = current.brightness / 255.0f;
for (int i = 0; i < NUM_LEDS; i++) {
uint8_t r = current.r, g = current.g, b = current.b, w = current.w;
if (target.effect == 1) { // breathe
float ph = (sinf(millis() / 1200.0f + i * 0.02f) + 1) * 0.5f;
float k = 0.6f + 0.4f * ph;
r *= k; g *= k; b *= k; w *= k;
} else if (target.effect == 2) { // chase
float ph = fmodf(millis() / 12.0f - i * 4.0f, (float)NUM_LEDS * 4);
float k = ph < 24 ? 1.0f : 0.25f;
r *= k; g *= k; b *= k; w *= k;
}
leds[i] = CRGB(GAMMA[(uint8_t)(r * bScale)],
GAMMA[(uint8_t)(g * bScale)],
GAMMA[(uint8_t)(b * bScale)]);
whiteCh[i] = GAMMA[(uint8_t)(w * bScale)];
}
FastLED.show();
}
/* ── circadian white point ──────────────────────────────────── */
void circadianUpdate(int hour) {
// Approximate: cool and bright midday, warm and dim after sunset.
uint8_t w, r, g, b;
if (hour >= 7 && hour < 17) { w = 255; r = 0; g = 0; b = 30; } // ~5000 K
else if (hour >= 17 && hour < 21){ w = 200; r = 60; g = 20; b = 0; } // ~3000 K
else { w = 110; r = 100; g = 25; b = 0; } // ~2200 K
if (target.w == w && target.r == r) return; // already there
fadeFrom = current;
target.r = r; target.g = g; target.b = b; target.w = w;
target.fadeMs = 20000; // 20 s — imperceptible
target.seq = ++lastSeq;
fadeStart = millis();
meshSend(target);
}
/* ── presence ───────────────────────────────────────────────── */
void presenceService() {
if (digitalRead(PIN_PIR) == HIGH) lastPresence = millis();
bool occupied = millis() - lastPresence < 180000UL; // 3 min hold
uint8_t want = occupied ? 200 : 40; // dim, never off
if (abs((int)target.brightness - (int)want) < 8) return;
fadeFrom = current;
target.brightness = want;
target.fadeMs = occupied ? 400 : 4000; // fast on, slow off
target.seq = ++lastSeq;
fadeStart = millis();
meshSend(target);
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_PIR, INPUT);
pinMode(PIN_BTN, INPUT_PULLUP);
buildGamma();
FastLED.addLeds<WS2812B, PIN_LEDS, GRB>(leds, NUM_LEDS);
FastLED.setMaxPowerInVoltsAndMilliamps(5, 12000); // hard current cap
FastLED.clear(true);
prefs.begin("light", false);
if (prefs.getBytesLength("scene") == sizeof(Scene))
prefs.getBytes("scene", ¤t, sizeof(Scene));
target = current;
meshBegin();
Serial.printf("Zone %d ready, MAC %s\n", ZONE_ID, WiFi.macAddress().c_str());
}
void loop() {
static uint32_t lastFrame = 0, lastSlow = 0, lastSave = 0;
if (millis() - lastFrame >= 1000 / FPS) { lastFrame = millis(); render(); }
if (millis() - lastSlow >= 200) {
lastSlow = millis();
presenceService();
if (digitalRead(PIN_BTN) == LOW) { // cycle effect
target.effect = (target.effect + 1) % 3;
target.seq = ++lastSeq;
meshSend(target);
while (digitalRead(PIN_BTN) == LOW) delay(10);
}
}
if (millis() - lastSave >= 30000) {
lastSave = millis();
prefs.putBytes("scene", &target, sizeof(Scene));
}
}
Configuration & Calibration
Configuration steps
- Set a unique
ZONE_IDfor every node, and compile exactly one node withIS_GATEWAY 1. - Set
MESH_CHANNELto match your router's 2.4 GHz channel. Fix the router to a specific channel rather than leaving it on auto, or the gateway will drift away from the mesh. - Set
NUM_LEDSper node and usesetMaxPowerInVoltsAndMilliampsto match your actual supply, with margin. - For SK6812 RGBW, use a FastLED build with RGBW support or drive the white channel through a second logical strip — the sketch keeps
whiteCh[]separate for that reason. - Adjust the circadian schedule to your latitude. Fixed hours are a simplification; tying it to the computed sunset (as in the curtain project) is better.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Verify gamma looks right
Fade from 0 to 255 over ten seconds and watch. With correct gamma the perceived change is even throughout. Without it, almost all the visible change happens in the first two seconds.
Measure the actual current draw
Command full white at maximum brightness with a clamp meter on the supply lead. Compare against your calculation. If the measurement is well below the calculation, FastLED's power limiter is scaling you back — which means the supply is undersized.
Check ESP-NOW latency
Toggle a scene from one node and time the response on another with a phone camera at 240 fps. Under 10 ms means one or two frames — anything approaching 100 ms means the nodes are on different channels and traffic is being relayed by luck.
Network Architecture & Connectivity
Communication protocol
ESP-NOW frames are 250 bytes maximum and connectionless, so there is no retransmission or ordering guarantee. This design compensates with a sequence number and by making every message a full state description rather than a delta — a lost frame is corrected by the next one, with no accumulated error.
Broadcast frames cannot be encrypted in ESP-NOW. If confidentiality matters, use unicast to explicitly registered peers with PMK/LMK encryption, at the cost of a 20-peer limit and per-peer sends.
| Topic / endpoint | Direction | Payload |
|---|---|---|
home/light/zone1/set | broker → gateway → mesh | JSON: state, brightness, color, effect, transition |
home/light/zone1/state | gateway → broker (retained) | JSON: current scene for the zone |
Message contract between the device and the broker.
Security considerations
- Add a shared secret beyond the magic byte if the mesh is in a shared building — ESP-NOW broadcast is readable by anyone with an ESP32 on the same channel.
- The lighting keeps working with the gateway down, which is a resilience feature and also means a compromised gateway cannot brick the lights.
- Keep the gateway on an IoT VLAN like every other bridged device.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Power one node with a short strip | The strip lights to the stored scene within a second of boot, with no flash of random colour. |
| Fade brightness from 0 to full | A perceptually even ramp with no visible steps, especially at the low end. |
| Change a scene on one node | Every other node changes within one video frame at 60 fps — visually simultaneous. |
| Unplug the router | Scene changes between nodes still work. Only Home Assistant control is lost. |
| Command full white at maximum brightness | No flicker, no controller reset, and supply voltage staying above 4.8 V at the far end of the strip. |
| Walk into the zone | Brightness rises over about 400 ms; after three minutes of stillness it fades down over four seconds to the dim level, never fully off. |
| Power-cycle a node | It restores the last scene from NVS and resynchronises on the next mesh broadcast. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Precompute the gamma table at boot. Calling
powf()for 180 LEDs at 50 fps is 9000 float power operations a second and will visibly cost you frame rate. - Render at 50 fps, not as fast as possible. WS2812 output is a blocking bit-banged operation; running it flat out starves everything else and gains nothing perceptible.
- Send full state rather than deltas over ESP-NOW. It makes lost frames self-correcting and removes any need for retransmission logic.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- A 20 A 5 V supply can deliver enough current to start a fire through a thin wire. Fuse the output and use conductors sized for the full rating, not for the expected load.
- LED strips get warm at high duty. Mount them on aluminium channel if they run above 50 % brightness for long periods — adhesive backing on a plastic surface can soften.
- Do not stare at high-power LEDs at close range, particularly the blue channel at full output.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add ESP-NOW encryption with unicast peers for shared buildings.
- Add mmWave presence sensing so a stationary person does not get dimmed on.
- Add screen colour capture on a PC that broadcasts an ambient scene at 30 Hz — the low ESP-NOW latency is what makes this feel synchronised rather than laggy.
- Add true tunable white with separate warm and cool white channels for proper CCT control rather than an approximation.
- Move to Matter over Thread for the bridged half so the system works with every ecosystem.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- ESP-NOW — ESP-IDF programming guideEspressif
- WS2812B intelligent control LED — datasheetWorldsemi
- SK6812 RGBW LED — datasheetOpsco
- Adafruit NeoPixel Überguide — power, level shifting and best practiceAdafruit
- FastLED library documentation and power managementFastLED
- Gamma correction and perceptual brightnessAdafruit
- Light at night and melatonin suppression — a reviewJournal of Clinical Endocrinology & Metabolism