Skip to content

canack/huesmith

Repository files navigation

huesmith

CI crates.io docs.rs MSRV license

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.

hue-huesmith-demo-ezgif com-optimize
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    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.

Getting Started

Prerequisites

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-espflash

Linux system packages: espflash links against libudev, so install it first β€” sudo apt install libudev-dev pkg-config on 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 first cargo build (this needs git and a working C toolchain, which both platforms already have via Xcode CLT / build-essential).

A rust-toolchain.toml pinning nightly + rust-src (as in this repo) makes the nightly selection automatic for your project.

Project layout

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();
}

Build and flash

cargo espflash flash --release --monitor

The 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 the huesmith::…() call changes. Ready-to-copy main.rs bodies for each type live in huesmith/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 on huesmith), so copy the one you want into your project's src/main.rs and flash from there.


Examples

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 2

CCT (tunable white) β€” cool + warm PWM channels:

huesmith::cct(19, 18).launch(); // cool on GPIO 19, warm on GPIO 18

Dimmable β€” 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();

Builder API

Entry points

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

Builder methods

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)

Bring your own hardware

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.

Identity presets

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 Philips model_identifier is combined with a Signify-range MAC (OUI 00: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. See DEVELOPMENT.md and huesmith/src/zigbee/endpoint.rs for the full explanation.


Hardware Backends

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.


Pairing

  1. Hue App β†’ Settings β†’ Lights β†’ Search
  2. The ESP32 does network steering automatically on boot.
  3. 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

MAC Address

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.


Running Tests

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-gnu

Data Flow

Hue Bridge                    ESP32
    β”‚                           β”‚
    │──── ZCL SetAttribute ────▢│
    β”‚     (cluster + attr + val)β”‚
    β”‚                           β–Ό
    β”‚                  parse_set_attribute()
    β”‚                           β”‚
    β”‚                     LightCommand
    β”‚                           β”‚
    β”‚               LightState::apply_command()
    β”‚                           β”‚
    β”‚             LightOutput::set_brightness()
    β”‚                    set_color_xy() …
    β”‚                           β”‚
    β”‚                        LED output
    β”‚                           β”‚
    │◄─── Attribute Report ─────│

Zigbee Endpoint

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

Limitations

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, and StartUpColorTemperature attributes 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. ColorLoopSet is not implemented, so the generic color preset advertises capabilities without the loop bit. See huesmith_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).

Troubleshooting

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

SDK Versions

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

Further Reading


License

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.

About

Turn a ~$10 ESP32 into a smart light that pairs directly with your Philips Hue Bridge β€” no cloud, no hub. A Rust library. πŸ’‘

Topics

Resources

License

Contributing

Stars

2 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors