Open source technical witchcraft for light, sound and other cool stuff.
Enabling ESP32-based devices with web interface, OTA updates, and mesh sync
LightWitch is a platform for building your own light, sound, sensor and input devices. You design the object, LightWitch does the magic. Lamps, lanterns, torches, glowing orbs, candles, wearables, buttons, remotes, speakers, whatever you have in mind.
Any device with an ESP32 inside (a cheap microcontroller with Wi-Fi and Bluetooth on board) joins the mesh on its own. There is no router to configure, no gateway to carry along and no account anywhere. Unpack, power up, done. Works in a workshop, at a campsite, on a job site, in the middle of a party.
Behaviour is built in a visual editor by docking blocks together. Wire anything, a device can notice to anything it can do. Easily distributed to every device in the mesh with matching hardware. No code required.
Nothing says a device may only have one job. A row of garden lamps lights the path and watches the soil at the same time, and starts the pump when things get too dry. Actually smart stuff, everywhere you feel like putting it. Rig your table lamp as an egg timer in a minute, build a doorbell that rings or blinks in whichever room currently has a light on, or make fire that you actually light, blow out and that flickers when its windy and jumps over to the device standing next to it, if near enough. Finally enabling realistic torches for an indoor event.
Hardware, wiring and configs are meant to be shared. Publish yours, borrow someone else's, change everything about it. Firmware, editor and design files are open. Nothing to subscribe to.
Use this to flash a fresh device using pre-built firmware from the GitHub releases page. All you need is a Chrome or Edge browser with WebSerial support.
1. Download the firmware
Go to the latest release and download the files for your board type:
| File | What it is |
|---|---|
bootloader-esp32c3.bin |
Bootloader for ESP32-C3 devices |
bootloader-esp32dev.bin |
Bootloader for ESP32-WROOM-32 devices |
bootloader-esp32s3.bin |
Bootloader for ESP32-S3 devices |
partitions.bin |
Partition table (same for all board types) |
firmware-esp32c3.bin |
Firmware for ESP32-C3 devices |
firmware-esp32dev.bin |
Firmware for ESP32-WROOM-32 devices |
firmware-esp32s3.bin |
Firmware for ESP32-S3 devices |
littlefs.bin |
Web UI filesystem (same for all board types) |
2. Open the web flasher
Go to https://esp.huhn.me in Chrome or Edge.
3. Connect your device
Plug in the ESP32 via USB, then click Connect and select the device's serial port.
4. Add the files
Add entries for your board type with these addresses:
ESP32-C3 / ESP32-S3:
| Address | File |
|---|---|
0x0 |
bootloader-esp32c3.bin (C3) / bootloader-esp32s3.bin (S3) |
0x8000 |
partitions.bin |
0x10000 |
firmware-esp32c3.bin (C3) / firmware-esp32s3.bin (S3) |
0x3B0000 |
littlefs.bin |
ESP32-WROOM-32:
| Address | File |
|---|---|
0x1000 |
bootloader-esp32dev.bin |
0x8000 |
partitions.bin |
0x10000 |
firmware-esp32dev.bin |
0x3B0000 |
littlefs.bin |
5. Flash
Click Program and wait for it to finish. The device will reboot automatically.
Continue with First boot below.
Each device starts as a WiFi access point named after its MAC address (e.g. light-a1b2c3).
| Setting | Value |
|---|---|
| Network | light-a1b2c3 (unique per device) |
| Password | lightwitch |
| Web interface | http://192.168.4.1 |
- Connect to the device's WiFi network.
- Open http://192.168.4.1 in a browser.
- Under Settings -> Network, set a memorable device name, enter your WiFi credentials, and click Save. Changing the device name reboots the device to apply it.
After reboot the device joins your WiFi and is reachable at http://<devicename>.local (e.g. http://lightwitch1.local). The AP turns off automatically once connected.
If WiFi is unreachable the device falls back to AP mode so you can always reach it at http://192.168.4.1.
After first boot, go to Settings to review:
| Setting | Why |
|---|---|
| Device name | mDNS hostname (http://<name>.local) and OTA target name; defaults to a MAC-based name like light-a1b2c3. |
| AP password | Defaults to lightwitch (published in this README) — change it if the device's AP could ever be reachable by others. |
| Timezone | Only used by the Time-mode pattern; skip it if you don't use that mode. |
| MQTT | Optional home-automation integration — leave the broker host blank to disable it. |
| Firmware updates | Optional GitHub-releases integration — see below. |
Log level, OTA port, and buttons can be left at their defaults unless you have a specific reason to change them.
Devices discover each other automatically over ESP-NOW and form a mesh (scene sync, pattern sync, etc.) — this only requires sharing the same 2.4 GHz WiFi channel, not the same WiFi network or credentials. Each device tracks up to 16 other devices at once (PeerRegistry::MAX_PEERS); beyond that, additional devices won't be discovered until an existing entry drops off the mesh. To set up additional devices without re-entering every field by hand:
- Flash and boot the new device as described above; it appears in the first device's Connected Lights table once discovered.
- Click Push config to all (or the per-device push button next to a specific peer) to send WiFi networks, AP password, MQTT settings, GitHub repo/token, and OTA-enabled to the target(s) in one step.
- Any field left unchecked in the push dialog is skipped on the target. Sensitive fields (AP password, MQTT password, GitHub token) are filled in by the firmware itself and never appear in the browser.
A fresh device starts with one group (Default) but no lights configured — add your hardware before anything lights up.
- Go to Settings → Lights and click Add light.
- Choose the LED type (WS2812B single-wire or WS2801 two-wire), the data pin (and clock pin for WS2801), and the light's length (LED count, or columns/rows for a matrix).
- Click Save & Reboot — hardware changes require a restart to take effect.
- Back on the main tab, use the Group dropdown in the Connected Lights table to assign the light to a group (new lights start in Default).
Repeat for each physical light attached to the device (up to 4).
A group holds the pattern/scene/color state — lights don't have their own; they display whatever their assigned group is set to. Put multiple lights in the same group to keep them in sync, or in separate groups to control them independently.
Default cannot be deleted; deleting any other group moves its lights back to Default.
Adding a sound output brings the codec up and lets you play a short built-in test melody to verify wiring.
- Go to Settings → Sound and click Add sound.
- Choose the chip (only ES8311, a mono I2S codec, is supported today), then the I2C pins (SDA/SCL, plus the I2C address if your board's CE pin isn't strapped to the default), and the I2S pins (BCLK, WS/LRCK, DOUT). Leave the MCLK pin unset if your board doesn't wire one — the ES8311 can derive its clock from BCLK internally. If your board gates a separate speaker amp, set the PA enable pin: either a direct GPIO, or — on boards where it sits behind a TCA9555 I2C GPIO expander shared with other peripherals instead of a native pin — select TCA9555 expander and its I2C address.
- Click Save & Reboot — hardware changes require a restart to take effect.
- Click Test speaker to play a short built-in melody and confirm the wiring works.
One sound output per device. For known board-specific pin values, see docs/boards/ (currently: Waveshare ESP32-S3-AUDIO-Board).
Supported hardware: only the Waveshare ESP32-S3-AUDIO-Board has an SD card reader — its firmware detects the card automatically at boot, with nothing to configure in Settings. Other boards (plain ESP32, ESP32-C3, generic ESP32-S3 devkits) don't have this reader and won't show a Storage section at all.
Preparing the card:
- Format the card FAT32.
- Insert it before powering the device on, or reboot the device after inserting it — the card is only detected once, at boot, not while running.
- Open Settings → Storage: if the card was detected, you'll see its used/total capacity and a file list.
What you can manage: .wav audio files only, uploaded and deleted individually from Settings → Storage — there are no folders, just a flat list. Any other file already on the card (e.g. left over from formatting it on a computer) is ignored and won't show up here.
Format: 16-bit PCM WAV, mono or stereo, any sample rate — anything else (compressed formats, 8/24/32-bit, more than 2 channels) is silently skipped at playback time rather than rejected at upload.
Mesh-wide synchronized audio playback: start a file or playlist on one device and every other device in the same audio group plays it at (as close to) the same instant as possible.
An audio group is the audio-side equivalent of a light group — it decides which devices a play/stop command reaches. Under Settings → Hardware → Sound, assign a device's sound output to a group (defaults to Default, same convention as light groups). Manage groups from the Audio card on the dashboard — create, rename, delete; Default cannot be deleted.
A playlist is a named, ordered list of .wav filenames plus a loop flag, created and edited from the Audio card (add/reorder/remove files, toggle loop, save). Playlists are synced across the mesh as metadata only, the same way scenes are — the audio files themselves are never distributed over the mesh. Upload the same files to every device's Storage that should be able to play them; a device missing a file a playlist (or a single-file play command) references simply doesn't play it — no error, no warning, it just stays silent for that entry.
From the Audio card, pick a target group and either a single file (once or looped) or a saved playlist (its own loop flag applies). Stop halts playback on every device in that group immediately, regardless of what each was doing.
The same triggers are available over MQTT (if configured, see Settings → Connectivity): publish to <device>/audiogroup/<id>/set with {"action":"play","filename":"...","loop":false}, {"action":"play","playlistId":"..."}, or {"action":"stop"}. There's no state topic for playback — these are fire-and-forget commands, not something to poll.
There's no shared wall clock and no per-device readiness handshake: the triggering device broadcasts once, and every device that can find the referenced file(s) locally opens and buffers them immediately, then starts output a fixed short delay later, timed from its own local clock. A device that can't finish preparing before that instant skips the trigger entirely rather than starting late.
This is a best-effort start sync, not continuous — there's no re-sync while playing. Short clips and short loops stay tightly aligned; a long-running loop will gradually drift apart across devices (ESP32 crystal tolerances put this at roughly tens of milliseconds after half an hour), audibly so for music, less so for ambient/background loops. If that matters for your use case, keep loops short or restart them periodically yourself.
Each device's own output level is a slider under Settings → Hardware → Sound, applied immediately — no reboot needed. It's local to that device, not part of a play command, and not synced over mesh.
Devices can check for and install new firmware directly from GitHub releases without a computer attached.
-
Go to Settings → Firmware updates in the device web UI.
-
Set Repository to
variour/lightwitch(the default). -
Create a GitHub fine-grained personal access token with the following permissions:
- Repository access: this repository only (
variour/lightwitch) - Repository permissions:
Contents— Read-only (required to download release assets)Metadata— Read-only (automatically included, required to access repository metadata)Pull requests— Read-only (needed for Allow installing PR builds)
Classic tokens work too with the
reposcope, but a fine-grained token with read-only access is safer. - Repository access: this repository only (
-
Paste the token into Personal access token and click Save — this takes effect immediately, no reboot needed.
- Click Check to query the latest GitHub release and compare it to the device's current firmware version.
- If a newer version is available, an Install update button appears. Click it to flash both firmware and filesystem over WiFi. The device reboots automatically when done.
- Settings (WiFi, groups, etc.) are preserved — the device restores them from NVS after the filesystem is reflashed.
Note: The token is stored in NVS on the device. It is write-only from the web UI — it is never returned by the API. Use a token with minimal permissions scoped to this repository only.
Supported LED types:
- WS2812B
- WS2801
LED type and data/clock GPIO pins are configured per light in the web UI.
Supported sound chips:
- ES8311 (mono I2S codec), see Configuring sound output
Sound chip and I2C/I2S GPIO pins are configured in the web UI.
Battery (optional, ESP32-C3 / ESP32-S3 only): BAT (battery +) can be sensed through a 200 kΩ / 100 kΩ (±1 %) voltage divider, solder-bridged onto GPIO1. GPIO1 isn't ADC-capable on the classic ESP32 (esp32dev), so this is unavailable there. On the Waveshare ESP32-S3-AUDIO-Board, closing that bridge disables the board's camera header (they share the same pin) — not a concern for this firmware, which never uses the camera. Once wired, enable it under Settings → Battery; battery percentage and charging/mains status then show up in the device list, over the mesh, and via MQTT. There's no readable charge-status signal on this hardware — the charger IC's STAT pin only drives its own indicator LED, not a GPIO — so "charging" is inferred from voltage alone (see BatteryMonitor.h).
See docs/known-issues.md.
- docs/architecture.md — overview of the
src/module breakdown - docs/development.md — building from source, OTA via PlatformIO, running the web UI locally
- docs/hosting.md — deploying the web UI to Azure Container Apps
- docs/mesh-compatibility.md — mesh wire-protocol compatibility policy and message inventory
- docs/boards/ — per-board pin values for hardware that needs more than "pick a GPIO" (e.g. sound codecs behind an I2C GPIO expander)
