Siddhant Kumar
Project 009 · Smart Home

Smart Lighting Mesh.

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.

Intermediate 12–18 hours 36 min read MeshRGBWAutomation
Jump to source Bill of materials
Smart Lighting Mesh — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Intermediate
Build time
12–18 hours
Indicative cost
₹5,800 – ₹8,400 for a three-zone system
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
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.

A schematic of a feed-forward artificial neural network
A network diagram. The lighting mesh is genuinely peer-to-peer — every node hears every other directly, with no central hub in the path. Photograph sourced from Wikimedia Commons — Artificial neural network.svg. Reused under the licence stated on that page; please check it before republishing.

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

SettingHow it is used
Whole-room accent lightingCove and shelf lighting that changes together as one surface rather than as separate strips.
Circadian home lightingCool bright light in the morning and deep warm light after sunset, which is the evidence-backed part of "human-centric lighting".
Home cinema and gamingSub-10 ms scene changes make ambient light that follows on-screen content actually feel synchronised.
Corridor and stair safety lightingLow-level presence-triggered light that never fully switches off is safer and less jarring at night.
Retail and exhibition displayMany nodes, one scene command, no network infrastructure to install.
Photography and video lightingA 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

AttributeValue
Difficulty levelIntermediate
Estimated completion time12–18 hours
Indicative build cost₹5,800 – ₹8,400 for a three-zone system
Primary disciplineSmart Home
Reference platformESP32 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.

ComponentKey specificationQtyApprox. 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 DAC1₹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 channel2₹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 idle1₹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 protection1₹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 % efficiency1₹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 grid1₹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 lugs1₹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 white3₹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 line1₹30
74AHCT125 level shifter
Converts 3.3 V data to 5 V. Long strips are unreliable without it.
Quad buffer, 5 V logic1₹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 brightness1₹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

PartSpecificationSupplyInterfaceReference
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 DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
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 channel5 V1-wire timed protocolDatasheet
HC-SR501 PIR motion sensor3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle4.5–20 VDigital high on motionDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
WS2812B addressable RGB LED strip (60 LED/m)5 V120Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data.
HC-SR501 PIR motion sensor4.5–20 V0.05Give it 60 s to settle after power-up or it fires false triggers.
5 V 3 A regulated SMPS adapter5 V3000Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.
LM2596 adjustable buck converter module4.5–40 V8Set 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
FastLED 3.6.xTiming-exact WS2812B driver with colour-correction and palettes.Library Manager → "FastLED"
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-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.

Smart Lighting Mesh — system block diagramFunctional block diagram of the Smart Lighting Mesh system. InputPIR per zonepresenceButton / encodermanualMQTT via gatewayremoteDecideScene enginetarget stateCircadian clockCCT by timeDistributeESP-NOW broadcast< 10 msPeer ackreliabilityRenderInterpolator 50 Hzsmooth fadeGamma + RGBWSK6812 outputeventsscene structper-node target
Smart Lighting Mesh — system block diagram

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.

Smart Lighting Mesh — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBPIR presence sensorGPIO 27High on motionWall buttonGPIO 32Short press cyclesscenesRotary encoderGPIO 33 / 25BrightnessSK6812 RGBW stripGPIO 5 → 74AHCT125 → strip800 kHz, 330 ΩseriesSecond strip segmentGPIO 18Separate channel forlong runsStatus LEDGPIO 2Mesh health
Smart Lighting Mesh — wiring schematic
PeripheralPeripheral pinController pinSignal
PIR presence sensorOUTGPIO 27High on motion
Wall buttonNOGPIO 32Short press cycles scenes
Rotary encoderA / BGPIO 33 / 25Brightness
SK6812 RGBW stripDINGPIO 5 → 74AHCT125 → strip800 kHz, 330 Ω series
Second strip segmentDINGPIO 18Separate channel for long runs
Status LEDAnodeGPIO 2Mesh 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.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board. Each lighting zone runs one, and exactly one is compiled as the MQTT gateway. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

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.

Smart Lighting Mesh — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · HC-SR501 PIR motion sensorDriver layerfastled · wifi · pubsub · arduinojsonApplication logicsampling loop · filtering · thresholds · state machineTransport layerESP-NOW + MQTT bridge · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Smart Lighting Mesh — architecture stack

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

plainLED 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

plainVoltage 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

plainGamma 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.

Smart Lighting Mesh — firmware flowchartControl flow through the main program loop. Boot: restore scene, registerESP-NOW peersPoll PIR, button, encoder,ESP-NOW inboxNew target scene?yeskeep interpolatingBroadcast scene to all meshpeersInterpolate current → targetat 50 HzApply gamma LUT, write RGBWframePersist scene to NVS after 5 ssettle
Smart Lighting Mesh — firmware flowchart

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.

