pennant 0.5.0

Reusable LED animation effects for embedded projects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
#![cfg_attr(not(test), no_std)]
//! LED animation effects for embedded projects.
//!
//! This crate provides reusable animation effects that work with RGB LEDs.
//! It is `no_std` compatible for embedded use.
//!
//! # StatusLed Trait
//!
//! The `StatusLed` trait provides a common interface for LED drivers that can
//! display status colors. This enables crates like `rustyfarian-network` to
//! show connection status without depending on a specific LED implementation.
//!
//! # NoLed
//!
//! [`NoLed`] is a zero-size stub that implements `StatusLed` with an `Infallible` error type.
//! Use it when a type parameter requires a `StatusLed` but no physical LED is present.
//!
//! # SimpleLed (opt-in via `hal` feature)
//!
//! For simple on/off GPIO LEDs (not RGB), enable the `hal` feature to get the
//! [`SimpleLed`] adapter, which implements `StatusLed` by mapping RGB colors to
//! on/off based on brightness.
//! It is generic over [`embedded_hal::digital::OutputPin`], so it works with
//! any HAL or test mock.
//!
//! # PulseEffect
//!
//! The [`PulseEffect`] creates smooth pulsing brightness animations.

use rgb::RGB8;

mod async_status_led;
pub use async_status_led::AsyncStatusLed;

mod no_led;
pub use no_led::NoLed;

#[cfg(feature = "hal")]
mod simple_led;

#[cfg(feature = "hal")]
pub use simple_led::SimpleLed;

/// Error type for PulseEffect configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PulseEffectError {
    /// min must be less than max
    InvalidRange { min: u8, max: u8 },
    /// step must be greater than 0
    ZeroStep,
}

impl core::fmt::Display for PulseEffectError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            PulseEffectError::InvalidRange { min, max } => {
                write!(
                    f,
                    "invalid range: min ({}) must be less than max ({})",
                    min, max
                )
            }
            PulseEffectError::ZeroStep => {
                write!(f, "step must be greater than 0")
            }
        }
    }
}

/// Trait for LED status indicators.
///
/// Implement this trait for your LED driver to enable status feedback
/// in other crates like `rustyfarian-network`.
///
/// # Example
///
/// ```ignore
/// use pennant::StatusLed;
/// use rgb::RGB8;
///
/// struct MyLed { /* ... */ }
///
/// impl StatusLed for MyLed {
///     type Error = MyError;
///
///     fn set_color(&mut self, color: RGB8) -> Result<(), Self::Error> {
///         // Set the LED color
///         Ok(())
///     }
/// }
/// ```
pub trait StatusLed {
    /// The error type returned by LED operations.
    type Error;

    /// Sets the LED to the specified color.
    fn set_color(&mut self, color: RGB8) -> Result<(), Self::Error>;
}

/// Default brightness threshold for simple on/off LED decisions.
pub const DEFAULT_BRIGHTNESS_THRESHOLD: u8 = 10;

/// Calculates the maximum channel value from an RGB color.
///
/// Returns the highest value among red, green, and blue channels.
/// Useful for simple brightness-based on/off decisions.
///
/// # Example
///
/// ```
/// use pennant::max_channel_brightness;
/// use rgb::RGB8;
///
/// let color = RGB8::new(10, 50, 30);
/// assert_eq!(max_channel_brightness(color), 50);
/// ```
#[inline]
pub fn max_channel_brightness(color: RGB8) -> u8 {
    color.r.max(color.g).max(color.b)
}

/// Determines if an RGB color exceeds a brightness threshold.
///
/// Returns `true` if any channel is strictly greater than the threshold.
/// A color exactly equal to the threshold is considered "off".
/// Useful for converting RGB colors to simple on/off states.
///
/// # Example
///
/// ```
/// use pennant::exceeds_threshold;
/// use rgb::RGB8;
///
/// let color = RGB8::new(0, 0, 15);
/// assert!(exceeds_threshold(color, 10));  // 15 > 10
/// assert!(!exceeds_threshold(color, 20)); // 15 <= 20
/// ```
#[inline]
pub fn exceeds_threshold(color: RGB8, threshold: u8) -> bool {
    max_channel_brightness(color) > threshold
}

