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/// 
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/// 
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}