huesmith 0.1.0

Hue-compatible Zigbee light library for ESP32-C6/H2 (ESP-IDF)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Build Philips Hue–compatible Zigbee lights on the ESP32-C6 / ESP32-H2.
//!
//! Any LED — on/off, dimmable, CCT, or a full-colour WS2812B strip — becomes a
//! device that pairs directly with a real Hue Bridge. Construct a light with one
//! of the entry points ([`on_off`], [`dimmable`], [`cct`], [`color`],
//! [`rgb_strip`], [`custom`]), customise it with the [`Light`] builder, then
//! call [`Light::launch`].
//!
//! ```no_run
//! // Cool/warm white CCT light on GPIO 19 / 18; pairs with any Hue Bridge.
//! huesmith::cct(19, 18).starts_on().launch();
//! ```
//!
//! Platform-agnostic logic (color math, state machine, scene parsing) lives in the
//! companion `huesmith-core` crate. This crate provides the ESP-IDF hardware backends
//! and the Zigbee (ZBOSS) integration.

mod driver;
pub(crate) mod light;
pub(crate) mod zigbee;

// Re-export the `huesmith-core` types that appear in this crate's public API, so
// a user writing a `custom()` backend or passing an `.identity()` preset does not
// have to add (and version-lock) a second `huesmith-core` dependency by hand.
// Exported under their original names to match the rustdoc examples.
pub use huesmith_core::hue::device::HueDeviceType;
pub use huesmith_core::hue::identity::{self, DeviceIdentity};
pub use huesmith_core::light::state::LightSnapshot;
pub use huesmith_core::light::LightOutput;

// ── Builder entry points ─────────────────────────────────────────────────────

/// Create a dimmable LED light on a single PWM GPIO.
///
/// Active-high by default (GPIO high = on); chain [`Light::inverted`] when the
/// GPIO sinks current directly from the LED's cathode instead.
/// The Hue Bridge sees this as a dimmable white bulb.
pub fn dimmable(gpio: u8) -> Light {
    Light::dimmable(gpio)
}

/// Create an on/off light on a single GPIO (no dimming).
///
/// Active-high by default; chain [`Light::inverted`] for direct-sink wiring.
/// The Hue Bridge sees this as an on/off light with no level control.
pub fn on_off(gpio: u8) -> Light {
    Light::on_off(gpio)
}

/// Create a CCT (warm + cool white) light on two PWM GPIOs.
///
/// The default polarity is **active-high**: each GPIO drives a low-side switch
/// (N-MOSFET or transistor) that carries the channel current, with the lamp's
/// common positive on the supply rail. For a small lamp whose negative wires
/// are soldered **directly to the GPIOs** (the GPIO itself sinks the current),
/// chain [`Light::inverted`] to flip the electrical duty accordingly.
///
/// The Hue Bridge sees this as a color-temperature tunable bulb.
///
/// # Arguments
/// * `cool_gpio` — GPIO for the cool white (high K) channel.
/// * `warm_gpio` — GPIO for the warm white (low K) channel.
pub fn cct(cool_gpio: u8, warm_gpio: u8) -> Light {
    Light::cct(cool_gpio, warm_gpio)
}

/// Create a WS2812B RGB color light on a single data GPIO.
///
/// The Hue Bridge sees this as a full color (RGB + CCT) bulb.
/// Chain `.leds(n)` to drive a strip of n LEDs (default: 1).
pub fn color(gpio: u8) -> Light {
    Light::color(gpio)
}

/// Create a WS2812B RGB strip with a given LED count.
///
/// Equivalent to `color(gpio).leds(count)`. Convenience entry point
/// for multi-LED strips.
///
/// # Panics
/// Panics if `count` is 0 or exceeds 300.
pub fn rgb_strip(gpio: u8, count: usize) -> Light {
    Light::color(gpio).leds(count)
}

