Skip to main content

device_envoy_core/
led4.rs

1//! A device abstraction support module for 4-digit, 7-segment LED displays.
2//!
3//! This module provides platform-independent types and text-to-segment mapping
4//! used by platform crates such as `device-envoy-rp` and `device-envoy-esp`.
5
6use core::borrow::Borrow;
7use core::num::NonZeroU8;
8use core::ops::{BitOrAssign, Index, IndexMut};
9use embassy_futures::select::{Either, select};
10use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal};
11use embassy_time::Duration;
12use embassy_time::Timer;
13use heapless::{LinearMap, Vec};
14
15/// The number of cells (digits) in a 4-digit display.
16#[doc(hidden)] // Platform plumbing constant; user-facing APIs should use CELL_COUNT.
17pub const CELL_COUNT_U8: u8 = 4;
18
19/// The number of cells (digits) in a 4-digit display.
20pub const CELL_COUNT: usize = CELL_COUNT_U8 as usize;
21
22/// The number of segments per digit.
23#[doc(hidden)] // Platform plumbing constant used by RP/ESP led4 internals.
24pub const SEGMENT_COUNT: usize = 8;
25
26/// Sleep duration between multiplexing updates.
27#[doc(hidden)] // Platform plumbing timing constant used by shared loops.
28pub const MULTIPLEX_SLEEP: Duration = Duration::from_millis(3);
29
30/// Maximum number of animation frames accepted by led4-style APIs.
31pub const ANIMATION_MAX_FRAMES: usize = 16;
32
33const BLINK_OFF_DELAY: Duration = Duration::from_millis(50);
34const BLINK_ON_DELAY: Duration = Duration::from_millis(150);
35
36/// Blinking behavior for 4-digit LED displays.
37#[derive(Debug, Clone, Copy, Default)]
38#[cfg_attr(feature = "defmt", derive(defmt::Format))]
39pub enum BlinkState {
40    /// Display is always on (solid, no blinking).
41    #[default]
42    Solid,
43    /// Display blinks; currently shows on.
44    BlinkingAndOn,
45    /// Display blinks; currently shows off.
46    BlinkingButOff,
47}
48
49/// Frame of animated text for `Led4::animate_text`.
50#[derive(Clone, Copy, Debug)]
51pub struct AnimationFrame {
52    /// Text to display (4 characters for a 4-digit display).
53    pub text: [char; CELL_COUNT],
54    /// Duration to display this frame. This uses [`embassy_time::Duration`](https://docs.rs/embassy-time/latest/embassy_time/struct.Duration.html).
55    pub duration: embassy_time::Duration,
56}
57
58impl AnimationFrame {
59    /// Creates a new animation frame with text and duration.
60    /// This method uses [`embassy_time::Duration`](https://docs.rs/embassy-time/latest/embassy_time/struct.Duration.html) for frame timing.
61    #[must_use]
62    pub const fn new(text: [char; CELL_COUNT], duration: embassy_time::Duration) -> Self {
63        Self { text, duration }
64    }
65}
66
67/// Creates a circular outline animation that chases around display edges.
68#[must_use]
69pub fn circular_outline_animation(clockwise: bool) -> Vec<AnimationFrame, ANIMATION_MAX_FRAMES> {
70    const FRAME_DURATION: Duration = Duration::from_millis(120);
71    const CLOCKWISE: [[char; 4]; 8] = [
72        ['\'', '\'', '\'', '\''],
73        ['\'', '\'', '\'', '"'],
74        [' ', ' ', ' ', '>'],
75        [' ', ' ', ' ', ')'],
76        ['_', '_', '_', '_'],
77        ['*', '_', '_', '_'],
78        ['<', ' ', ' ', ' '],
79        ['(', '\'', '\'', '\''],
80    ];
81    const COUNTER: [[char; 4]; 8] = [
82        ['(', '\'', '\'', '\''],
83        ['<', ' ', ' ', ' '],
84        ['*', '_', '_', '_'],
85        ['_', '_', '_', '_'],
86        [' ', ' ', ' ', ')'],
87        [' ', ' ', ' ', '>'],
88        ['\'', '\'', '\'', '"'],
89        ['\'', '\'', '\'', '\''],
90    ];
91
92    let mut animation = Vec::new();
93    let frames = if clockwise { &CLOCKWISE } else { &COUNTER };
94    for text in frames {
95        animation
96            .push(AnimationFrame::new(*text, FRAME_DURATION))
97            .expect("animation exceeds frame capacity");
98    }
99    animation
100}
101
102/// Commands sent to the shared led4 command loop.
103#[derive(Clone)]
104#[doc(hidden)] // Platform plumbing command type; not part of trait-facing API.
105pub enum Led4Command {
106    /// Display static text with selected blink behavior.
107    Text {
108        /// Blink behavior for the text.
109        blink_state: BlinkState,
110        /// Text to display.
111        text: [char; CELL_COUNT],
112    },
113    /// Display a looping text animation.
114    Animation(Vec<AnimationFrame, ANIMATION_MAX_FRAMES>),
115}
116
117/// Signal used to send [`Led4Command`] values.
118#[doc(hidden)] // Platform plumbing signal type used by RP/ESP implementations.
119pub type Led4CommandSignal = Signal<CriticalSectionRawMutex, Led4Command>;
120
121/// Signals static text to a led4 command loop.
122#[doc(hidden)] // Platform plumbing helper used by RP/ESP implementations.
123pub fn signal_text(
124    led4_command_signal: &Led4CommandSignal,
125    text: [char; CELL_COUNT],
126    blink_state: BlinkState,
127) {
128    led4_command_signal.signal(Led4Command::Text { blink_state, text });
129}
130
131/// Signals an animation to a led4 command loop.
132#[doc(hidden)] // Platform plumbing helper used by RP/ESP implementations.
133pub fn signal_animation<I>(led4_command_signal: &Led4CommandSignal, animation: I)
134where
135    I: IntoIterator,
136    I::Item: Borrow<AnimationFrame>,
137{
138    let mut frames: Vec<AnimationFrame, ANIMATION_MAX_FRAMES> = Vec::new();
139    for animation_frame in animation {
140        let animation_frame = *animation_frame.borrow();
141        frames
142            .push(animation_frame)
143            .expect("animation fits within ANIMATION_MAX_FRAMES");
144    }
145    led4_command_signal.signal(Led4Command::Animation(frames));
146}
147
148/// Platform-agnostic 4-digit display contract.
149///
150/// Platform crates implement this trait for their concrete runtime handles.
151/// Constructors (`new`, `new_static`) remain inherent on platform types.
152///
153/// # Example
154///
155/// ```rust,no_run
156/// use device_envoy_core::led4::{AnimationFrame, BlinkState, Led4, circular_outline_animation};
157/// use embassy_time::{Duration, Timer};
158///
159/// async fn show_status(led4: &impl Led4) -> ! {
160///     // Blink "1234" for three seconds.
161///     led4.write_text(['1', '2', '3', '4'], BlinkState::BlinkingAndOn);
162///     Timer::after(Duration::from_secs(3)).await;
163///
164///     // Run the circular outline animation for three seconds.
165///     led4.animate_text(circular_outline_animation(true));
166///     Timer::after(Duration::from_secs(3)).await;
167///
168///     // Show "rUSt" solid forever.
169///     led4.write_text(['r', 'U', 'S', 't'], BlinkState::Solid);
170///     core::future::pending().await
171/// }
172///
173/// # struct DemoLed4;
174/// # impl Led4 for DemoLed4 {
175/// #     fn write_text(&self, _text: [char; 4], _blink_state: BlinkState) {}
176/// #     fn animate_text<I>(&self, _animation: I)
177/// #     where
178/// #         I: IntoIterator,
179/// #         I::Item: core::borrow::Borrow<AnimationFrame>,
180/// #     {
181/// #     }
182/// # }
183/// # let led4 = DemoLed4;
184/// # let _future = show_status(&led4);
185/// ```
186pub trait Led4 {
187    /// Send text to the display with optional blinking.
188    ///
189    /// See the [Led4 trait documentation](Self) for usage examples.
190    fn write_text(&self, text: [char; CELL_COUNT], blink_state: BlinkState);
191
192    /// Play a looped text animation from the provided frames.
193    ///
194    /// See the [Led4 trait documentation](Self) for usage examples.
195    fn animate_text<I>(&self, animation: I)
196    where
197        I: IntoIterator,
198        I::Item: Borrow<AnimationFrame>;
199}
200
201/// Shared command loop for blinking/animated led4 text.
202#[doc(hidden)] // Platform plumbing loop used by RP/ESP device tasks.
203pub async fn run_command_loop<F>(
204    led4_command_signal: &'static Led4CommandSignal,
205    mut write_text: F,
206) -> !
207where
208    F: FnMut([char; CELL_COUNT]),
209{
210    let mut command = Led4Command::Text {
211        blink_state: BlinkState::default(),
212        text: [' '; CELL_COUNT],
213    };
214
215    loop {
216        command = match command {
217            Led4Command::Text { blink_state, text } => {
218                run_text_loop(blink_state, text, led4_command_signal, &mut write_text).await
219            }
220            Led4Command::Animation(animation) => {
221                run_animation_loop(animation, led4_command_signal, &mut write_text).await
222            }
223        };
224    }
225}
226
227async fn run_text_loop<F>(
228    mut blink_state: BlinkState,
229    text: [char; CELL_COUNT],
230    led4_command_signal: &'static Led4CommandSignal,
231    write_text: &mut F,
232) -> Led4Command
233where
234    F: FnMut([char; CELL_COUNT]),
235{
236    loop {
237        match blink_state {
238            BlinkState::Solid => {
239                write_text(text);
240                return led4_command_signal.wait().await;
241            }
242            BlinkState::BlinkingAndOn => {
243                write_text(text);
244                match select(led4_command_signal.wait(), Timer::after(BLINK_ON_DELAY)).await {
245                    Either::First(command) => return command,
246                    Either::Second(()) => blink_state = BlinkState::BlinkingButOff,
247                }
248            }
249            BlinkState::BlinkingButOff => {
250                write_text([' '; CELL_COUNT]);
251                match select(led4_command_signal.wait(), Timer::after(BLINK_OFF_DELAY)).await {
252                    Either::First(command) => return command,
253                    Either::Second(()) => blink_state = BlinkState::BlinkingAndOn,
254                }
255            }
256        }
257    }
258}
259
260async fn run_animation_loop<F>(
261    animation: Vec<AnimationFrame, ANIMATION_MAX_FRAMES>,
262    led4_command_signal: &'static Led4CommandSignal,
263    write_text: &mut F,
264) -> Led4Command
265where
266    F: FnMut([char; CELL_COUNT]),
267{
268    if animation.is_empty() {
269        return led4_command_signal.wait().await;
270    }
271
272    let frames = animation;
273    let len = frames.len();
274    let mut index = 0;
275    loop {
276        let frame = frames[index];
277        write_text(frame.text);
278        match select(led4_command_signal.wait(), Timer::after(frame.duration)).await {
279            Either::First(command) => return command,
280            Either::Second(()) => index = (index + 1) % len,
281        }
282    }
283}
284
285/// Error returned when building [`BitsToIndexes`] exceeds preallocated capacity.
286#[derive(Debug, Clone, Copy, Eq, PartialEq)]
287#[cfg_attr(feature = "defmt", derive(defmt::Format))]
288#[doc(hidden)] // Platform plumbing error for shared multiplex internals.
289pub enum Led4BitsToIndexesError {
290    /// `BitsToIndexes` does not have enough preallocated space.
291    Full,
292}
293
294/// Internal type for multiplex optimization.
295///
296/// Maps segment bit patterns to the indexes of digits sharing that pattern.
297#[doc(hidden)] // Platform plumbing map type used by shared multiplex loop.
298pub type BitsToIndexes = LinearMap<NonZeroU8, Vec<u8, CELL_COUNT>, CELL_COUNT>;
299
300/// Output adapter used by the shared led4 multiplex loop.
301#[doc(hidden)] // Platform plumbing adapter trait implemented in platform crates.
302pub trait Led4OutputAdapter {
303    /// Platform-specific error returned by GPIO writes.
304    type Error;
305
306    /// Applies segment bits to the segment output pins.
307    fn set_segments_from_nonzero_bits(&mut self, bits: NonZeroU8);
308
309    /// Enables or disables the selected cells.
310    ///
311    /// When `active` is `true`, cells should turn on. When `active` is
312    /// `false`, cells should turn off.
313    fn set_cells_active(&mut self, indexes: &[u8], active: bool) -> Result<(), Self::Error>;
314}
315
316/// Errors produced by [`run_simple_loop`].
317#[derive(Debug)]
318#[doc(hidden)] // Platform plumbing error used by platform task wiring.
319pub enum Led4SimpleLoopError<E> {
320    /// Failed while grouping bit patterns for multiplexing.
321    BitsToIndexes(Led4BitsToIndexesError),
322    /// Failed while writing the platform output pins.
323    Output(E),
324}
325
326/// Shared multiplex loop used by platform led4 drivers.
327///
328/// Waits for new [`BitMatrixLed4`] values on `bit_matrix_signal`, then drives
329/// the attached output adapter.
330///
331/// # Errors
332///
333/// Returns [`Led4SimpleLoopError`] when either bit grouping or output writes
334/// fail.
335#[doc(hidden)] // Platform plumbing loop used by RP/ESP implementations.
336pub async fn run_simple_loop<T>(
337    led4_output_adapter: &mut T,
338    bit_matrix_signal: &'static Signal<CriticalSectionRawMutex, BitMatrixLed4>,
339) -> Result<core::convert::Infallible, Led4SimpleLoopError<T::Error>>
340where
341    T: Led4OutputAdapter,
342{
343    let mut bit_matrix_led4 = BitMatrixLed4::default();
344    let mut bits_to_indexes = BitsToIndexes::default();
345    'outer: loop {
346        bit_matrix_led4
347            .bits_to_indexes(&mut bits_to_indexes)
348            .map_err(Led4SimpleLoopError::BitsToIndexes)?;
349
350        match bits_to_indexes.iter().next() {
351            None => bit_matrix_led4 = bit_matrix_signal.wait().await,
352            Some((&bits, indexes)) if bits_to_indexes.len() == 1 => {
353                led4_output_adapter.set_segments_from_nonzero_bits(bits);
354                led4_output_adapter
355                    .set_cells_active(indexes, true)
356                    .map_err(Led4SimpleLoopError::Output)?;
357                bit_matrix_led4 = bit_matrix_signal.wait().await;
358                led4_output_adapter
359                    .set_cells_active(indexes, false)
360                    .map_err(Led4SimpleLoopError::Output)?;
361            }
362            _ => loop {
363                for (bits, indexes) in &bits_to_indexes {
364                    led4_output_adapter.set_segments_from_nonzero_bits(*bits);
365                    led4_output_adapter
366                        .set_cells_active(indexes, true)
367                        .map_err(Led4SimpleLoopError::Output)?;
368                    let timeout_or_signal =
369                        select(Timer::after(MULTIPLEX_SLEEP), bit_matrix_signal.wait()).await;
370                    led4_output_adapter
371                        .set_cells_active(indexes, false)
372                        .map_err(Led4SimpleLoopError::Output)?;
373                    if let Either::Second(notification) = timeout_or_signal {
374                        bit_matrix_led4 = notification;
375                        continue 'outer;
376                    }
377                }
378            },
379        }
380    }
381}
382
383/// LED segment state for a 4-digit 7-segment display.
384#[derive(Debug, Clone, PartialEq, Eq)]
385#[cfg_attr(feature = "defmt", derive(defmt::Format))]
386#[doc(hidden)] // Platform plumbing frame type used by shared multiplex internals.
387pub struct BitMatrixLed4([u8; CELL_COUNT]);
388
389impl BitMatrixLed4 {
390    #[must_use]
391    pub const fn new(bits: [u8; CELL_COUNT]) -> Self {
392        Self(bits)
393    }
394
395    #[must_use]
396    pub fn from_text(text: &[char; CELL_COUNT]) -> Self {
397        let bytes = text.map(|char| Leds::ASCII_TABLE.get(char as usize).copied().unwrap_or(0));
398        Self::new(bytes)
399    }
400
401    pub fn iter(&self) -> impl Iterator<Item = &u8> {
402        self.0.iter()
403    }
404
405    pub fn iter_mut(&mut self) -> core::slice::IterMut<'_, u8> {
406        self.0.iter_mut()
407    }
408
409    /// Converts to optimized index mapping for multiplexing.
410    ///
411    /// # Errors
412    ///
413    /// Returns [`Led4BitsToIndexesError::Full`] when the preallocated mapping
414    /// storage is exhausted.
415    pub fn bits_to_indexes(
416        &self,
417        bits_to_indexes: &mut BitsToIndexes,
418    ) -> Result<(), Led4BitsToIndexesError> {
419        bits_to_indexes.clear();
420        for (&bits, index) in self.iter().zip(0..CELL_COUNT_U8) {
421            if let Some(nonzero_bits) = NonZeroU8::new(bits) {
422                if let Some(indexes) = bits_to_indexes.get_mut(&nonzero_bits) {
423                    indexes
424                        .push(index)
425                        .map_err(|_| Led4BitsToIndexesError::Full)?;
426                } else {
427                    let indexes =
428                        Vec::from_slice(&[index]).map_err(|_| Led4BitsToIndexesError::Full)?;
429                    bits_to_indexes
430                        .insert(nonzero_bits, indexes)
431                        .map_err(|_| Led4BitsToIndexesError::Full)?;
432                }
433            }
434        }
435        Ok(())
436    }
437}
438
439impl Default for BitMatrixLed4 {
440    fn default() -> Self {
441        Self([0; CELL_COUNT])
442    }
443}
444
445impl BitOrAssign<u8> for BitMatrixLed4 {
446    fn bitor_assign(&mut self, rhs: u8) {
447        self.iter_mut().for_each(|bits| *bits |= rhs);
448    }
449}
450
451impl Index<usize> for BitMatrixLed4 {
452    type Output = u8;
453
454    #[expect(clippy::indexing_slicing, reason = "Caller validates indexing")]
455    fn index(&self, index: usize) -> &Self::Output {
456        &self.0[index]
457    }
458}
459
460impl IndexMut<usize> for BitMatrixLed4 {
461    #[expect(clippy::indexing_slicing, reason = "Caller validates indexing")]
462    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
463        &mut self.0[index]
464    }
465}
466
467impl IntoIterator for BitMatrixLed4 {
468    type Item = u8;
469    type IntoIter = core::array::IntoIter<u8, CELL_COUNT>;
470
471    fn into_iter(self) -> Self::IntoIter {
472        self.0.into_iter()
473    }
474}
475
476impl<'a> IntoIterator for &'a BitMatrixLed4 {
477    type Item = &'a u8;
478    type IntoIter = core::slice::Iter<'a, u8>;
479
480    fn into_iter(self) -> Self::IntoIter {
481        self.0.iter()
482    }
483}
484
485impl<'a> IntoIterator for &'a mut BitMatrixLed4 {
486    type Item = &'a mut u8;
487    type IntoIter = core::slice::IterMut<'a, u8>;
488
489    fn into_iter(self) -> Self::IntoIter {
490        self.0.iter_mut()
491    }
492}
493
494struct Leds;
495
496impl Leds {
497    const SEG_A: u8 = 0b_0000_0001;
498    const SEG_B: u8 = 0b_0000_0010;
499    const SEG_C: u8 = 0b_0000_0100;
500    const SEG_D: u8 = 0b_0000_1000;
501    const SEG_E: u8 = 0b_0001_0000;
502    const SEG_F: u8 = 0b_0010_0000;
503
504    const ASCII_TABLE: [u8; 128] = [
505        0b_0000_0000,
506        0b_0000_0000,
507        0b_0000_0000,
508        0b_0000_0000,
509        0b_0000_0000,
510        0b_0000_0000,
511        0b_0000_0000,
512        0b_0000_0000,
513        0b_0000_0000,
514        0b_0000_0000,
515        0b_0000_0000,
516        0b_0000_0000,
517        0b_0000_0000,
518        0b_0000_0000,
519        0b_0000_0000,
520        0b_0000_0000,
521        0b_0000_0000,
522        0b_0000_0000,
523        0b_0000_0000,
524        0b_0000_0000,
525        0b_0000_0000,
526        0b_0000_0000,
527        0b_0000_0000,
528        0b_0000_0000,
529        0b_0000_0000,
530        0b_0000_0000,
531        0b_0000_0000,
532        0b_0000_0000,
533        0b_0000_0000,
534        0b_0000_0000,
535        0b_0000_0000,
536        0b_0000_0000,
537        0b_0000_0000,
538        0b_1000_0110,
539        Self::SEG_A | Self::SEG_B,
540        0b_0000_0000,
541        0b_0000_0000,
542        0b_0000_0000,
543        0b_0000_0000,
544        Self::SEG_A,
545        Self::SEG_A | Self::SEG_F,
546        Self::SEG_C | Self::SEG_D,
547        Self::SEG_D | Self::SEG_E,
548        0b_0000_0000,
549        0b_0000_0000,
550        0b_0100_0000,
551        0b_1000_0000,
552        0b_0000_0000,
553        0b_0011_1111,
554        0b_0000_0110,
555        0b_0101_1011,
556        0b_0100_1111,
557        0b_0110_0110,
558        0b_0110_1101,
559        0b_0111_1101,
560        0b_0000_0111,
561        0b_0111_1111,
562        0b_0110_1111,
563        0b_0000_0000,
564        0b_0000_0000,
565        Self::SEG_E | Self::SEG_F,
566        0b_0000_0000,
567        Self::SEG_B | Self::SEG_C,
568        0b_0000_0000,
569        0b_0000_0000,
570        0b_0111_0111,
571        0b_0111_1100,
572        0b_0011_1001,
573        0b_0101_1110,
574        0b_0111_1001,
575        0b_0111_0001,
576        0b_0011_1101,
577        0b_0111_0110,
578        0b_0000_0110,
579        0b_0001_1110,
580        0b_0111_0110,
581        0b_0011_1000,
582        0b_0001_0101,
583        0b_0101_0100,
584        0b_0011_1111,
585        0b_0111_0011,
586        0b_0110_0111,
587        0b_0101_0000,
588        0b_0110_1101,
589        0b_0111_1000,
590        0b_0011_1110,
591        0b_0010_1010,
592        0b_0001_1101,
593        0b_0111_0110,
594        0b_0110_1110,
595        0b_0101_1011,
596        0b_0011_1001,
597        0b_0000_0000,
598        0b_0000_1111,
599        0b_0000_0000,
600        0b_0000_1000,
601        0b_0000_0000,
602        0b_0111_0111,
603        0b_0111_1100,
604        0b_0011_1001,
605        0b_0101_1110,
606        0b_0111_1001,
607        0b_0111_0001,
608        0b_0011_1101,
609        0b_0111_0100,
610        0b_0001_0000,
611        0b_0001_1110,
612        0b_0111_0110,
613        0b_0011_1000,
614        0b_0001_0101,
615        0b_0101_0100,
616        0b_0101_1100,
617        0b_0111_0011,
618        0b_0110_0111,
619        0b_0101_0000,
620        0b_0110_1101,
621        0b_0111_1000,
622        0b_0011_1110,
623        0b_0010_1010,
624        0b_0001_1101,
625        0b_0111_0110,
626        0b_0110_1110,
627        0b_0101_1011,
628        0b_0011_1001,
629        0b_0000_0110,
630        0b_0000_1111,
631        0b_0100_0000,
632        0b_0000_0000,
633    ];
634}
635
636#[cfg(test)]
637mod tests {
638    use super::{BitMatrixLed4, CELL_COUNT};
639
640    #[test]
641    fn from_text_maps_known_characters() {
642        let bit_matrix_led4 = BitMatrixLed4::from_text(&['1', '2', '3', '4']);
643        assert_eq!(bit_matrix_led4.iter().count(), CELL_COUNT);
644    }
645}