light_mode IoT Firmware · ESP32-S3

MorayGlow

Wi-Fi RGB LED strip controller with Home Assistant integration, WebSocket real-time sync, and a captive-portal setup UI — all on a Seeed XIAO ESP32-S3.

codeC++ memoryXIAO ESP32-S3 wifiWi-Fi 802.11n hubMQTT homeHome Assistant buildPlatformIO storageLittleFS boltLEDC PWM
info

Overview

MorayGlow is an ESP32-S3 firmware that turns a 12V 5050 RGB LED strip into a smart home device. Three IRLML6344TR N-channel MOSFETs are driven by 1 kHz LEDC PWM on GPIO1, GPIO2 and GPIO6, allowing full 8-bit colour control of each channel. A MP1584 buck converter provides regulated 5V from the same 12V rail that powers the strip, keeping the BOM simple.

The device runs an ESPAsyncWebServer serving a LittleFS-hosted single-page UI, a WebSocket endpoint for real-time state push, and a JSON REST API. It simultaneously connects to an MQTT broker and publishes Home Assistant auto-discovery payloads, making it zero-configuration in a standard HA setup. State changes from any source — REST, WebSocket command, or MQTT — funnel through applyLedState()broadcastState()mqttPublishState(), keeping all clients in sync.

On first boot (or after a button-hold factory reset) the device starts an access point and serves a captive portal that scans for nearby networks and saves credentials to EEPROM. The mDNS-based device discovery endpoint allows the devices.html UI page to list all MorayGlow units on the local network automatically.

Control Flow