/// A pulsing brightness effect that smoothly oscillates between dim and bright.
///
/// # Example
///
/// ```
/// use pennant::PulseEffect;
///
/// let mut pulse = PulseEffect::new();
/// let base_color = (255, 0, 0); // Red
///
/// // Call update() in your main loop to get the next animation frame
/// let current_color = pulse.update(base_color);
/// ```
#[derive(Debug)]
pub struct PulseEffect {
    brightness: u8,
    increasing: bool,
    min_brightness: u8,
    max_brightness: u8,
    step: u8,
}

impl Default for PulseEffect {
    fn default() -> Self {
        Self::new()
    }
}

impl PulseEffect {
    /// Creates a new pulse effect with default parameters.
    ///
    /// Default range: 2-30 brightness, step size: 2
    pub fn new() -> Self {
        Self {
            brightness: 0,
            increasing: true,
            min_brightness: 2,
            max_brightness: 30,
            step: 2,
        }
    }

    /// Creates a pulse effect with custom brightness range and step size.
    ///
    /// # Arguments
    ///
    /// * `min` - Minimum brightness (0-255)
    /// * `max` - Maximum brightness (0-255), must be > min
    /// * `step` - Brightness change per update call, must be > 0
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - `min >= max`
    /// - `step == 0`
    pub fn with_range(min: u8, max: u8, step: u8) -> Result<Self, PulseEffectError> {
        if min >= max {
            return Err(PulseEffectError::InvalidRange { min, max });
        }
        if step == 0 {
            return Err(PulseEffectError::ZeroStep);
        }

        Ok(Self {
            brightness: min,
            increasing: true,
            min_brightness: min,
            max_brightness: max,
            step,
        })
    }

    /// Updates the effect state and returns the next color frame.
    ///
    /// Call this method repeatedly in your animation loop.
    ///
    /// # Arguments
    ///
    /// * `rgb` - Base color as (red, green, blue) tuple
    ///
    /// # Returns
    ///
    /// The color is scaled by the current brightness level
    pub fn update(&mut self, rgb: (u8, u8, u8)) -> RGB8 {
        let color = RGB8::new(
            ((rgb.0 as u16 * self.brightness as u16) / 255) as u8,
            ((rgb.1 as u16 * self.brightness as u16) / 255) as u8,
            ((rgb.2 as u16 * self.brightness as u16) / 255) as u8,
        );

        if self.increasing {
            if self.brightness >= self.max_brightness {
                self.increasing = false;
            } else {
                self.brightness = self.brightness.saturating_add(self.step);
            }
        } else if self.brightness <= self.min_brightness {
            self.increasing = true;
        } else {
            self.brightness = self.brightness.saturating_sub(self.step);
        }

        color
    }

    /// Returns the current brightness level (0-255).
    ///
    /// This value oscillates between `min_brightness` and `max_brightness`
    /// as `update()` is called repeatedly.
    pub fn brightness(&self) -> u8 {
        self.brightness
    }

    /// Resets the effect to its initial state.
    ///
    /// After calling this method:
    /// - Brightness is set to `min_brightness`
    /// - Direction is set to increasing
    ///
    /// Use this to restart the pulse animation from the beginning.
    pub fn reset(&mut self) {
        self.brightness = self.min_brightness;
        self.increasing = true;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(feature = "hal")]
    mod simple_led_tests {
        use super::*;
        use core::convert::Infallible;
        use embedded_hal::digital::{ErrorType, OutputPin};

        /// Minimal mock pin that tracks its on/off state.
        struct MockPin {
            is_high: bool,
        }

        impl MockPin {
            fn new() -> Self {
                Self { is_high: false }
            }
        }

        impl ErrorType for MockPin {
            type Error = Infallible;
        }

        impl OutputPin for MockPin {
            fn set_low(&mut self) -> Result<(), Self::Error> {
                self.is_high = false;
                Ok(())
            }
            fn set_high(&mut self) -> Result<(), Self::Error> {
                self.is_high = true;
                Ok(())
            }
        }

        #[test]
        fn test_simple_led_turns_on_for_bright_color() {
            let mut led = SimpleLed::new(MockPin::new());
            led.set_color(RGB8::new(0, 0, 255)).unwrap();
            assert!(led.pin.is_high);
        }

