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