A Rust library for building Philips Hueβcompatible Zigbee lights on ESP32-C6 and ESP32-H2. Any LED β dimmable, CCT, or full RGB β becomes a real light that pairs with the Hue Bridge and is controllable from the Hue app.
ββββββββββββ Zigbee 3.0 / 802.15.4 βββββββββββββ ββββββββββββ
β ESP32-C6 β ββββββββββββββββββββββββββββββΆβ Hue BridgeβββββββΆβ Hue App β
β huesmith β 2.4 GHz β β LAN β β
ββββββββββββ βββββββββββββ ββββββββββββ
β
βΌ
on/off Β· dimmable Β· CCT Β· WS2812B
The Hue Bridge accepts devices based on self-reported Zigbee descriptors β there is no cryptographic verification. Any device that advertises the correct ZCL clusters and attributes is recognized as a genuine Hue bulb.
The ESP32-C6 / ESP32-H2 are RISC-V, so the standard Rust nightly toolchain builds
them via build-std β the Xtensa espup toolchain is not needed. Works the same on
Linux and macOS:
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly
cargo install ldproxy espflash cargo-espflashLinux system packages:
espflashlinks againstlibudev, so install it first βsudo apt install libudev-dev pkg-configon Debian/Ubuntu (or the equivalent for your distro). macOS needs no extra system packages. On either OS the ESP-IDF toolchain is fetched and built automatically on the firstcargo build(this needsgitand a working C toolchain, which both platforms already have via Xcode CLT / build-essential).
A
rust-toolchain.tomlpinningnightly+rust-src(as in this repo) makes the nightly selection automatic for your project.
Create a new Cargo binary project and add the following files:
my-light/
βββ .cargo/
β βββ config.toml
βββ src/
β βββ main.rs
βββ build.rs
βββ Cargo.toml
βββ sdkconfig.defaults
βββ partitions.csv
Cargo.toml
[package]
name = "my-light"
version = "0.1.0"
edition = "2021"
[dependencies]
huesmith = "0.1"
esp-idf-sys = "0.37"
[build-dependencies]
embuild = "0.33"build.rs
fn main() {
embuild::espidf::sysenv::output();
}.cargo/config.toml
[build]
target = "riscv32imac-esp-espidf"
[target.riscv32imac-esp-espidf]
linker = "ldproxy"
# `--partition-table` is required: unlike `cargo espflash flash`, a bare
# `espflash`/`cargo run` does not read the partition table from sdkconfig, and
# without the custom table the Zigbee NVS partitions are missing.
runner = "espflash flash --monitor --partition-table partitions.csv"
[unstable]
build-std = ["std", "panic_abort"]
[env]
MCU = "esp32c6"
ESP_IDF_VERSION = "v5.2.2"
ESP_IDF_TOOLS_INSTALL_DIR = "global"
ESP_IDF_COMPONENT_MANAGER = "y"sdkconfig.defaults
CONFIG_ZB_ENABLED=y
CONFIG_ZB_RADIO_NATIVE=y
CONFIG_ZB_ZCZR=y
CONFIG_IEEE802154_ENABLED=y
CONFIG_FREERTOS_HZ=100
CONFIG_FREERTOS_UNICORE=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="$ENV{PROJECT_DIR}/partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="$ENV{PROJECT_DIR}/partitions.csv"
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_BT_ENABLED=n
CONFIG_LWIP_IPV6=n
CONFIG_NVS_ENCRYPTION=n
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y
partitions.csv
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x180000,
zb_storage, data, fat, 0x190000, 0x10000,
zb_fct, data, fat, 0x1a0000, 0x1000,
src/main.rs
fn main() {
// Quickstart: drive the dev board's built-in WS2812 LED (GPIO 8) as a
// full-color Hue bulb. No external wiring β just flash and pair.
huesmith::color(8).starts_on().launch();
}cargo espflash flash --release --monitorThe first build downloads ESP-IDF v5.2.2 (~10β30 min). Subsequent builds: <1 min.
Every light type is the same project with a different
main.rs. The scaffold above is identical for all of them; only thehuesmith::β¦()call changes. Ready-to-copymain.rsbodies for each type live inhuesmith/examples/with wiring notes. They are reference code, not standalone firmware (a Cargo example target can't pull in the Zigbee SDK β that only propagates to a crate that depends onhuesmith), so copy the one you want into your project'ssrc/main.rsand flash from there.
Each light type below is a complete src/main.rs for the scaffold above β same
project, one different line. Full versions with wiring notes are in
huesmith/examples/.
Built-in LED (quickstart) β the dev board's on-board WS2812 on GPIO 8, no wiring:
huesmith::color(8).starts_on().launch();WS2812B strip β external addressable strip (level-shift the 5 V data line):
huesmith::rgb_strip(2, 30).launch(); // 30 pixels on GPIO 2CCT (tunable white) β cool + warm PWM channels:
huesmith::cct(19, 18).launch(); // cool on GPIO 19, warm on GPIO 18Dimmable β single PWM channel, brightness only:
huesmith::dimmable(19).launch();On/off β no dimming or color:
huesmith::on_off(19).launch();Custom hardware β your own LightOutput backend (see
Bring your own hardware):
huesmith::custom(huesmith::HueDeviceType::DimmableLight, MyDriver).launch();| Function | Light type | Hue device class |
|---|---|---|
huesmith::on_off(gpio) |
On/Off β no dimming | 0x0100 |
huesmith::dimmable(gpio) |
Dimmable white | 0x0101 |
huesmith::cct(cool, warm) |
Color temperature | 0x010C |
huesmith::color(gpio) |
Full color (WS2812B) | 0x010D |
huesmith::rgb_strip(gpio, n) |
Full color strip of n WS2812B |
0x010D |
huesmith::custom(type, output) |
Your own LightOutput backend |
per type |
huesmith::cct(19, 18) // cool white on GPIO19, warm white on GPIO18
.starts_on() // boot in the on state (default: off)
.name("Philips", "LTW015")// override manufacturer + model (default: "huesmith")
.mac([0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x00, 0x01]) // custom EUI-64 (ZBOSS little-endian)
.channel(15) // lock to a specific 802.15.4 channel (default: scan all)
.launch(); // never returns| Method | Default | Notes |
|---|---|---|
.starts_on() |
off | Light state at boot |
.name(mfr, model) |
"huesmith" / "HUESMITH-*" |
Override just the manufacturer + model strings |
.identity(&id) |
generic per type | Apply a full DeviceIdentity preset (name, date code, sw build, hw version) |
.mac([u8; 8]) |
chip efuse (unique) | ZBOSS little-endian EUI-64 |
.channel(u8) |
scan all | 11β26; must match your Bridge channel |
.leds(usize) |
1 |
WS2812B strip length, 1β300 (color mode only) |
.active_leds(usize) |
all | Light only the first N pixels; the rest are driven black (color mode) |
.reset_pin(gpio) |
none | Hold this pin low at boot to erase Zigbee NVRAM and re-pair |
.inverted() |
active-high | Reverse PWM polarity for direct GPIO-sink wiring (on_off/dimmable/cct) |
When the built-in GPIO/LEDC/RMT backends don't fit (an IΒ²C PWM chip, a relay
board, custom calibration, your own effects), implement the LightOutput trait
and hand it to custom() β the full pairing, scene, transition, and identify
machinery drives your code:
use huesmith::{HueDeviceType, LightOutput};
struct MyDriver;
impl LightOutput for MyDriver {
fn set_on(&mut self, on: bool) { /* drive your hardware */ }
fn set_brightness(&mut self, level: u8) { /* ZCL level, 0-254 */ }
fn set_rgb(&mut self, r: u8, g: u8, b: u8) { /* colors render through this */ }
}
fn main() {
huesmith::custom(HueDeviceType::DimmableLight, MyDriver).launch();
}Your backend receives well-defined domains: set_brightness is the ZCL level
(0-254, never 255), set_rgb is linear 0-255 per channel, set_color_temp
is raw 153-500 mireds. For full raw state β hue/saturation (0-254),
enhanced hue (0-65535), CIE x/y (0-65535), mireds, on/off, plus both the
logical brightness and the live rendered_brightness that ramps during
fades β override the optional state_update(&LightSnapshot) hook (plain
fields, no Options), which fires after every render including each ~20-33 ms
transition frame. Pick one lane: simple hardware implements the set_*
methods; an effects backend treats state_update as its single source of
truth and leaves the set_* bodies empty. Trait methods run on the Zigbee
task and must return quickly; for animations (e.g. blink when brightness is
10, breathe at 50), spawn your own thread and feed it state through a channel
β the custom() rustdoc carries a complete runnable pattern.
To spoof a specific Philips model (triggers the exact Bridge UI for that bulb type),
pass a preset to .identity() β it applies the manufacturer, model, date code,
software build id, and hardware version in one call:
use huesmith::identity::PHILIPS_LTW015;
huesmith::cct(19, 18)
.identity(&PHILIPS_LTW015)
.launch();Available presets: PHILIPS_LCT016, PHILIPS_LTW015, PHILIPS_LWB014,
PHILIPS_LST002, PHILIPS_LCT012, GENERIC_COLOR_LIGHT, GENERIC_CCT_LIGHT,
GENERIC_DIMMABLE_LIGHT, GENERIC_ON_OFF_LIGHT.
β οΈ Spoofing a real Philips model can trigger a crash loop. When the Bridge recognizes a genuine Hue model identifier it may push a firmware (OTA) update. This library has no OTA backend, so the ZBOSS OTA client asserts and the device reboots in a loop right after pairing β the risk is highest when a real Philipsmodel_identifieris combined with a Signify-range MAC (OUI00:17:88). The generic presets are the safe default; use the Philips presets only if you understand this, and keep the chip's own (non-Signify) MAC. SeeDEVELOPMENT.mdandhuesmith/src/zigbee/endpoint.rsfor the full explanation.
| Mode | Wiring |
|---|---|
on_off(gpio) / dimmable(gpio) |
Active-high PWM: GPIO β LED (or MOSFET gate), β β GND. For an LED wired + β 3V3 with its cathode straight on the GPIO (the GPIO sinks the current), add .inverted() |
cct(cool, warm) |
Active-high PWM: each GPIO drives a low-side N-MOSFET/transistor carrying that channel, lamp + on the supply rail. For a small lamp whose Wβ/Yβ wires are soldered directly to the GPIOs, add .inverted() |
color(gpio) |
WS2812B data line β GPIO (RMT peripheral). The GPIO drives 3.3 V data into a 5 V strip β that is below the WS2812B's nominal 0.7ΓVDD input threshold, so for reliable strips (especially long ones) add a 3.3β5 V level shifter (e.g. 74AHCT125) and a 330 Ξ© series resistor near the strip's DIN, plus a 470-1000 Β΅F capacitor across the strip's 5 V/GND |
See examples/ for wiring notes for each mode.
- Hue App β Settings β Lights β Search
- The ESP32 does network steering automatically on boot.
- The device appears in the Hue App within ~10β30 seconds.
Hue App: "Search for lights"
β
βΌ
Bridge: Permit joining (180 s)
β
βΌ
ESP32: Network steering β joins the Bridge
β
βΌ
Bridge: Reads Basic cluster β identifies device type
β
βΌ
Bridge: Configures attribute reporting
β
βΌ
Hue App: New light appears with correct controls
The default MAC is the chip's efuse ID, which is unique per module and requires no configuration. Override it only when you need a deterministic address β for example, when reflashing with a different device type forces a Bridge cache miss:
// Increment byte 0 each time you change the device type.
// ZBOSS stores EUI-64 in little-endian order (byte 7 is the displayed MSB).
huesmith::cct(19, 18)
.mac([0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x00, 0x01])
.launch();The Hue Bridge caches device descriptors by MAC address. After changing device type or cluster layout, increment the MAC prefix so the Bridge discards the old entry.
huesmith-core is platform-agnostic; its tests run on the host:
cargo test -p huesmith-core --target aarch64-apple-darwin # macOS ARM
cargo test -p huesmith-core --target x86_64-unknown-linux-gnuHue Bridge ESP32
β β
βββββ ZCL SetAttribute βββββΆβ
β (cluster + attr + val)β
β βΌ
β parse_set_attribute()
β β
β LightCommand
β β
β LightState::apply_command()
β β
β LightOutput::set_brightness()
β set_color_xy() β¦
β β
β LED output
β β
βββββ Attribute Report ββββββ
All light types register on endpoint 11, Home Automation profile (0x0104).
Clusters (always): 0x0000 Basic Β· 0x0003 Identify Β· 0x0004 Groups Β· 0x0005 Scenes Β· 0x0006 On/Off
Clusters (dimmable+): 0x0008 Level Control
Clusters (CCT + color): 0x0300 Color Control
Known gaps as of this release β none block normal Hue app control, but they are worth knowing before you build around them:
- Power-on behavior is not persisted. The
StartUpOnOff,StartUpCurrentLevel, andStartUpColorTemperatureattributes are advertised for Hue compatibility, but the boot state is fixed by.starts_on()at compile time β changing "power-on behavior" from the Hue app is accepted and then ignored, not restored on the next power cycle. - No local / physical control. The light is driven entirely over Zigbee. There is
no built-in handling for a physical wall switch or push-button to toggle the light
locally (the
.reset_pin()GPIO is only sampled once, at boot, for factory reset). - No color loop.
ColorLoopSetis not implemented, so the generic color preset advertises capabilities without the loop bit. Seehuesmith_core::hue::device::COLOR_CAP_*. - No OTA firmware updates. The OTA cluster is present only for Bridge discovery; the device cannot actually receive firmware over the air (see the identity-preset warning above).
| Symptom | Fix |
|---|---|
| Bridge cannot find device | Check serial log for "Network steering succeeded" |
| LED not lighting | See Hardware Backends wiring table above |
| Bridge shows stale device type | The Bridge caches by MAC β increment the first MAC byte and re-pair |
| "Steering failed, retrying" loop | Ensure Bridge is in "Search for lights" mode |
| Flash binary too large | opt-level = "s" + lto = true are set in the workspace release profile |
Always use dimmable() when you need dimming |
on_off() omits Level Control; the Bridge won't show a brightness slider |
| Component | Version |
|---|---|
| ESP-IDF | v5.2.2 |
| esp-zigbee-lib | 1.6.8 |
| esp-zboss-lib | 1.6.4 |
| esp-idf-sys | 0.37.2 |
| esp-idf-hal | 0.46.2 |
| esp-idf-svc | 0.52.1 |
DEVELOPMENT.mdβ FFI layout, pairing quirks, debug techniquesdocs/hue-scene-engine.mdβ Scene engine internalsdocs/FFI-SAFETY.mdβ Safety analysis of the Zigbee FFI layer
MIT
Note: This project is for educational and hobby use. It is an independent interoperability project and is not affiliated with, authorized, or endorsed by Signify (Philips Hue). "Philips Hue" and "Signify" are trademarks of Signify Netherlands B.V., used here only to describe compatibility.