Skip to main content

device_envoy_core/
led_strip.rs

1//! Shared LED-strip building blocks used across all device-envoy platforms.
2//!
3//! This module provides platform-independent types and traits for NeoPixel-style
4//! (WS2812) LED strips. See the platform crate (`device-envoy-rp` or
5//! `device-envoy-esp`) for the primary documentation and examples.
6
7// ============================================================================
8// Re-exports: color types
9// ============================================================================
10
11/// 8-bit RGB color.
12///
13/// Used in [`Frame1d`] for pixel colors. See [`colors`] for predefined constants.
14/// Converts to [`Rgb888`] via [`ToRgb888::to_rgb888`].
15#[doc(inline)]
16pub use smart_leds::RGB8;
17
18/// Predefined [`RGB8`] color constants (CSS/Web names).
19///
20/// `GREEN` is `(0, 128, 0)`; `LIME` is `(0, 255, 0)`.
21pub mod colors {
22    pub use smart_leds::colors::*;
23}
24
25/// 8-bit-per-channel RGB color from `embedded-graphics`.
26///
27/// Get named colors from [`colors`] and convert with [`ToRgb888::to_rgb888`].
28/// Converts to [`RGB8`] via [`ToRgb8::to_rgb8`].
29#[doc(inline)]
30pub use embedded_graphics::pixelcolor::Rgb888;
31
32// ============================================================================
33// Color conversion traits
34// ============================================================================
35
36/// Convert a color to [`RGB8`] for LED strip rendering.
37pub trait ToRgb8 {
38    /// Convert to [`RGB8`].
39    #[must_use]
40    fn to_rgb8(self) -> RGB8;
41}
42
43impl ToRgb8 for RGB8 {
44    #[inline(always)]
45    fn to_rgb8(self) -> RGB8 {
46        self
47    }
48}
49
50impl ToRgb8 for Rgb888 {
51    #[inline(always)]
52    fn to_rgb8(self) -> RGB8 {
53        use embedded_graphics::prelude::RgbColor;
54        RGB8::new(self.r(), self.g(), self.b())
55    }
56}
57
58/// Convert a color to [`Rgb888`] for `embedded-graphics` rendering.
59pub trait ToRgb888 {
60    /// Convert to [`Rgb888`].
61    #[must_use]
62    fn to_rgb888(self) -> Rgb888;
63}
64
65impl ToRgb888 for RGB8 {
66    #[inline(always)]
67    fn to_rgb888(self) -> Rgb888 {
68        Rgb888::new(self.r, self.g, self.b)
69    }
70}
71
72impl ToRgb888 for Rgb888 {
73    #[inline(always)]
74    fn to_rgb888(self) -> Rgb888 {
75        self
76    }
77}
78
79// ============================================================================
80// Gamma correction
81// ============================================================================
82
83/// Gamma correction configuration for LED strips.
84///
85/// See the platform crate's `led_strip!` macro documentation for usage and context.
86#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
87pub enum Gamma {
88    /// No correction; raw LED PWM values.
89    Linear,
90    /// Perceptual sRGB semantics (gamma 2.2). Preserves named color intent.
91    #[default]
92    Srgb,
93    /// Compatibility with historical `smart_leds::gamma()` curve (2.8).
94    SmartLeds,
95}
96
97/// Default gamma used by the `led_strip!` macro.
98#[doc(hidden)]
99pub const GAMMA_DEFAULT: Gamma = Gamma::Srgb;
100
101/// Default max_frames used by the `led_strip!` macro.
102#[doc(hidden)]
103pub const MAX_FRAMES_DEFAULT: usize = 16;
104
105/// Gamma 2.2 lookup table (sRGB).
106pub(crate) const GAMMA_SRGB_TABLE: [u8; 256] = [
107    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2,
108    3, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 11, 11,
109    11, 12, 12, 13, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 22, 22, 23,
110    23, 24, 25, 25, 26, 26, 27, 28, 28, 29, 30, 30, 31, 32, 33, 33, 34, 35, 35, 36, 37, 38, 39, 39,
111    40, 41, 42, 43, 43, 44, 45, 46, 47, 48, 49, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
112    62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 73, 74, 75, 76, 77, 78, 79, 81, 82, 83, 84, 85, 87, 88,
113    89, 90, 91, 93, 94, 95, 97, 98, 99, 100, 102, 103, 105, 106, 107, 109, 110, 111, 113, 114, 116,
114    117, 119, 120, 121, 123, 124, 126, 127, 129, 130, 132, 133, 135, 137, 138, 140, 141, 143, 145,
115    146, 148, 149, 151, 153, 154, 156, 158, 159, 161, 163, 165, 166, 168, 170, 172, 173, 175, 177,
116    179, 181, 182, 184, 186, 188, 190, 192, 194, 196, 197, 199, 201, 203, 205, 207, 209, 211, 213,
117    215, 217, 219, 221, 223, 225, 227, 229, 231, 234, 236, 238, 240, 242, 244, 246, 248, 251, 253,
118    255,
119];
120
121/// Gamma 2.8 lookup table (matches `smart_leds::gamma()`).
122pub(crate) const GAMMA_SMARTLEDS_TABLE: [u8; 256] = [
123    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1,
124    1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5,
125    5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 13, 13, 13, 14,
126    14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 24, 24, 25, 25, 26, 27,
127    27, 28, 29, 29, 30, 31, 32, 32, 33, 34, 35, 35, 36, 37, 38, 39, 39, 40, 41, 42, 43, 44, 45, 46,
128    47, 48, 49, 50, 50, 51, 52, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 66, 67, 68, 69, 70, 72,
129    73, 74, 75, 77, 78, 79, 81, 82, 83, 85, 86, 87, 89, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104,
130    105, 107, 109, 110, 112, 114, 115, 117, 119, 120, 122, 124, 126, 127, 129, 131, 133, 135, 137,
131    138, 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 160, 162, 164, 167, 169, 171, 173, 175,
132    177, 180, 182, 184, 186, 189, 191, 193, 196, 198, 200, 203, 205, 208, 210, 213, 215, 218, 220,
133    223, 225, 228, 231, 233, 236, 239, 241, 244, 247, 249, 252, 255,
134];
135
136const LINEAR_TABLE: [u8; 256] = {
137    let mut t = [0u8; 256];
138    let mut index = 0usize;
139    // TODO_NIGHTLY When nightly feature const_for becomes stable, replace this
140    // while loop with a for loop.
141    while index < 256 {
142        t[index] = index as u8;
143        index += 1;
144    }
145    t
146};
147
148/// Build a combined gamma + brightness scaling lookup table (const-evaluable).
149///
150/// Used by the `led_strip!` macro to generate `COMBO_TABLE` at compile time.
151#[doc(hidden)]
152#[must_use]
153pub const fn generate_combo_table(gamma: Gamma, max_brightness: u8) -> [u8; 256] {
154    let gamma_table = match gamma {
155        Gamma::Linear => &LINEAR_TABLE,
156        Gamma::Srgb => &GAMMA_SRGB_TABLE,
157        Gamma::SmartLeds => &GAMMA_SMARTLEDS_TABLE,
158    };
159    let mut result = [0u8; 256];
160    let mut index = 0usize;
161    // TODO_NIGHTLY When nightly feature const_for becomes stable, replace this
162    // while loop with a for loop.
163    while index < 256 {
164        let corrected = gamma_table[index];
165        result[index] = ((corrected as u16 * max_brightness as u16) / 255) as u8;
166        index += 1;
167    }
168    result
169}
170
171// ============================================================================
172// Current budget → max brightness
173// ============================================================================
174
175/// Current budget for a single LED strip.
176///
177/// Used by the `led_strip!` macro to derive `MAX_BRIGHTNESS` on the generated struct.
178#[derive(Clone, Copy, Debug, Eq, PartialEq)]
179pub enum Current {
180    /// Current limit in milliamps.
181    Milliamps(u32),
182    /// No limit — full brightness.
183    Unlimited,
184}
185
186impl Default for Current {
187    fn default() -> Self {
188        Self::Milliamps(250)
189    }
190}
191
192impl Current {
193    /// Compute the maximum per-channel brightness (0–255) that keeps total
194    /// current draw within budget assuming `worst_case_ma` at full brightness.
195    ///
196    /// Returns 255 (full brightness) for [`Current::Unlimited`], or a scaled value for
197    /// [`Current::Milliamps`].
198    #[doc(hidden)]
199    #[must_use]
200    pub const fn max_brightness(self, worst_case_ma: u32) -> u8 {
201        assert!(worst_case_ma > 0, "worst_case_ma must be positive");
202        match self {
203            Self::Milliamps(ma) => {
204                let scale = (ma as u64 * 255) / worst_case_ma as u64;
205                if scale > 255 { 255 } else { scale as u8 }
206            }
207            Self::Unlimited => 255,
208        }
209    }
210}
211
212// ============================================================================
213// Frame1d
214// ============================================================================
215
216use core::ops::{Deref, DerefMut};
217
218/// 1D pixel array used to describe LED strip patterns.
219///
220/// See the platform crate's `led_strip` module documentation for usage examples.
221///
222/// Frames deref to `[RGB8; N]`, so you can mutate pixels directly before
223/// passing them to the generated strip's `write_frame` method.
224#[derive(Clone, Copy, Debug)]
225pub struct Frame1d<const N: usize>(pub [RGB8; N]);
226
227impl<const N: usize> Frame1d<N> {
228    /// Number of LEDs in this frame.
229    pub const LEN: usize = N;
230
231    /// Create a new blank (all-black) frame.
232    #[must_use]
233    pub const fn new() -> Self {
234        Self([RGB8::new(0, 0, 0); N])
235    }
236
237    /// Create a frame filled with a single color.
238    #[must_use]
239    pub const fn filled(color: RGB8) -> Self {
240        Self([color; N])
241    }
242}
243
244impl<const N: usize> Deref for Frame1d<N> {
245    type Target = [RGB8; N];
246    fn deref(&self) -> &Self::Target {
247        &self.0
248    }
249}
250
251impl<const N: usize> DerefMut for Frame1d<N> {
252    fn deref_mut(&mut self) -> &mut Self::Target {
253        &mut self.0
254    }
255}
256
257impl<const N: usize> From<[RGB8; N]> for Frame1d<N> {
258    fn from(array: [RGB8; N]) -> Self {
259        Self(array)
260    }
261}
262
263impl<const N: usize> From<Frame1d<N>> for [RGB8; N] {
264    fn from(frame: Frame1d<N>) -> Self {
265        frame.0
266    }
267}
268
269impl<const N: usize> Default for Frame1d<N> {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275// ============================================================================
276// Command channel and LedStrip handle
277// ============================================================================
278
279use core::borrow::Borrow;
280use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
281use embassy_sync::signal::Signal;
282use embassy_time::Duration;
283use heapless::Vec;
284
285/// Platform-agnostic LED strip device contract.
286///
287/// Platform crates implement this for their concrete LED strip types so shared logic can
288/// drive strips without knowing the underlying hardware backend.
289///
290/// This page serves as the definitive reference for what a generated LED strip type
291/// provides. For first-time readers, start with the `led_strip` module documentation in your
292/// platform crate (`device-envoy-rp` or `device-envoy-esp`), then return here for a
293/// complete list of available methods and associated constants.
294///
295/// Design intent:
296///
297/// - Primitive operations are [`LedStrip::write_frame`] and [`LedStrip::animate`].
298/// - This trait is intended for static dispatch on embedded targets.
299///
300/// # Example: Write a Single 1-Dimensional Frame
301///
302/// In this example, we set every other LED to blue and gray.
303///
304/// ![LED strip preview](https://raw.githubusercontent.com/CarlKCarlK/device-envoy/main/crates/device-envoy-core/docs/assets/led_strip_simple.png)
305///
306/// ```rust,no_run
307/// use device_envoy_core::led_strip::{Frame1d, LedStrip, colors};
308///
309/// fn write_alternating_blue_gray<const N: usize>(led_strip: &impl LedStrip<N>) {
310///     let mut frame = Frame1d::new();
311///     for pixel_index in 0..N {
312///         frame[pixel_index] = [colors::BLUE, colors::GRAY][pixel_index % 2];
313///     }
314///     led_strip.write_frame(frame);
315/// }
316///
317/// # struct LedStripSimple;
318/// # impl LedStrip<8> for LedStripSimple {
319/// #     const MAX_FRAMES: usize = 16;
320/// #     const MAX_BRIGHTNESS: u8 = 133;
321/// #     fn write_frame(&self, _frame: Frame1d<8>) {}
322/// #     fn animate<I>(&self, _frames: I)
323/// #     where
324/// #         I: IntoIterator,
325/// #         I::Item: core::borrow::Borrow<(Frame1d<8>, embassy_time::Duration)>,
326/// #     {
327/// #     }
328/// # }
329/// # let led_strip_simple = LedStripSimple;
330/// # write_alternating_blue_gray(&led_strip_simple);
331/// ```
332///
333/// # Example: Animate a Sequence
334///
335/// This example animates a 96-LED strip through red, green, and blue frames, cycling
336/// continuously.
337///
338/// ![LED strip preview](https://raw.githubusercontent.com/CarlKCarlK/device-envoy/main/crates/device-envoy-core/docs/assets/led_strip_animated.png)
339///
340/// ```rust,no_run
341/// use device_envoy_core::led_strip::{Frame1d, LedStrip, colors};
342/// use embassy_time::Duration;
343///
344/// fn animate_rgb_cycle<const N: usize>(led_strip: &impl LedStrip<N>) {
345///     let frame_duration = Duration::from_millis(300);
346///     led_strip.animate([
347///         (Frame1d::filled(colors::RED), frame_duration),
348///         (Frame1d::filled(colors::GREEN), frame_duration),
349///         (Frame1d::filled(colors::BLUE), frame_duration),
350///     ]);
351/// }
352///
353/// # struct LedStripAnimated;
354/// # impl LedStrip<96> for LedStripAnimated {
355/// #     const MAX_FRAMES: usize = 3;
356/// #     const MAX_BRIGHTNESS: u8 = 44;
357/// #     fn write_frame(&self, _frame: Frame1d<96>) {}
358/// #     fn animate<I>(&self, _frames: I)
359/// #     where
360/// #         I: IntoIterator,
361/// #         I::Item: core::borrow::Borrow<(Frame1d<96>, embassy_time::Duration)>,
362/// #     {
363/// #     }
364/// # }
365/// # let led_strip_animated = LedStripAnimated;
366/// # animate_rgb_cycle(&led_strip_animated);
367/// ```
368pub trait LedStrip<const N: usize> {
369    /// Number of LEDs in this strip.
370    const LEN: usize = N;
371    /// Maximum number of animation frames allowed.
372    const MAX_FRAMES: usize;
373    /// Maximum brightness level, automatically limited by the power budget.
374    const MAX_BRIGHTNESS: u8;
375
376    /// Write a frame to the LED strip.
377    ///
378    /// See the [LedStrip trait documentation](Self) for usage examples.
379    fn write_frame(&self, frame: Frame1d<N>);
380
381    /// Animate frames on the LED strip.
382    ///
383    /// The duration type is [`embassy_time::Duration`](https://docs.rs/embassy-time/latest/embassy_time/struct.Duration.html), and `frames` can be any iterator whose
384    /// items borrow `(Frame1d<N>, embassy_time::Duration)`.
385    ///
386    /// See the [LedStrip trait documentation](Self) for usage examples.
387    fn animate<I>(&self, frames: I)
388    where
389        I: IntoIterator,
390        I::Item: Borrow<(Frame1d<N>, embassy_time::Duration)>;
391}
392
393/// Signal type used to send commands to the background device task.
394///
395/// `#[doc(hidden)]` because it is named in macro-generated `static` items in
396/// downstream crates that call `led_strip!`. Must be `pub` for that use.
397#[doc(hidden)]
398pub type LedStripCommandSignal<const N: usize, const MAX_FRAMES: usize> =
399    Signal<CriticalSectionRawMutex, Command<N, MAX_FRAMES>>;
400
401/// Commands sent from a platform runtime handle to the background device task.
402///
403/// `#[doc(hidden)]` — implementation detail exposed only for macro expansion.
404#[doc(hidden)]
405#[derive(Clone)]
406pub enum Command<const N: usize, const MAX_FRAMES: usize> {
407    /// Display a single static frame indefinitely.
408    DisplayStatic(Frame1d<N>),
409    /// Loop through a sequence of (frame, duration) pairs.
410    Animate(Vec<(Frame1d<N>, Duration), MAX_FRAMES>),
411}
412
413// Must be `pub` for macro expansion at foreign call sites — not user-facing.
414#[doc(hidden)]
415pub fn __write_frame<const N: usize, const MAX_FRAMES: usize>(
416    command_signal: &'static LedStripCommandSignal<N, MAX_FRAMES>,
417    frame: Frame1d<N>,
418) {
419    command_signal.signal(Command::DisplayStatic(frame));
420}
421
422// Must be `pub` for macro expansion at foreign call sites — not user-facing.
423#[doc(hidden)]
424pub fn __animate<const N: usize, const MAX_FRAMES: usize, I>(
425    command_signal: &'static LedStripCommandSignal<N, MAX_FRAMES>,
426    frames: I,
427) where
428    I: IntoIterator,
429    I::Item: Borrow<(Frame1d<N>, Duration)>,
430{
431    assert!(MAX_FRAMES > 0, "animation disabled (MAX_FRAMES = 0)");
432    let mut sequence: Vec<(Frame1d<N>, Duration), MAX_FRAMES> = Vec::new();
433    for item in frames {
434        let (frame, duration) = *item.borrow();
435        assert!(
436            duration.as_micros() > 0,
437            "animation frame duration must be positive"
438        );
439        sequence
440            .push((frame, duration))
441            .expect("animation sequence fits within MAX_FRAMES");
442    }
443    assert!(
444        !sequence.is_empty(),
445        "animation requires at least one frame"
446    );
447    command_signal.signal(Command::Animate(sequence));
448}
449
450/// Static resources for a LED strip runtime instance. Allocated once at program
451/// start (typically as a `static`).
452///
453/// `#[doc(hidden)]` — exposed only for macro expansion in downstream crates.
454#[doc(hidden)]
455pub struct LedStripStatic<const N: usize, const MAX_FRAMES: usize> {
456    command_signal: LedStripCommandSignal<N, MAX_FRAMES>,
457}
458
459impl<const N: usize, const MAX_FRAMES: usize> LedStripStatic<N, MAX_FRAMES> {
460    /// Create the static resources. Call from a `static` initializer.
461    #[must_use]
462    #[doc(hidden)]
463    pub const fn new_static() -> Self {
464        Self {
465            command_signal: Signal::new(),
466        }
467    }
468
469    #[doc(hidden)]
470    pub fn command_signal(&'static self) -> &'static LedStripCommandSignal<N, MAX_FRAMES> {
471        &self.command_signal
472    }
473}
474
475// ============================================================================
476// apply_correction
477// ============================================================================
478
479/// Apply the combo (gamma + brightness) table to every pixel in a frame.
480///
481/// `#[doc(hidden)]` — called from platform-specific device loops.
482#[doc(hidden)]
483pub fn apply_correction<const N: usize>(frame: &mut Frame1d<N>, combo_table: &[u8; 256]) {
484    frame.iter_mut().for_each(|pixel| {
485        pixel.r = combo_table[pixel.r as usize];
486        pixel.g = combo_table[pixel.g as usize];
487        pixel.b = combo_table[pixel.b as usize];
488    });
489}