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