home Home Assistant HA integration auto-discovery
swap_horiz MQTT
hub MQTT Broker Mosquitto etc. <id>/command
swap_horiz MQTT
developer_board MorayGlow XIAO ESP32-S3 applyLedState()
swap_horiz WS / REST
web Web UI Browser / LittleFS /ws · /api/*

Boot & State Change Sequence

1
Boot: EEPROM.begin(512) → read WiFiConfigWiFi.mode(WIFI_STA)Device::init() derives MAC-based ID (e.g. morayglow-2cb120)
2
Station mode: valid credentials → connect to home network (20 s timeout, status LED blinks during connect)
3
Services start: MDNS.begin()IguanaOTA::Initialise()mqttSetup()webserverSetup()
4
MQTT connects: subscribes to <id>/command → publishes HA auto-discovery to homeassistant/light/<id>/config → publishes initial state (retained)
5
AP fallback: no credentials / failed → WiFi.mode(WIFI_AP) → SSID MorayGlow-XXXXXX · IP 10.0.0.1 → all HTTP redirected to captive portal setup.html
6
State change (any source): REST POST / MQTT command / cycle timer → applyLedState() drives LEDC PWM → broadcastState() pushes JSON to all WebSocket clients → mqttPublishState()
7
Factory reset: hold button GPIO5 for 5 s → status LED blinks faster as threshold approaches → EEPROM zeroed → ESP.restart() into AP mode
info
Single-source state model. All three control paths (REST, WebSocket, MQTT) write to the same four globals (ledOn, ledColor, cycleMode, apMode) then call the same three propagation functions. There is no separate internal/external state — every source sees the same truth.
photo_library

Screenshots

Web UI served from LittleFS on the device and mirrored by the Node.js mock server. All three pages share the same dark theme and minimalist layout.

Main control UI showing power toggle (ON), colour picker dots, and Connected status
Main UI — power toggle, colour swatch picker, WebSocket connection status
WiFi setup captive portal with network list, SSID and password fields, and Connect button
Setup portal — scans nearby networks, enter SSID and password to connect
Device discovery page showing scan failed message and Back to controls link
Devices page — mDNS discovery of all MorayGlow units on the local network
memory

Hardware

MCU
Seeed XIAO ESP32-S3
Xtensa LX7 dual-core · 240 MHz · 8 MB Flash · 8 MB PSRAM · 2.4 GHz Wi-Fi · 21 × 17.5 mm
LED Strip
12V 5050 RGB Strip
Common-anode, 3-channel. Each channel switched by an IRLML6344TR N-MOSFET (4.1 A, 20 V) driven from 3.3V GPIO
Power Supply
12V DC input → MP1584 buck
MP1584 step-down converter supplies regulated 5V to XIAO 5V pad. Single 12V rail powers both strip and MCU
PWM Drive
LEDC hardware PWM
3 channels · 1 kHz · 8-bit resolution (256 levels per colour). Channels 0/1/2 on GPIO1/2/6
Status LED
GPIO4 (D3) via 330 Ω
Optional indicator — blinks during WiFi connect, solid on when connected, fast-blink during hold-to-reset countdown
Button
GPIO5 (D4) · INPUT_PULLUP
Active-low. ButtonStatus class handles 50 ms debounce. Hold 5 s → factory reset (EEPROM clear + restart)
warning
MOSFET gate drive. The IRLML6344TR is fully enhanced at 3.3V (VGS(th) ≤ 1.0V typical) — no gate driver is needed between the ESP32 GPIO and the MOSFET gate. Do not use logic-level MOSFETs with a higher threshold voltage or the RGB channels will be dim or non-functional.
list_alt

Bill of Materials

Two build variants share the same circuit topology. The SMD build is designed for the custom PCB; the through-hole build suits breadboard or perfboard prototyping.

Ref Qty Value Description Part / Source
U11XIAO ESP32-S3Seeed XIAO ESP32-S3 Plus SMD module (castellated edges)Seeed 113991116
Q1–Q33IRLML6344TRN-Ch SOT-23 MOSFET VGS(th)≈1.0V Id=5A — R/G/B channelsDigikey IRLML6344TRPBFCT-ND
D111N5819HW-7-FSchottky 40V 1A SOD-123 — USB/12V back-feed protectionDigikey 1N5819HW-FDICT-ND
R1–R33100 ΩGate resistors Q1–Q3, 1/10W 0603LCSC C22775
R4–R63100 kΩGate pull-downs Q1–Q3, 1/10W 0603LCSC C25803
R71330 ΩStatus LED current limiter, 1/10W 0603LCSC C23138
C11100 nFVBUS decoupling, ceramic 16V 0603LCSC C14663
C2110 µFVBUS bulk cap, X5R 10V 0805LCSC C15850
C31100 nFVDD_5V output decoupling (on PS1 VOUT), ceramic 25V 0603LCSC C14663
C41100 µFVCC_12V bulk cap near J2 — absorbs PWM switching transients, Kemet A SMDLCSC C16780
LED11LED Blue 0805Status indicator, 0805LCSC C72038
SW11Tactile 6mmPushbutton 6mm SMD 4-pinLCSC C318884
PS11MP1584 Buck 5VBuck module 7–28V in / 5V 3A out, via 2.54mm headersGeneric module
J11Barrel Jack 5.5/2.112V DC input, SMD horizontalLCSC C381116
J21Screw Terminal 4P 3.5mmRGB strip connector, SMDLCSC C395916
Ref Qty Value Description Part / Source Notes
U11XIAO ESP32-S3XIAO module on 2×7 2.54mm female socketsSeeed 113991116 + socketsXIAO has 0.7mm hole pitch — use castellated SMD or buy with pre-soldered headers
Q1–Q33IRL540NN-Ch TO-220 logic-level MOSFET VGS(th)≤2.0V Id=36A — R/G/B channelsDigikey IRL540NPBF-ND3.3V gate drive gives ~100–150 mΩ RDS(on). Pin order: G=1 D=2 S=3 (flat face toward you)
D111N5819Schottky 40V 1A DO-41 axial — USB/12V back-feed protectionLCSC C727110Cathode band toward XIAO VBUS pin
R1–R33100 ΩGate resistors Q1–Q3, 1/4W axialLCSC C57430
R4–R63100 kΩGate pull-downs Q1–Q3, 1/4W axialLCSC C57692
R71330 ΩStatus LED current limiter, 1/4W axialLCSC C57502
C11100 nFVBUS decoupling, ceramic disc 50V 5mm pitchLCSC C49678Non-polarised
C2110 µF 25VVBUS bulk cap, aluminium electrolytic radial 5mm pitchLCSC C36369Long lead = positive (VBUS)
C31100 nFVDD_5V output decoupling (on PS1 VOUT), ceramic disc 50V 5mm pitchLCSC C49678Non-polarised
C41100 µF 16VVCC_12V bulk cap near J2 — absorbs PWM switching transients, radial electrolyticLCSC C36370Long lead = positive (+12V)
LED11LED Blue 5mmStatus indicator, 5mm through-hole LEDLCSC C2296Long lead = anode (+); cathode has flat on lens rim
SW11Tactile 6×6mmPushbutton 6×6mm through-holeLCSC C318885All four legs are equivalent pairs
PS11MP1584 Buck 5VSame buck module — via 2×2 2.54mm pin headersGeneric moduleSolder 4 header pins to module pads first
J11Barrel Jack 5.5/2.112V DC input, through-hole horizontalLCSC C136706 / CUI PJ-002AHCentre pin = +12V; sleeve = GND
J21Screw Terminal 4P 3.5mmRGB strip connector, through-hole 3.5mm pitchLCSC C474870Or 5.08mm pitch: LCSC C395916-TH
schema

Wiring

The schematic shows the XIAO ESP32-S3 GPIO connections. The three PWM outputs (D0, D1, D5) connect to MOSFET gates and are not shown here for clarity — see the KiCad project in docs/KiCad/ for the full schematic including the MOSFET drive and 12V strip wiring. The optional status LED circuit on D3 is shown below.

12V DC PSU → MP1584 → 5V powers strip + MCU XIAO ESP32-S3 USB-C 5V GND D0 D1 D5 D3 D4 5V power pad Ground GPIO1 — PWM Red (CH0) GPIO2 — PWM Green (CH1) GPIO6 — PWM Blue (CH2) GPIO4 — Status LED signal GPIO5 — Button (INPUT_PULLUP) ESP32-S3 Xtensa LX7 dual-core 2.4 GHz WiFi 2.4 GHz → MQTT broker / Web UI 5 V GND OPTIONAL — status LED LED any colour 330 Ω ¼ W GND D3 / GPIO4 — status LED signal LEGEND 5V power Ground D3 status LED signal Wireless (WiFi 2.4GHz)

Full KiCad Schematic

MorayGlow KiCad schematic — full circuit including MOSFET drive, MP1584 buck converter, decoupling capacitors, and RGB strip connectors
info
KiCad source. The schematic above is exported from the KiCad project in docs/KiCad/. The SVG diagram above shows the XIAO GPIO connections; the KiCad schematic includes the complete circuit — IRLML6344TR MOSFET drive, MP1584 buck converter, decoupling capacitors, and 12V RGB strip connectors.
developer_board

Pins Used

PinFunctionDirectionNotes
GPIO1 / D0PIN_RED — PWM Red channelOutputLEDC CH0 · 1 kHz · 8-bit · drives IRLML6344TR gate → R channel of 12V strip
GPIO2 / D1PIN_GREEN — PWM Green channelOutputLEDC CH1 · 1 kHz · 8-bit · drives IRLML6344TR gate → G channel of 12V strip
GPIO6 / D5PIN_BLUE — PWM Blue channelOutputLEDC CH2 · 1 kHz · 8-bit · drives IRLML6344TR gate → B channel of 12V strip (GPIO3 avoided — ESP32-S3 strapping pin)
GPIO4 / D3PIN_STATUS — Status LEDOutputActive-high · 330 Ω series resistor to GND. Solid = connected, blink = busy, fast-blink = reset pending
GPIO5 / D4PIN_BUTTON — User buttonInputINPUT_PULLUP · active-low · 50 ms debounce via ButtonStatus · hold 5 s → factory reset
5V padVCC supply inputPower inAccepts 5V from MP1584 buck output. Do not exceed 5.5V
GND padGround referencePower inCommon ground with 12V supply return and LED GND
(internal)Wi-Fi 2.4GHz radioBidirectionalStation mode (STA) for normal operation · AP mode (192.168.x.x → 10.0.0.1) for WiFi setup
(internal)LEDC PWM controllerOutput3 channels assigned to GPIO1–3. 1 kHz carrier, 8-bit duty cycle (0–255 = 0–100%)
(internal)EEPROM (emulated Flash)R/W512 bytes via EEPROM.begin(512). Stores WiFiConfig struct (SSID + password, 1-byte alignment)
(internal)LittleFS flash partitionR/WStores web UI files: index.html, setup.html, devices.html, css/style.css, js/app.js, js/moray.js
view_in_ar

3D PCB

code

Firmware

The firmware is a PlatformIO project targeting the seeed_xiao_esp32s3 board using the Arduino framework. Three build environments are defined: USB flash (xiao_esp32s3), OTA Wi-Fi flash (xiao_esp32s3_ota), and native unit tests (native).

The state.h header is intentionally inline-only with #ifndef ARDUINO guards so it compiles on the native host. This lets unit tests exercise JSON serialisation and MQTT command parsing without any ESP32 hardware. Tests are written with the Unity framework and run via pio test -e native.

First-Time Setup

1

Configure MQTT broker

Edit MorayServer/include/config.h and set your broker IP and optional credentials before building.

config.hcontent_copy
#define MQTT_HOST     "192.168.1.10"   // your broker IP
#define MQTT_PORT     1883
#define MQTT_USER     ""               // leave blank if unauthenticated
#define MQTT_PASSWORD ""
2

Build & flash firmware via USB

powershellcontent_copy
# Build only
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e xiao_esp32s3

# Build + flash firmware
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e xiao_esp32s3 -t upload
3

Upload web UI to LittleFS

Files in MorayServer/data/ are served by the ESP32. Upload them after any UI change.

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e xiao_esp32s3 -t uploadfs
4

Connect to device & configure Wi-Fi

On first boot the device starts AP MorayGlow-XXXXXX. Connect from a phone or laptop, visit http://10.0.0.1, select your network and enter the password. The device restarts into station mode.

5

OTA updates (subsequent flashes)

Once the device is on your network, flash over Wi-Fi. The device mDNS hostname is printed to serial on boot.

powershellcontent_copy
# Firmware OTA (replace with your device hostname or IP)
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e xiao_esp32s3_ota -t upload --upload-port morayglow-2cb120.local

# LittleFS OTA
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e xiao_esp32s3_ota -t uploadfs --upload-port morayglow-2cb120.local
6

Run native unit tests

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" test -e native
warning
OTA on Windows requires a firewall rule. The ESP32 calls back to the host machine on an ephemeral port during OTA. Add an inbound allow rule for %USERPROFILE%\.platformio\penv\Scripts\python.exe. Also note that IoT network isolation (common on mesh routers) blocks OTA — temporarily move the device to the main network or use USB.

Dev Mock Server (Client-only development)

A Node.js mock server mirrors the firmware REST and WebSocket API so the web UI can be developed without flashing hardware.

bashcontent_copy
cd MorayClient/test-server
npm install        # first time only
npm start          # → http://localhost:3000

# Run Jest tests
cd MorayClient
npm test           # tests moray.js + app.js

cd MorayClient/test-server
npm test           # tests logic.js + server.js
api

API Reference

REST Endpoints

All endpoints return Content-Type: application/json. POST bodies must be JSON. State-modifying endpoints return the new full state object.

MethodPathRequest BodyResponse
GET/api/state{"on":bool,"color":"#rrggbb","cycle":bool}
POST/api/power{"on":bool}State JSON — also broadcasts via WS + MQTT
POST/api/color{"color":"#rrggbb"}State JSON — also sets cycleMode=false
POST/api/mode{"cycle":bool}State JSON
GET/api/devices{"devices":[{"id","ip","url"},…]} — mDNS query, ~2 s. Station mode only.
GET/api/deviceinfo{"id":"morayglow-2cb120","url":"http://…"} — available in both STA and AP modes
GET/api/networkdata{"scanning":bool,"networks":[{"ssid","rssi","secure"},…]} — async WiFi scan, deduplicated by SSID
POST/api/networkset{"ssid":"…","password":"…"}{"ok":true} then device reboots into station mode

WebSocket

PathDirectionFormatNotes
/wsServer → Client{"on":bool,"color":"#rrggbb","cycle":bool}Sent immediately on connect and on every state change. AsyncWebSocket supports multiple concurrent clients.

MQTT Topics

Topics are prefixed with the runtime device ID (e.g. morayglow-2cb120). The MQTT command schema follows the Home Assistant JSON light schema — note it differs from the REST API format.

DirectionTopicPayload ExampleNotes
Subscribe<id>/command{"state":"ON","color":{"r":255,"g":0,"b":0},"cycle":false}All fields optional. Missing fields leave current state unchanged. Color is RGB object (not hex).
Publish<id>/state{"on":true,"color":"#ff0000","cycle":false}Retained. Published on every state change and on MQTT reconnect. Color is hex string.
Publishhomeassistant/light/<id>/configHA discovery JSONRetained. Published on every MQTT reconnect. Enables zero-config HA integration.

LWT (Last Will & Testament)

On MQTT connect the device registers {"on":false} as the LWT on its state topic. If the device disconnects unexpectedly, the broker immediately publishes this payload, allowing Home Assistant to mark the light as unavailable.

info
MQTT vs REST colour format. The command topic uses HA JSON schema with an RGB object ({"r":255,"g":0,"b":0}). The REST /api/color and the state topic use hex strings ("#ff0000"). The parseMqttCommand() function in state.h handles the conversion.
folder_open

Project Structure

textcontent_copy
MorayGlow/
├── MorayServer/                    # ESP32-S3 firmware (PlatformIO)
│   ├── platformio.ini               # envs: xiao_esp32s3, xiao_esp32s3_ota, native
│   ├── src/
│   │   ├── main.cpp                 # setup/loop, LED PWM, button, colour cycle, WiFi/mDNS/OTA init
│   │   ├── webserver.cpp            # ESPAsyncWebServer, WebSocket, all REST endpoints, LittleFS
│   │   ├── mqtt.cpp                 # PubSubClient, HA auto-discovery, MQTT reconnect loop
│   │   └── device.cpp               # Device static class — MAC-based ID, mDNS query
│   ├── include/
│   │   ├── config.h                 # MQTT broker host/port/credentials — edit before flashing
│   │   ├── state.h                  # Header-only: stateToJson, rgbToHex, parseMqttCommand
│   │   ├── device.h                 # Device::init(), id(), apSsid(), url(), queryDevicesJson()
│   │   ├── webserver.h              # broadcastState(), webserverSetup(), webserverLoop()
│   │   └── mqtt.h                   # mqttSetup(), mqttLoop(), mqttPublishState()
│   ├── data/                        # LittleFS web UI (uploadfs target)
│   │   ├── index.html               # Main control UI: power toggle, mode, colour picker, device list
│   │   ├── setup.html               # Captive portal: network scan, SSID/password entry
│   │   ├── devices.html             # Multi-device mDNS discovery page
│   │   ├── css/style.css            # Shared UI styles
│   │   └── js/
│   │       ├── app.js               # Main UI logic: WebSocket, REST calls, colour picker
│   │       └── moray.js            # UMD utility module: buildWsUrl, isValidHexColor
│   └── test/                        # Native unit tests (Unity framework)
│       └── test_state/
│           └── test_main.cpp        # Tests for stateToJson, rgbToHex, parseMqttCommand
├── MorayClient/                    # Node.js dev tools
│   ├── js/
│   │   └── moray.js                # Shared UMD module (same file served from LittleFS)
│   ├── test-server/
│   │   ├── server.js                # Express mock server: mirrors firmware REST + WebSocket API
│   │   ├── logic.js                 # Stateless validation logic mirroring state.h in JS
│   │   └── package.json             # express, ws, jest
│   └── package.json                 # jest for moray.js + app.js tests
└── docs/
    ├── index.html                   # This documentation page
    └── KiCad/                       # PCB schematic and layout (KiCad 7)
menu_book

Documentation & References

memory
Seeed XIAO ESP32-S3 Official getting-started guide, pinout, power specs, and datasheet links for the target MCU board.
developer_board
Arduino ESP32 Core API reference for WiFi, LEDC PWM, ESPmDNS, EEPROM, and all ESP32 Arduino framework peripherals.
tune
LEDC PWM API ESP32 LEDC hardware PWM used to drive RGB channels at 1 kHz with 8-bit resolution (ledcSetup, ledcWrite).
data_object
ArduinoJson v7 Dynamic-capacity JsonDocument used for REST body parsing, state serialisation, and MQTT discovery payloads.
http
ESPAsyncWebServer Non-blocking async HTTP server and WebSocket handler. POST body arrives in onBody lambda; never blocks the loop.
hub
PubSubClient MQTT client library. setBufferSize(512) required — default 256 is too small for HA auto-discovery payloads.
home
HA MQTT Light Home Assistant MQTT light integration schema — defines the command/state JSON format and auto-discovery config structure.
storage
LittleFS (ESP32) Flash filesystem used to store and serve the web UI files. Uploaded separately from firmware via the uploadfs target.
system_update
ArduinoOTA Over-the-air firmware and filesystem updates via espota. Wrapped by IguanaOTA — init once, then call handle() in loop().
build
PlatformIO Build system and library manager. Three environments defined: USB flash, OTA Wi-Fi flash, and native unit tests.
science
Unity Test Framework C unit test framework used for native-host testing of state.h logic (stateToJson, parseMqttCommand, rgbToHex).
wifi
ESP32 WiFi API Station and AP mode setup, async network scan (WiFi.scanNetworks), and softAP captive portal configuration.