huesmith 0.1.0

Hue-compatible Zigbee light library for ESP32-C6/H2 (ESP-IDF)
// The legacy RMT API is deprecated in esp-idf-hal 0.46, but it is the only
// driver whose refill ISR is source-marked IRAM_ATTR for multi-block
// transmissions, so it remains the correct (and ecosystem-standard) choice for
// WS2812B. Silence the deprecation noise for this whole module.
#![allow(deprecated)]

use huesmith_core::color;
use huesmith_core::light::LightOutput;

use core::time::Duration;

use enumset::EnumSet;
use esp_idf_hal::gpio::OutputPin;
use esp_idf_hal::interrupt::InterruptType;
use esp_idf_hal::rmt::config::TransmitConfig;
use esp_idf_hal::rmt::{PinState, Pulse, RmtChannel, TxRmtDriver, VariableLengthSignal};

// WS2812B bit timings (datasheet, ±150 ns tolerance):
//   "0" bit: T0H = 350 ns high, T0L = 800 ns low
//   "1" bit: T1H = 700 ns high, T1L = 600 ns low
const T0H_NS: u64 = 350;
const T0L_NS: u64 = 800;
const T1H_NS: u64 = 700;
const T1L_NS: u64 = 600;

/// WS2812B / NeoPixel addressable LED output.
///
/// Driven through the legacy RMT API (`TxRmtDriver` + `rmt_write_items`). The
/// legacy driver's interrupt handler refills the RMT memory blocks from IRAM as
/// the signal streams out, so a strip of any length transmits correctly. The
/// newer `TxChannelDriver` copy-encoder path instead crashes once the signal
/// exceeds one memory block (≈2 LEDs) because it acquires a lock in ISR context.
///
/// All active LEDs share one colour (the strip is one logical bulb). The pixels
/// in `active_leds..num_leds` are always driven black, so a strip longer than
/// the lit section keeps its tail dark. `signal` is allocated once and rebuilt
/// in place each frame (no per-update reallocation during colour transitions).
pub struct Ws2812b<'d> {
    tx: TxRmtDriver<'d>,
    t0h: Pulse,
    t0l: Pulse,
    t1h: Pulse,
    t1l: Pulse,
    num_leds: usize,
    /// Leading pixels that show color; pixels in `active_leds..num_leds` are
    /// always driven black so an over-long strip's tail stays dark.
    active_leds: usize,
    signal: VariableLengthSignal,
    brightness: u8,
    on: bool,
    current_r: u8,
    current_g: u8,
    current_b: u8,
}

impl<'d> Ws2812b<'d> {
    pub fn new<C, P>(channel: C, pin: P, num_leds: usize, active_leds: usize) -> Self
    where
        C: RmtChannel + 'd,
        P: OutputPin + 'd,
    {
        // clock_divider(1) → 80 MHz counter clock (12.5 ns/tick), enough
        // resolution for the sub-microsecond WS2812B pulses.
        //
        // mem_block_num(2): ESP32-C6's RMT has 4 channels x 48 words, but TX and
        // RX are *separate* pools — channels 0-1 are TX-only, 2-3 RX-only, with
        // distinct config registers (rmt_ll.h: `chnconf0[].mem_size_chn` for TX
        // vs `chmconf[].conf0.mem_size_chm` for RX). A TX channel can therefore
        // extend only into the other TX channel's memory: 2 blocks (96 words =
        // 4 LEDs) is the hardware maximum for channel 0. The legacy driver's
        // `mem_block_num + channel <= 4` check predates this split (single-pool
        // ESP32) and would happily accept an invalid 4. The default of 1
        // (48 words = 2 LEDs) means every strip beyond 2 LEDs relies on the
        // refill ISR firing before the buffer empties; 2 blocks doubles that
        // margin. Safe because this crate never uses RMT channel 1.
        //
        // intr_flags(Iram): the refill ISR (`rmt_driver_isr_default`) is already
        // source-marked IRAM_ATTR, but esp_intr_alloc still disables any
        // interrupt not explicitly flagged IRAM-safe for the duration of a flash
        // op (e.g. Zigbee writing NVRAM/NVS in the background). Without this
        // flag, an NVRAM write that lands mid-transmission stalls the refill,
        // and >50us of a stalled data line is read by WS2812B as a frame reset —
        // exactly the "extra pixel(s) light up" / "colors go erratic on longer
        // strips" symptom (worse on longer strips: a longer transmission window
        // has more chance of overlapping a background flash write).
        let config = TransmitConfig::new()
            .clock_divider(1)
            .mem_block_num(2)
            .intr_flags(EnumSet::only(InterruptType::Iram));
        let tx = TxRmtDriver::new(channel, pin, &config).expect("WS2812B RMT init failed");

        let ticks_hz = tx.counter_clock().expect("WS2812B counter clock");
        let pulse = |state: PinState, ns: u64| {
            Pulse::new_with_duration(ticks_hz, state, &Duration::from_nanos(ns))
                .expect("WS2812B pulse")
        };

        let active_leds = active_leds.min(num_leds);
        log::info!(
            "WS2812B: legacy RMT @ {} Hz ({num_leds} LEDs, {active_leds} active)",
            u32::from(ticks_hz),
        );

        let mut this = Self {
            tx,
            t0h: pulse(PinState::High, T0H_NS),
            t0l: pulse(PinState::Low, T0L_NS),
            t1h: pulse(PinState::High, T1H_NS),
            t1l: pulse(PinState::Low, T1L_NS),
            num_leds,
            active_leds,
            // 24 bits/LED × 1 pulse pair each. with_capacity counts pulses (2/bit).
            signal: VariableLengthSignal::with_capacity(num_leds * 24 * 2),
            brightness: 254,
            on: false,
            current_r: 0,
            current_g: 0,
            current_b: 0,
        };
        // Park the strip dark at construction (mirrors how SimpleLed/CctPwm park
        // their pins). Without an initial frame the WS2812 latches hold whatever
        // color they had before a warm reset — a brownout that resets the MCU but
        // keeps the 5 V rail up would leave an "off" light fully lit until the
        // first Hue command, and the tail-black guarantee for active_leds..num_leds
        // would not hold either.
        this.send_pixels(0, 0, 0);
        this
    }

