huesmith 0.1.0

Hue-compatible Zigbee light library for ESP32-C6/H2 (ESP-IDF)
use huesmith_core::color;
use huesmith_core::light::LightOutput;

use esp_idf_hal::gpio::OutputPin;
use esp_idf_hal::ledc::{LedcChannel, LedcDriver, LedcTimer, LedcTimerDriver, LowSpeed};

use super::electrical_duty;

/// Two-channel CCT (cool/warm white) output driven by LEDC PWM.
///
/// Brightness and colour temperature are both continuously adjustable: the Hue
/// level maps to PWM duty, and the colour-temperature mireds set the cool/warm mix.
///
/// Two wiring polarities are supported:
///
/// - `inverted = false` (default): **active-high** — duty 0 = off, full duty =
///   fully on — for a low-side switch per channel (N-MOSFET or transistor:
///   gate/base ← GPIO, drain/collector → channel, source/emitter → GND), with
///   the lamp's common positive on the supply rail.
/// - `inverted = true`: **direct GPIO sink** — the GPIO sinks current straight
///   from the channel's negative wire (lamp `+` → 3V3, W−/Y− → GPIO), so the
///   electrical duty is reversed: pin parked high = off, low = full on. Only
///   for small loads within the GPIO's current limit.
pub struct CctPwm<'d> {
    _timer: LedcTimerDriver<'d, LowSpeed>,
    cool: LedcDriver<'d>,
    warm: LedcDriver<'d>,
    max_duty: u32,
    brightness: u8,
    color_temp_mireds: u16,
    on: bool,
    inverted: bool,
}

impl<'d> CctPwm<'d> {
    /// Coolest supported color temperature (≈6500 K), the Hue/ZCL minimum.
    pub const MIN_MIREDS: u16 = 153;
    /// Warmest supported color temperature (≈2200 K). Hue ColorTemperatureLight
    /// bulbs advertise 454 as their physical max, vs 500 for ExtendedColorLight.
    pub const MAX_MIREDS: u16 = 454;

    pub fn new<T, C0, C1>(
        timer: T,
        cool_channel: C0,
        cool_pin: impl OutputPin + 'd,
        warm_channel: C1,
        warm_pin: impl OutputPin + 'd,
        inverted: bool,
    ) -> Self
    where
        T: LedcTimer<SpeedMode = LowSpeed> + 'd,
        C0: LedcChannel<SpeedMode = LowSpeed> + 'd,
        C1: LedcChannel<SpeedMode = LowSpeed> + 'd,
    {
        let timer_driver =
            LedcTimerDriver::new(timer, &esp_idf_hal::ledc::config::TimerConfig::default())
                .expect("CCT LEDC timer init failed");

        let cool = LedcDriver::new(cool_channel, &timer_driver, cool_pin)
            .expect("CCT cool channel init failed");
        let warm = LedcDriver::new(warm_channel, &timer_driver, warm_pin)
            .expect("CCT warm channel init failed");
        let max_duty = cool.get_max_duty();

        let mut this = Self {
            _timer: timer_driver,
            cool,
            warm,
            max_duty,
            brightness: 254,
            color_temp_mireds: 370,
            on: false,
            inverted,
        };
        // Park both channels in the electrical "off" state (high when inverted).
        this.all_off();
        this
    }

    fn set_channel_level(driver: &mut LedcDriver<'_>, max_duty: u32, level: u8, inverted: bool) {
        let duty = electrical_duty(level, max_duty, inverted);
        if let Err(e) = driver.set_duty(duty) {
            log::error!("LEDC set_duty failed: {e}");
        }
    }

    fn all_off(&mut self) {
        let inverted = self.inverted;
        let max_duty = self.max_duty;
        Self::set_channel_level(&mut self.cool, max_duty, 0, inverted);
        Self::set_channel_level(&mut self.warm, max_duty, 0, inverted);
    }

    fn apply_current(&mut self) {
        if !self.on {
            self.all_off();
            return;
        }

        let (cool_level, warm_level) = color::cct_mix_from_mireds(
            self.color_temp_mireds,
            self.brightness,
            Self::MIN_MIREDS,
            Self::MAX_MIREDS,
        );
        let max_duty = self.max_duty;
        let inverted = self.inverted;
        Self::set_channel_level(&mut self.cool, max_duty, cool_level, inverted);
        Self::set_channel_level(&mut self.warm, max_duty, warm_level, inverted);
    }

    fn mireds_from_rgb(&mut self, r: u8, g: u8, b: u8) {
        if r == 0 && g == 0 && b == 0 {
            return;
        }
        self.color_temp_mireds =
            color::cct_mireds_from_rgb(r, g, b, Self::MIN_MIREDS, Self::MAX_MIREDS);
    }
}

impl<'d> LightOutput for CctPwm<'d> {
    fn set_on(&mut self, on: bool) {
        self.on = on;
        // The LightOutput contract requires set_on(true) to restore the last
        // output by itself — identify blinking toggles set_on alone, with no
        // render call in between.
        if on {
            self.apply_current();
        } else {
            self.all_off();
        }
    }

    fn set_brightness(&mut self, level: u8) {
        self.brightness = level.min(254);
    }

    fn set_color_xy(&mut self, x: u16, y: u16) {
        let (r, g, b) = color::xy_to_rgb(x, y, self.brightness);
        self.set_rgb(r, g, b);
    }

    fn set_color_temp(&mut self, mireds: u16) {
        self.color_temp_mireds = mireds.clamp(Self::MIN_MIREDS, Self::MAX_MIREDS);
        self.apply_current();
    }

    fn set_rgb(&mut self, r: u8, g: u8, b: u8) {
        self.mireds_from_rgb(r, g, b);
        self.apply_current();
    }
}