        #[test]
        fn test_simple_led_turns_off_for_black() {
            let mut led = SimpleLed::new(MockPin::new());
            led.set_color(RGB8::new(255, 0, 0)).unwrap();
            assert!(led.pin.is_high);
            led.set_color(RGB8::new(0, 0, 0)).unwrap();
            assert!(!led.pin.is_high);
        }

        #[test]
        fn test_simple_led_respects_custom_threshold() {
            let mut led = SimpleLed::with_threshold(MockPin::new(), 100);
            led.set_color(RGB8::new(50, 50, 50)).unwrap();
            assert!(!led.pin.is_high);
            led.set_color(RGB8::new(101, 0, 0)).unwrap();
            assert!(led.pin.is_high);
        }

        #[test]
        fn test_simple_led_at_threshold_stays_off() {
            let mut led = SimpleLed::with_threshold(MockPin::new(), 100);
            led.set_color(RGB8::new(100, 100, 100)).unwrap();
            assert!(!led.pin.is_high);
        }
    }

    #[test]
    fn test_max_channel_brightness_returns_highest() {
        assert_eq!(max_channel_brightness(RGB8::new(100, 50, 25)), 100);
        assert_eq!(max_channel_brightness(RGB8::new(25, 100, 50)), 100);
        assert_eq!(max_channel_brightness(RGB8::new(50, 25, 100)), 100);
    }

    #[test]
    fn test_max_channel_brightness_all_same() {
        assert_eq!(max_channel_brightness(RGB8::new(42, 42, 42)), 42);
    }

    #[test]
    fn test_max_channel_brightness_black() {
        assert_eq!(max_channel_brightness(RGB8::new(0, 0, 0)), 0);
    }

    #[test]
    fn test_max_channel_brightness_white() {
        assert_eq!(max_channel_brightness(RGB8::new(255, 255, 255)), 255);
    }

    #[test]
    fn test_exceeds_threshold_above() {
        assert!(exceeds_threshold(RGB8::new(0, 0, 15), 10));
        assert!(exceeds_threshold(RGB8::new(11, 0, 0), 10));
        assert!(exceeds_threshold(RGB8::new(0, 255, 0), 10));
    }

    #[test]
    fn test_exceeds_threshold_at_boundary() {
        // Equal to a threshold should return false (must be greater than)
        assert!(!exceeds_threshold(RGB8::new(10, 10, 10), 10));
    }

    #[test]
    fn test_exceeds_threshold_below() {
        assert!(!exceeds_threshold(RGB8::new(5, 5, 5), 10));
        assert!(!exceeds_threshold(RGB8::new(0, 0, 0), 10));
    }

    #[test]
    fn test_pulse_increases_then_decreases() {
        let mut pulse = PulseEffect::new();
        let mut prev_brightness = pulse.brightness();

        // Should increase initially
        for _ in 0..5 {
            pulse.update((255, 255, 255));
            assert!(pulse.brightness() >= prev_brightness);
            prev_brightness = pulse.brightness();
        }
    }

    #[test]
    fn test_custom_range() {
        let pulse = PulseEffect::with_range(10, 100, 5).unwrap();
        assert_eq!(pulse.brightness(), 10);
    }

    #[test]
    fn test_with_range_rejects_min_greater_than_max() {
        let err = PulseEffect::with_range(100, 10, 5).unwrap_err();
        assert_eq!(err, PulseEffectError::InvalidRange { min: 100, max: 10 });
    }

    #[test]
    fn test_with_range_rejects_equal_min_max() {
        let err = PulseEffect::with_range(50, 50, 5).unwrap_err();
        assert_eq!(err, PulseEffectError::InvalidRange { min: 50, max: 50 });
    }

    #[test]
    fn test_with_range_rejects_zero_step() {
        let err = PulseEffect::with_range(10, 100, 0).unwrap_err();
        assert_eq!(err, PulseEffectError::ZeroStep);
    }

    #[test]
    fn test_error_display() {
        let err = PulseEffectError::InvalidRange { min: 100, max: 10 };
        assert_eq!(
            format!("{}", err),
            "invalid range: min (100) must be less than max (10)"
        );

        let err = PulseEffectError::ZeroStep;
        assert_eq!(format!("{}", err), "step must be greater than 0");
    }
}