/// Create a light driven by your own [`LightOutput`] backend.
///
/// This is the escape hatch when the built-in GPIO/LEDC/RMT backends don't fit
/// your hardware — an I²C PWM controller, a DALI gateway, a relay board, an
/// exotic LED protocol, custom gamma/calibration curves, or effects of your
/// own. Implement [`LightOutput`] (only `set_on`, `set_brightness`, and
/// `set_rgb` are required; see the trait's call contract), pick the
/// [`HueDeviceType`] that matches the capabilities your hardware really has
/// (it decides which Zigbee clusters are advertised), and the full Hue
/// pairing, scene, transition, and identify machinery drives your
/// implementation.
///
/// # Value domains your backend receives
///
/// | Callback | Values | Range |
/// |---|---|---|
/// | `set_brightness(level)` | ZCL level | `0-254` (254 = full; never 255) |
/// | `set_rgb(r, g, b)` | converted color | `0-255` per channel, linear |
/// | `set_color_temp(mireds)` | raw color temperature | `153-500` mireds |
/// | `state_update(&LightSnapshot)` | **every raw field, no `Option`s** | on, brightness + rendered_brightness `0-254`, hue/sat `0-254`, enhanced hue `0-65535`, CIE x/y `0-65535`, mireds |
///
/// `state_update` fires after every render — including each ~20-33 ms frame of
/// a smooth transition — so effects can key off the raw values directly
/// instead of reverse-engineering them from RGB. `brightness` is the logical
/// target level; `rendered_brightness` is what is being driven right now (it
/// ramps during fades — key visual effects off it). **Pick one lane:** simple
/// hardware implements the `set_*` methods and ignores the hook; an
/// effects/full-control backend treats the hook as its single source of truth
/// and leaves the `set_*` bodies empty.
///
/// # Running your own effects (threads welcome)
///
/// `LightOutput` methods are called on the Zigbee stack's task and must return
/// quickly (microseconds-to-a-few-ms; never block on delays). For animations,
/// own a thread and feed it state — full `std` is available on ESP-IDF:
///
/// ```no_run
/// use huesmith::{HueDeviceType, LightOutput, LightSnapshot};
/// use std::sync::mpsc;
///
/// struct FxDriver {
///     tx: mpsc::Sender<LightSnapshot>,
/// }
///
/// impl LightOutput for FxDriver {
///     fn set_on(&mut self, _on: bool) {}
///     fn set_brightness(&mut self, _level: u8) {}
///     fn set_rgb(&mut self, _r: u8, _g: u8, _b: u8) {}
///     // Forward the raw state; the effect thread decides what it means.
///     fn state_update(&mut self, state: &LightSnapshot) {
///         let _ = self.tx.send(*state);
///     }
/// }
///
/// fn main() {
///     let (tx, rx) = mpsc::channel::<LightSnapshot>();
///
///     // The effect thread owns the LED hardware and its own timing:
///     // brightness 10 → blink, brightness 50 → breathe, anything else → solid.
///     std::thread::spawn(move || {
///         let mut latest: Option<LightSnapshot> = None;
///         loop {
///             while let Ok(state) = rx.try_recv() {
///                 latest = Some(state);
///             }
///             if let Some(state) = latest {
///                 match state.brightness {
///                     10 => { /* toggle the LED — blink */ }
///                     50 => { /* sine-ramp the LED — breathe */ }
///                     _ => { /* drive state.rendered_brightness as-is */ }
///                 }
///             }
///             std::thread::sleep(std::time::Duration::from_millis(40));
///         }
///     });
///
///     huesmith::custom(HueDeviceType::DimmableLight, FxDriver { tx }).launch();
/// }
/// ```
pub fn custom(device_type: HueDeviceType, output: impl LightOutput + 'static) -> Light {
    Light::custom(device_type, output)
}

// ── Light builder ────────────────────────────────────────────────────────────

/// Builder for a Hue-compatible Zigbee light.
///
/// Construct via the module-level free functions ([`dimmable`], [`on_off`],
/// [`cct`], [`color`], [`custom`]) and finish with [`Light::launch`].
///
/// # Example
/// ```no_run
/// huesmith::cct(19, 18)
///     .name("Philips", "LTW015")
///     .starts_on()
///     .launch();
/// ```
pub struct Light {
    pub(crate) kind: LightKind,
    pub(crate) identity: DeviceIdentity,
    pub(crate) mac: Option<[u8; 8]>,
    pub(crate) power_on: bool,
    pub(crate) channel: Option<u8>,
    pub(crate) leds: usize,
    /// Number of leading pixels that show color; the rest are driven black.
    /// `None` = all `leds` are active.
    pub(crate) active_leds: Option<usize>,
    pub(crate) reset_pin: Option<u8>,
    /// Reverse the electrical PWM polarity (direct GPIO-sink wiring).
    pub(crate) inverted: bool,
    /// User-supplied backend for [`LightKind::Custom`].
    pub(crate) custom_output: Option<Box<dyn LightOutput>>,
}

