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