Skip to main content

led_ring/
lib.rs

1//! LED ring animation engine: spatial patterns + temporal modifiers.
2//!
3//! Generic over ring size (`N` LEDs per ring). Default is 12.
4//! No-std compatible, no allocator needed.
5//!
6//! # Usage
7//!
8//! ```
9//! use led_ring::{LedRing, RingAnimation, Rgb, PEDALBOARD_CLOCK_MAP};
10//!
11//! // 12-LED ring with pedalboard PCB mapping
12//! let mut ring = LedRing::<12>::new(&PEDALBOARD_CLOCK_MAP);
13//! ring.set(RingAnimation::solid(Rgb::new(255, 0, 0)));
14//! let frame = ring.render(0);
15//! assert!(frame.iter().all(|px| *px == Rgb::new(255, 0, 0)));
16//! ```
17
18#![no_std]
19
20use serde::{Deserialize, Serialize};
21
22/// RGB color (matches smart_leds::RGB8 layout).
23#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Rgb {
25    pub r: u8,
26    pub g: u8,
27    pub b: u8,
28}
29
30impl Rgb {
31    pub const fn new(r: u8, g: u8, b: u8) -> Self {
32        Self { r, g, b }
33    }
34
35    pub const BLACK: Self = Self::new(0, 0, 0);
36
37    /// Scale brightness by factor 0..=255 (255 = full).
38    pub fn scale(self, factor: u8) -> Self {
39        Self {
40            r: ((self.r as u16 * factor as u16) / 255) as u8,
41            g: ((self.g as u16 * factor as u16) / 255) as u8,
42            b: ((self.b as u16 * factor as u16) / 255) as u8,
43        }
44    }
45}
46
47/// Spatial pattern — which LEDs are lit and what color.
48#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub enum Renderer {
50    Off,
51    /// All LEDs same color.
52    Solid(Rgb),
53    /// Arc from rotation anchor, `count` LEDs lit.
54    Fill(Rgb, u8),
55    /// Potentiometer arc, blue→green→red. `fill` = 0..N.
56    Heatmap(u8),
57    /// Single LED at position (0..N-1), with neighbors lit.
58    Single(Rgb, u8),
59    /// N evenly-spaced LEDs.
60    Dots(Rgb, u8),
61}
62
63/// Temporal modifier — how rendered pixels change over time.
64#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub enum Modifier {
66    /// No modulation, full brightness.
67    #[default]
68    Solid,
69    /// Static dim (1/8 intensity) — "available but inactive".
70    Glow,
71    /// On/off toggle at ~4Hz.
72    Blink,
73    /// Sine-wave fade, period ~1.5s.
74    Pulse,
75    /// Rotate pattern clockwise, one step per ~100ms.
76    Rotate,
77    /// Cycle hue over time (ignores original color, keeps lit/unlit pattern).
78    ColorCycle,
79}
80
81/// Complete ring animation state.
82#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct RingAnimation {
84    pub renderer: Renderer,
85    pub modifier: Modifier,
86}
87
88impl Default for RingAnimation {
89    fn default() -> Self {
90        Self {
91            renderer: Renderer::Off,
92            modifier: Modifier::Solid,
93        }
94    }
95}
96
97impl RingAnimation {
98    pub const fn off() -> Self {
99        Self {
100            renderer: Renderer::Off,
101            modifier: Modifier::Solid,
102        }
103    }
104
105    pub const fn solid(color: Rgb) -> Self {
106        Self {
107            renderer: Renderer::Solid(color),
108            modifier: Modifier::Solid,
109        }
110    }
111
112    pub const fn glow(color: Rgb) -> Self {
113        Self {
114            renderer: Renderer::Solid(color),
115            modifier: Modifier::Glow,
116        }
117    }
118}
119
120/// Identity clock map for N LEDs (position i maps to physical index i).
121pub const IDENTITY_MAP: [usize; 24] = [
122    0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
123];
124
125/// Stateful LED ring that tracks animation and renders frames.
126///
127/// `N` is the number of LEDs in the ring.
128/// `clock_map` maps logical positions (clock hours) to physical LED indices.
129#[derive(Copy, Clone)]
130pub struct LedRing<const N: usize = 12> {
131    pub animation: RingAnimation,
132    rotation: u8,
133    clock_map: [usize; N],
134}
135
136impl<const N: usize> LedRing<N> {
137    /// Create a new ring with a clock-position-to-physical-index mapping.
138    ///
139    /// `clock_map` should have N entries mapping logical position to physical LED index.
140    pub const fn new(clock_map: &[usize; N]) -> Self {
141        Self {
142            animation: RingAnimation::off(),
143            rotation: 0,
144            clock_map: *clock_map,
145        }
146    }
147
148    /// Create with a rotation anchor (first lit LED position for Fill patterns).
149    pub const fn with_rotation(clock_map: &[usize; N], rotation: u8) -> Self {
150        Self {
151            animation: RingAnimation::off(),
152            rotation,
153            clock_map: *clock_map,
154        }
155    }
156
157    pub fn set(&mut self, anim: RingAnimation) {
158        self.animation = anim;
159    }
160
161    /// Render current frame using a global tick (shared across all rings).
162    pub fn render(&self, tick: u16) -> [Rgb; N] {
163        let base = self.render_spatial();
164        self.apply_modifier(base, tick)
165    }
166
167    fn render_spatial(&self) -> [Rgb; N] {
168        let mut frame = [Rgb::BLACK; N];
169        match self.animation.renderer {
170            Renderer::Off => {}
171            Renderer::Solid(c) => {
172                for px in &mut frame {
173                    *px = c;
174                }
175            }
176            Renderer::Fill(c, count) => {
177                for i in 0..(count as usize).min(N) {
178                    let pos = (self.rotation as usize + N - i) % N;
179                    frame[self.clock_map[pos]] = c;
180                }
181            }
182            Renderer::Heatmap(fill) => {
183                // Arc from 7h position clockwise, N-1 positions
184                let arc_len = N - 1;
185                let lit = ((fill as usize) * arc_len / N.max(1)).min(arc_len);
186                for i in 0..lit {
187                    // Start at ~7h equivalent position
188                    let hour = (N * 7 / 12 + i) % N;
189                    frame[self.clock_map[hour]] = heatmap_color(i, arc_len);
190                }
191            }
192            Renderer::Single(c, pos) => {
193                let p = (pos as usize) % N;
194                frame[self.clock_map[p]] = c;
195                // Light neighbors for visibility
196                if N > 2 {
197                    frame[self.clock_map[(p + N - 1) % N]] = c;
198                    frame[self.clock_map[(p + 1) % N]] = c;
199                }
200            }
201            Renderer::Dots(c, count) => {
202                let n = (count as usize).clamp(1, N);
203                let spacing = N / n;
204                for i in 0..n {
205                    frame[self.clock_map[(i * spacing) % N]] = c;
206                }
207            }
208        }
209        frame
210    }
211
212    fn apply_modifier(&self, mut frame: [Rgb; N], tick: u16) -> [Rgb; N] {
213        match self.animation.modifier {
214            Modifier::Solid => frame,
215            Modifier::Glow => {
216                for px in frame.iter_mut() {
217                    *px = px.scale(32);
218                }
219                frame
220            }
221            Modifier::Blink => {
222                if (tick / 25) % 2 == 1 {
223                    [Rgb::BLACK; N]
224                } else {
225                    frame
226                }
227            }
228            Modifier::Pulse => {
229                let factor = sine_u8(tick % 50, 50);
230                for px in &mut frame {
231                    *px = px.scale(factor);
232                }
233                frame
234            }
235            Modifier::Rotate => {
236                let shift = (tick / 25) as usize % N;
237                let mut rotated = [Rgb::BLACK; N];
238                for i in 0..N {
239                    rotated[(i + shift) % N] = frame[i];
240                }
241                rotated
242            }
243            Modifier::ColorCycle => {
244                let color = hue_to_rgb((tick * 3) as u8);
245                for px in frame.iter_mut() {
246                    if *px != Rgb::BLACK {
247                        *px = color;
248                    }
249                }
250                frame
251            }
252        }
253    }
254}
255
256impl Default for LedRing<12> {
257    fn default() -> Self {
258        Self::with_rotation(&PEDALBOARD_CLOCK_MAP, 8)
259    }
260}
261
262/// Clock-position mapping for the pedalboard PCB (12 LEDs).
263/// PCB layout: D1=3h, D2=2h, ..., D12=4h.
264pub const PEDALBOARD_CLOCK_MAP: [usize; 12] = [
265    3,  //  0: 12h
266    2,  //  1:  1h
267    1,  //  2:  2h
268    0,  //  3:  3h
269    11, //  4:  4h
270    10, //  5:  5h
271    9,  //  6:  6h
272    8,  //  7:  7h
273    7,  //  8:  8h
274    6,  //  9:  9h
275    5,  // 10: 10h
276    4,  // 11: 11h
277];
278
279// --- Helper functions ---
280
281/// Blue (0) → Green (mid) → Red (max-1)
282fn heatmap_color(pos: usize, max: usize) -> Rgb {
283    if max <= 1 {
284        return Rgb::new(0, 0, 255);
285    }
286    let t = (pos * 255) / (max - 1);
287    if t < 128 {
288        let g = (t * 2) as u8;
289        Rgb::new(0, g, 255 - g)
290    } else {
291        let r = ((t - 128) * 2) as u8;
292        Rgb::new(r, 255 - r, 0)
293    }
294}
295
296/// HSV hue (0–255) to RGB at full saturation/value.
297pub fn hue_to_rgb(h: u8) -> Rgb {
298    let region = h / 43;
299    let remainder = (h % 43) * 6;
300    match region {
301        0 => Rgb::new(255, remainder, 0),
302        1 => Rgb::new(255 - remainder, 255, 0),
303        2 => Rgb::new(0, 255, remainder),
304        3 => Rgb::new(0, 255 - remainder, 255),
305        4 => Rgb::new(remainder, 0, 255),
306        _ => Rgb::new(255, 0, 255 - remainder),
307    }
308}
309
310/// Approximate sine wave for LED pulsing (no libm needed).
311fn sine_u8(phase: u16, period: u16) -> u8 {
312    const TABLE: [u8; 16] = [
313        0, 25, 50, 74, 98, 120, 142, 162, 180, 197, 213, 226, 237, 245, 251, 255,
314    ];
315    let pos = phase % period;
316    let half = period / 2;
317    let dist = half.abs_diff(pos);
318    let idx = ((half - dist) as usize * 15) / half.max(1) as usize;
319    TABLE[idx.min(15)]
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn ring12() -> LedRing<12> {
327        LedRing::with_rotation(&PEDALBOARD_CLOCK_MAP, 8)
328    }
329
330    #[test]
331    fn off_renders_black() {
332        let ring = ring12();
333        let frame = ring.render(0);
334        assert!(frame.iter().all(|px| *px == Rgb::BLACK));
335    }
336
337    #[test]
338    fn solid_renders_all_same() {
339        let mut ring = ring12();
340        let c = Rgb::new(255, 0, 0);
341        ring.set(RingAnimation::solid(c));
342        let frame = ring.render(0);
343        assert!(frame.iter().all(|px| *px == c));
344    }
345
346    #[test]
347    fn glow_dims_uniformly() {
348        let mut ring = ring12();
349        ring.set(RingAnimation::glow(Rgb::new(255, 255, 255)));
350        let frame = ring.render(0);
351        assert!(frame.iter().all(|px| px.r == 32 && px.g == 32 && px.b == 32));
352    }
353
354    #[test]
355    fn blink_alternates() {
356        let mut ring = ring12();
357        let c = Rgb::new(255, 0, 0);
358        ring.set(RingAnimation {
359            renderer: Renderer::Solid(c),
360            modifier: Modifier::Blink,
361        });
362        assert!(ring.render(0).iter().all(|px| *px == c));
363        assert!(ring.render(25).iter().all(|px| *px == Rgb::BLACK));
364    }
365
366    #[test]
367    fn dots_2_lights_opposing() {
368        let mut ring = ring12();
369        let c = Rgb::new(0, 255, 0);
370        ring.set(RingAnimation {
371            renderer: Renderer::Dots(c, 2),
372            modifier: Modifier::Solid,
373        });
374        let frame = ring.render(0);
375        let lit = frame.iter().filter(|px| **px != Rgb::BLACK).count();
376        assert_eq!(lit, 2);
377    }
378
379    #[test]
380    fn heatmap_full() {
381        let mut ring = ring12();
382        ring.set(RingAnimation {
383            renderer: Renderer::Heatmap(12),
384            modifier: Modifier::Solid,
385        });
386        let frame = ring.render(0);
387        let lit = frame.iter().filter(|px| **px != Rgb::BLACK).count();
388        assert_eq!(lit, 11);
389    }
390
391    #[test]
392    fn rotate_shifts() {
393        let mut ring = ring12();
394        ring.set(RingAnimation {
395            renderer: Renderer::Single(Rgb::new(255, 255, 0), 0),
396            modifier: Modifier::Rotate,
397        });
398        let f0 = ring.render(0);
399        let f1 = ring.render(25);
400        assert_ne!(f0, f1);
401    }
402
403    #[test]
404    fn pulse_starts_dim() {
405        let mut ring = ring12();
406        ring.set(RingAnimation {
407            renderer: Renderer::Solid(Rgb::new(255, 255, 255)),
408            modifier: Modifier::Pulse,
409        });
410        let frame = ring.render(0);
411        assert!(frame[0].r < 30);
412    }
413
414    #[test]
415    fn scale_zero_is_black() {
416        assert_eq!(Rgb::new(255, 128, 64).scale(0), Rgb::BLACK);
417    }
418
419    #[test]
420    fn scale_255_is_identity() {
421        let c = Rgb::new(200, 100, 50);
422        assert_eq!(c.scale(255), c);
423    }
424
425    #[test]
426    fn struct_sizes() {
427        assert_eq!(core::mem::size_of::<Rgb>(), 3);
428        assert_eq!(core::mem::size_of::<RingAnimation>(), 6);
429        // LedRing<12> = animation(6) + rotation(1) + padding + clock_map([usize;12])
430        assert!(core::mem::size_of::<LedRing<12>>() <= 112);
431    }
432
433    #[test]
434    fn generic_ring_16_leds() {
435        let map: [usize; 16] = core::array::from_fn(|i| i);
436        let mut ring = LedRing::<16>::new(&map);
437        ring.set(RingAnimation::solid(Rgb::new(0, 0, 255)));
438        let frame = ring.render(0);
439        assert_eq!(frame.len(), 16);
440        assert!(frame.iter().all(|px| *px == Rgb::new(0, 0, 255)));
441    }
442
443    #[test]
444    fn generic_ring_24_leds() {
445        let map: [usize; 24] = core::array::from_fn(|i| i);
446        let mut ring = LedRing::<24>::new(&map);
447        ring.set(RingAnimation {
448            renderer: Renderer::Fill(Rgb::new(255, 0, 0), 12),
449            modifier: Modifier::Solid,
450        });
451        let frame = ring.render(0);
452        let lit = frame.iter().filter(|px| **px != Rgb::BLACK).count();
453        assert_eq!(lit, 12);
454    }
455}