#[derive(Clone, Copy, Debug)]
pub(crate) enum LightKind {
    Dimmable { gpio: u8 },
    OnOff { gpio: u8 },
    Cct { cool_gpio: u8, warm_gpio: u8 },
    Color { gpio: u8 },
    Custom { device_type: HueDeviceType },
}

impl LightKind {
    pub(crate) fn device_type(self) -> HueDeviceType {
        match self {
            Self::Dimmable { .. } => HueDeviceType::DimmableLight,
            Self::OnOff { .. } => HueDeviceType::OnOffLight,
            Self::Cct { .. } => HueDeviceType::ColorTemperatureLight,
            Self::Color { .. } => HueDeviceType::ExtendedColorLight,
            Self::Custom { device_type } => device_type,
        }
    }

    fn default_model(self) -> &'static str {
        match self.device_type() {
            HueDeviceType::DimmableLight => "HUESMITH-DIM01",
            HueDeviceType::OnOffLight => "HUESMITH-ONF01",
            HueDeviceType::ColorTemperatureLight => "HUESMITH-CCT01",
            HueDeviceType::ExtendedColorLight => "HUESMITH-ECL01",
        }
    }
}

impl Light {
    fn new(kind: LightKind) -> Self {
        Self {
            identity: DeviceIdentity {
                manufacturer_name: "huesmith",
                model_identifier: kind.default_model(),
                date_code: "20250101",
                sw_build_id: env!("CARGO_PKG_VERSION"),
                hw_version: 1,
            },
            kind,
            mac: None,
            power_on: false,
            channel: None,
            leds: 1,
            active_leds: None,
            reset_pin: None,
            inverted: false,
            custom_output: None,
        }
    }

    pub fn dimmable(gpio: u8) -> Self {
        Self::new(LightKind::Dimmable { gpio })
    }

    pub fn on_off(gpio: u8) -> Self {
        Self::new(LightKind::OnOff { gpio })
    }

    pub fn cct(cool_gpio: u8, warm_gpio: u8) -> Self {
        Self::new(LightKind::Cct {
            cool_gpio,
            warm_gpio,
        })
    }

    pub fn color(gpio: u8) -> Self {
        Self::new(LightKind::Color { gpio })
    }

    /// Builder form of the module-level [`custom`] entry point.
    pub fn custom(device_type: HueDeviceType, output: impl LightOutput + 'static) -> Self {
        let mut light = Self::new(LightKind::Custom { device_type });
        light.custom_output = Some(Box::new(output));
        light
    }

    // ── Customization ────────────────────────────────────────────────────────

    /// GPIO pin that triggers a factory reset when held **low at boot**.
    ///
    /// When the specified pin reads low during startup, the Zigbee NVS storage
    /// is erased and the device re-enters pairing mode on next boot. Use the
    /// board's BOOT button (GPIO 9 on most ESP32-C6 boards) or any spare GPIO
    /// tied to a momentary switch connected to GND.
    ///
    /// The internal pull-up is enabled automatically, so a floating pin is
    /// read as high (normal operation).
    pub fn reset_pin(mut self, gpio: u8) -> Self {
        self.reset_pin = Some(gpio);
        self
    }

    /// Set the number of WS2812B LEDs in the strip (only relevant for `color()`).
    ///
    /// Default is 1 (single onboard LED).
    ///
    /// # Panics
    /// Panics if `count` is 0 or exceeds 300.
    pub fn leds(mut self, count: usize) -> Self {
        assert!(
            count > 0 && count <= 300,
            "LED count must be 1–300, got {count}"
        );
        self.leds = count;
        self
    }

    /// Light only the first `count` pixels; drive the rest of the strip black.
    ///
    /// Use this when the physical strip is longer than the section you want lit,
    /// and you don't know its exact length: set `leds()` to a safe upper bound
    /// (≥ the real strip length) and `active_leds()` to how many should show
    /// color. Every pixel up to `leds()` receives data, so the unused tail is
    /// reliably turned off instead of holding a random power-on color. Surplus
    /// data past the physical end of the strip is harmless.
    ///
    /// # Panics
    /// Panics if `count` is 0.
    pub fn active_leds(mut self, count: usize) -> Self {
        assert!(count > 0, "active LED count must be ≥ 1, got {count}");
        self.active_leds = Some(count);
        self
    }