    /// Append one pixel's 24 bits (GRB, MSB first) to `signal`.
    /// Returns false if the signal buffer rejected a push.
    fn push_pixel(
        signal: &mut VariableLengthSignal,
        pulses: &(Pulse, Pulse, Pulse, Pulse),
        r: u8,
        g: u8,
        b: u8,
    ) -> bool {
        let (t0h, t0l, t1h, t1l) = pulses;
        for &byte in &[g, r, b] {
            for bit_pos in (0..8).rev() {
                let (high, low) = if (byte >> bit_pos) & 1 != 0 {
                    (t1h, t1l)
                } else {
                    (t0h, t0l)
                };
                if signal.push([high, low]).is_err() {
                    return false;
                }
            }
        }
        true
    }

    /// Drive the active pixels to one colour; the trailing pixels are black.
    ///
    /// `start_blocking` busy-waits in the calling (ZBOSS) task for the whole
    /// frame: 24 bits × ~1.25 µs ≈ 30 µs per LED, so ~3 ms at 100 LEDs and
    /// ~9 ms at the builder's 300-LED cap — well inside what the Zigbee stack
    /// tolerates per transition step, which is why the cap exists.
    fn send_pixels(&mut self, r: u8, g: u8, b: u8) {
        if self.num_leds == 0 {
            return;
        }
        let pulses = (self.t0h, self.t0l, self.t1h, self.t1l);
        self.signal.clear();
        for i in 0..self.num_leds {
            let (pr, pg, pb) = if i < self.active_leds {
                (r, g, b)
            } else {
                (0, 0, 0)
            };
            if !Self::push_pixel(&mut self.signal, &pulses, pr, pg, pb) {
                log::error!("WS2812B: signal push failed");
                return;
            }
        }
        if let Err(e) = self.tx.start_blocking(&self.signal) {
            log::error!("WS2812B RMT transmit failed: {e}");
        }
    }
}

impl<'d> LightOutput for Ws2812b<'d> {
    fn set_on(&mut self, on: bool) {
        self.on = on;
        if on {
            self.send_pixels(self.current_r, self.current_g, self.current_b);
        } else {
            self.send_pixels(0, 0, 0);
        }
    }

    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) {
        let (r, g, b) = color::scale_rgb(color::mireds_to_rgb(mireds), self.brightness);
        self.set_rgb(r, g, b);
    }

    fn set_rgb(&mut self, r: u8, g: u8, b: u8) {
        self.current_r = r;
        self.current_g = g;
        self.current_b = b;
        if self.on {
            self.send_pixels(r, g, b);
        }
    }
}