cpplighting-mesh-node.ino
/* ═══════════════════════════════════════════════════════════════
   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", &current, 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));
  }
}
__attribute__((packed))ESP-NOW copies raw bytes. Without packing, the compiler inserts padding for alignment and two nodes built with different compiler settings would disagree about the struct layout — producing scenes that decode to nonsense.
magic byte and seqThe magic byte rejects any other ESP-NOW traffic on the channel; the sequence number stops a re-broadcast being applied twice, which would restart the fade and produce a visible stutter.
esp_wifi_set_channel on non-gateway nodesAn ESP32 has one radio. Nodes that never associate must be pinned to the mesh channel explicitly, and the gateway must associate on that same channel, or half the mesh never hears anything.
smoothstep t*t*(3−2t)A linear fade has visible corners at both ends. Smoothstep gives zero first derivative at the start and finish, which reads as a natural fade rather than a ramp.
setMaxPowerInVoltsAndMilliampsFastLED will scale the whole frame down to stay inside this current budget. It is a genuine safety feature — a full-white command on an undersized supply otherwise browns out the controller mid-frame and leaves the strip in a random state.
fast on, slow offAsymmetric fade times matter perceptually. Light appearing quickly feels responsive; light disappearing slowly feels calm. Equal times feel wrong in both directions.

Configuration & Calibration

Configuration steps

  • Set a unique ZONE_ID for every node, and compile exactly one node with IS_GATEWAY 1.
  • Set MESH_CHANNEL to 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_LEDS per node and use setMaxPowerInVoltsAndMilliamps to 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.

  1. 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.

  2. 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.

  3. 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

Smart Lighting Mesh — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsZone 1 nodeESP-NOW onlyZone 2 nodeESP-NOW onlyGateway nodeESP-NOW + Wi-FiESP-NOW, ch 1Gateway nodethe only associated deviceMQTT 1883Home AssistantoptionalHA light entityper zoneWall switcheslocal, no network
Smart Lighting Mesh — network topology

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 / endpointDirectionPayload
home/light/zone1/setbroker → gateway → meshJSON: state, brightness, color, effect, transition
home/light/zone1/stategateway → 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.

TestWhat you should see
Power one node with a short stripThe strip lights to the stored scene within a second of boot, with no flash of random colour.
Fade brightness from 0 to fullA perceptually even ramp with no visible steps, especially at the low end.
Change a scene on one nodeEvery other node changes within one video frame at 60 fps — visually simultaneous.
Unplug the routerScene changes between nodes still work. Only Home Assistant control is lost.
Command full white at maximum brightnessNo flicker, no controller reset, and supply voltage staying above 4.8 V at the far end of the strip.
Walk into the zoneBrightness 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 nodeIt 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

The first LED shows a wrong colour, or the strip flickers

Likely cause. 3.3 V data driving a 5 V strip, or no series resistor.

Fix. Fit a 74AHCT125 level shifter and a 330 Ω resistor in the data line. The WS2812B needs a logic high above 3.5 V; 3.3 V is out of specification and works only by luck and at short range.

The far end of the strip is dim and orange

Likely cause. Voltage drop along the strip copper.

Fix. Inject 5 V at both ends and, for runs over 4 m, in the middle as well. This is a physics problem — no firmware setting can fix it.

ESP-NOW works between two nodes but not a third

Likely cause. Channel mismatch, almost always because the gateway associated on a different channel.

Fix. Print WiFi.channel() on the gateway after association and set every other node to that channel with esp_wifi_set_channel(). Fix the router to a specific channel rather than auto.

The controller resets whenever the lights go bright

Likely cause. Supply current, or inrush at the moment the frame changes.

Fix. Use setMaxPowerInVoltsAndMilliamps to cap draw, fit the 1000 µF capacitor at the injection point, and power the controller from a separate regulated feed rather than tapping the strip rail.

Colours are wrong — red and green swapped

Likely cause. Wrong colour order in the FastLED template.

Fix. Most WS2812B strips are GRB; some clones are RGB and SK6812 is often GRBW. Try <WS2812B, PIN, RGB> and compare.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

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 a millis() 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_t where 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

Why ESP-NOW instead of MQTT for everything?

Latency and resilience. An MQTT scene change takes 30–150 ms and stops working when the router reboots; ESP-NOW takes 2–4 ms and does not care. For a thermostat that difference is irrelevant, for lighting it is the entire feel of the system. The MQTT gateway still exists for integration — it just is not in the critical path.

Is RGBW really worth the extra cost?

Yes, and it is the single biggest quality difference in the build. RGB white has a spiky spectrum with a colour rendering index around 20–40, which makes skin and food look wrong, and it uses three times the power for the same brightness. A dedicated phosphor white channel gives CRI 80–90.

How many nodes can the mesh handle?

ESP-NOW allows 20 encrypted peers or up to 6 simultaneous unencrypted broadcast-style peers per device, but broadcast scales differently — every node hears every broadcast, so the practical limit is airtime rather than peer count. Twenty nodes sending occasional scene changes is comfortable; twenty nodes sending 30 Hz ambient updates is not.

Why does my strip need power injection when it worked fine on the bench?

Because a bench test at 20 % brightness draws a fifth of the current of full white. Voltage drop is proportional to current, so a strip that looks perfect at low brightness goes visibly orange at the far end when you command white. Always test at worst case.

Can I mix strip types on one mesh?

Yes — the scene packet describes intent (colour, brightness, effect) rather than pixel data, so each node renders it with whatever hardware it has. A node with a single RGB bulb and a node with 180 RGBW pixels can honour the same scene.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. ESP-NOW — ESP-IDF programming guideEspressif
  2. WS2812B intelligent control LED — datasheetWorldsemi
  3. SK6812 RGBW LED — datasheetOpsco
  4. Adafruit NeoPixel Überguide — power, level shifting and best practiceAdafruit
  5. FastLED library documentation and power managementFastLED
  6. Gamma correction and perceptual brightnessAdafruit
  7. Light at night and melatonin suppression — a reviewJournal of Clinical Endocrinology & Metabolism