    /// Reverse the electrical PWM polarity for the GPIO/LEDC backends
    /// (`on_off`, `dimmable`, `cct`).
    ///
    /// Default (not inverted) is **active-high**: duty grows with brightness,
    /// which suits an LED wired GPIO-high = on or a low-side switch (N-MOSFET /
    /// transistor) carrying the channel current. Call this when the GPIO
    /// instead **sinks current directly from the LED's cathode** (LED `+` →
    /// 3V3, `−` → GPIO): electrically, duty 0 must then mean "pin parked high /
    /// LED dark" and full duty "pin low / LED fully on", so the duty cycle is
    /// reversed inside the driver. Direct sink is only for small loads within
    /// the GPIO's current limit.
    ///
    /// Has no effect on `color()` (WS2812B is a data protocol, not PWM) or on
    /// `custom()` backends, which own their polarity.
    pub fn inverted(mut self) -> Self {
        self.inverted = true;
        self
    }

    /// Override the Zigbee device identity visible to the Hue Bridge.
    ///
    /// `manufacturer` and `model` must be `'static` string literals.
    /// Defaults to `("huesmith", "HUESMITH-*")` based on light type.
    ///
    /// To spoof a real Philips bulb, use one of the presets from
    /// [`huesmith::identity`](crate::identity) (e.g., `PHILIPS_LCT016`).
    pub fn name(mut self, manufacturer: &'static str, model: &'static str) -> Self {
        self.identity.manufacturer_name = manufacturer;
        self.identity.model_identifier = model;
        self
    }

    /// Set the full Zigbee Basic-cluster identity from a [`DeviceIdentity`].
    ///
    /// Unlike [`Light::name`], which overrides only the manufacturer and model,
    /// this also applies the preset's date code, software build id, and hardware
    /// version, so the Bridge sees the complete identity of the spoofed model.
    ///
    /// ```no_run
    /// use huesmith::identity::PHILIPS_LTW015;
    /// huesmith::cct(19, 18).identity(&PHILIPS_LTW015).launch();
    /// ```
    ///
    /// ⚠️ Spoofing a real Philips `model_identifier` can make the Bridge push a
    /// firmware (OTA) update this library cannot service, crashing the device in
    /// a boot loop — especially if combined with a Signify-range MAC. Prefer the
    /// generic presets unless you understand that risk (see the crate README).
    pub fn identity(mut self, id: &DeviceIdentity) -> Self {
        self.identity = *id;
        self
    }

    /// Override the Zigbee IEEE EUI-64 MAC address.
    ///
    /// By default the chip's unique efuse ID is used, which is unique per
    /// module and requires no configuration. Override this when you need a
    /// deterministic, reproducible address — for example to avoid Hue Bridge
    /// descriptor cache collisions during repeated development flashes.
    ///
    /// The bytes are stored in ESP ZBOSS little-endian order: `mac[7]` is
    /// the most significant byte (first displayed octet).
    pub fn mac(mut self, mac: [u8; 8]) -> Self {
        self.mac = Some(mac);
        self
    }

    /// Start the light in the on state after boot (default: off).
    ///
    /// Mirrors real Hue bulb behavior after a power cycle, which avoids
    /// stale Hue app state when reflashing during development.
    pub fn starts_on(mut self) -> Self {
        self.power_on = true;
        self
    }

    /// Lock the Zigbee stack to a specific 802.15.4 channel (11–26).
    ///
    /// By default all channels are scanned for maximum compatibility.
    /// Only set this if you know your Hue Bridge channel; an incorrect
    /// value will prevent pairing.
    ///
    /// # Panics
    /// Panics if `ch` is outside the valid 802.15.4 range 11–26.
    pub fn channel(mut self, ch: u8) -> Self {
        assert!(
            (11..=26).contains(&ch),
            "802.15.4 channel must be 11-26, got {ch}"
        );
        self.channel = Some(ch);
        self
    }

    // ── Terminal ─────────────────────────────────────────────────────────────

    /// Initialize the hardware and Zigbee stack, then enter the main loop.
    ///
    /// Consumes the builder — the peripherals and Zigbee stack can only be
    /// initialized once per boot. This function never returns.
    pub fn launch(self) -> ! {
        driver::launch(self)
    }
}