Skip to main content

dmx_interface/hardware/
led.rs

1//! Status LED: LEDC PWM patterns driven by a dedicated embassy task.
2//!
3//! The LED is the device status indicator. Patterns and timings follow
4//! the mockups in `assets/led/*.svg`:
5//!
6//! | [`LedStatus`] | Pattern                    | Period |
7//! | ------------- | -------------------------- | ------ |
8//! | `Off`         | dark                       | —      |
9//! | `Startup`     | breathing, peak brightness | 1000ms |
10//! | `Resetting`   | fast breathing, peak       | 300ms  |
11//! | `Ok`          | solid, config brightness   | —      |
12//! | `Warning`     | blink, config brightness   | 1000ms |
13//! | `Error`       | blink, peak brightness     | 200ms  |
14//!
15//! All timing lives inside [`task`]; every other task (or interrupt) only
16//! calls [`set`] — a lock-free atomic store. Config-driven brightness flows
17//! in through [`set_brightness`], pushed by
18//! [`crate::config::Config::apply`].
19
20use core::{
21    f32::consts::{FRAC_PI_2, TAU},
22    sync::atomic::{AtomicU8, Ordering},
23};
24
25use embassy_time::{Duration, Instant, Timer};
26use esp_hal::{
27    ledc::{
28        LSGlobalClkSource, Ledc, LowSpeed,
29        channel::{self, ChannelHW, ChannelIFace},
30        timer::{self, LSClockSource, TimerIFace},
31    },
32    peripherals::{GPIO7, LEDC},
33    time::Rate,
34};
35
36/// Breathing period of [`LedStatus::Startup`] (1000 ms per `assets/led/breathing_1000ms.svg`).
37const STARTUP_PERIOD_MS: u64 = 1000;
38/// Breathing period of [`LedStatus::Resetting`] (300 ms per `assets/led/breathing_300ms.svg`).
39const RESETTING_PERIOD_MS: u64 = 300;
40/// Blink period of [`LedStatus::Warning`] (1000 ms per `assets/led/blinking_1000ms.svg`).
41const WARNING_PERIOD_MS: u64 = 1000;
42/// Blink period of [`LedStatus::Error`] (200 ms per `assets/led/blinking_200ms.svg`).
43const ERROR_PERIOD_MS: u64 = 200;
44
45/// Effect refresh interval.
46const TICK: Duration = Duration::from_millis(10);
47
48/// Brightness of the attention states (full scale).
49const PEAK_BRIGHTNESS: u8 = 255;
50
51/// Maximum duty count for the configured 14-bit resolution
52/// ([`timer::config::Duty::Duty14Bit`], i.e. 2^14 − 1).
53///
54/// Duty is driven via [`ChannelHW::set_duty_hw`] (raw counts) instead of the
55/// percent-based [`ChannelIFace::set_duty`]: the percent API quantizes every
56/// update to whole percent, which makes the breathing curve visibly step at
57/// low brightness. Raw counts keep all 16384 hardware levels.
58const DUTY_MAX: u16 = 0x3FFF;
59
60/// Brightness assumed until [`set_brightness`] delivers the configured
61/// value (mirrors `Config::default().led_brightness`).
62const DEFAULT_BRIGHTNESS: u8 = 25;
63
64/// Semantic status shown by the LED (timings in the module docs).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LedStatus {
67    /// LED off (duty 0).
68    Off,
69    /// Slow breathing while starting up (1000 ms, peak brightness).
70    Startup,
71    /// Fast breathing while resetting (300 ms, peak brightness).
72    Resetting,
73    /// Solid on at the configured brightness.
74    Ok,
75    /// Slow blink at the configured brightness (1000 ms period).
76    Warning,
77    /// Fast blink at peak brightness (200 ms period).
78    Error,
79}
80
81impl LedStatus {
82    /// Stable wire encoding for the status atomic.
83    const fn code(self) -> u8 {
84        match self {
85            Self::Off => 0,
86            Self::Startup => 1,
87            Self::Resetting => 2,
88            Self::Ok => 3,
89            Self::Warning => 4,
90            Self::Error => 5,
91        }
92    }
93
94    /// Decode a stored status code; unknown codes yield `None`.
95    const fn from_code(code: u8) -> Option<Self> {
96        match code {
97            0 => Some(Self::Off),
98            1 => Some(Self::Startup),
99            2 => Some(Self::Resetting),
100            3 => Some(Self::Ok),
101            4 => Some(Self::Warning),
102            5 => Some(Self::Error),
103            _ => None,
104        }
105    }
106}
107
108/// Requested status; written by [`set`], consumed by [`task`].
109static STATUS: AtomicU8 = AtomicU8::new(0);
110/// Config-driven brightness (0–255) for [`LedStatus::Ok`] / [`LedStatus::Warning`].
111static BRIGHTNESS: AtomicU8 = AtomicU8::new(DEFAULT_BRIGHTNESS);
112
113/// Selects the status pattern.
114///
115/// Lock-free (`AtomicU8` store), safe to call from any task or interrupt.
116/// The effect loop restarts the pattern phase on the next tick, so blink
117/// and breathing always start from their initial position.
118pub fn set(status: LedStatus) {
119    STATUS.store(status.code(), Ordering::Release);
120}
121
122/// Sets the config-driven brightness (0–255) used by [`LedStatus::Ok`] and
123/// [`LedStatus::Warning`].
124///
125/// Pushed by [`crate::config::Config::apply`]; the default matches
126/// `Config::default().led_brightness`.
127pub fn set_brightness(brightness: u8) {
128    BRIGHTNESS.store(brightness, Ordering::Release);
129}
130
131/// Runs the status-LED effect loop, owning the LEDC peripheral.
132///
133/// Configure the LEDC timer/channel once, then apply [`set`] statuses at
134/// will — the loop picks up every change on its next 10 ms tick.
135///
136/// # Panics
137///
138/// Panics if the LEDC timer or channel fails to configure (should not
139/// happen on a healthy peripheral).
140#[embassy_executor::task]
141pub async fn task(ledc: LEDC<'static>, pin: GPIO7<'static>) -> ! {
142    let mut ledc = Ledc::new(ledc);
143    ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);
144
145    let mut timer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0);
146    timer0
147        .configure(timer::config::Config {
148            duty: timer::config::Duty::Duty14Bit,
149            clock_source: LSClockSource::APBClk,
150            frequency: Rate::from_khz(1),
151        })
152        .expect("LEDC timer0 config failed");
153
154    let mut channel = ledc.channel(channel::Number::Channel0, pin);
155    channel
156        .configure(channel::config::Config {
157            timer: &timer0,
158            duty_pct: 0,
159            drive_mode: esp_hal::gpio::DriveMode::PushPull,
160        })
161        .expect("LEDC channel0 config failed");
162
163    let mut current = current_status();
164    let mut phase = Instant::now();
165
166    loop {
167        let status = current_status();
168        if status != current {
169            current = status;
170            phase = Instant::now();
171        }
172        let brightness = BRIGHTNESS.load(Ordering::Acquire);
173        apply(&channel, status, brightness, phase.elapsed().as_millis());
174        Timer::after(TICK).await;
175    }
176}
177
178/// Reads the requested status; unknown codes fall back to [`LedStatus::Off`].
179fn current_status() -> LedStatus {
180    LedStatus::from_code(STATUS.load(Ordering::Acquire)).unwrap_or(LedStatus::Off)
181}
182
183/// Applies `status` to the LEDC channel.
184///
185/// # Arguments
186/// * `channel` - The LEDC PWM channel to control.
187/// * `status` - The requested status pattern.
188/// * `config_brightness` - Config-driven brightness (0–255) for the states
189///   that use it ([`LedStatus::Ok`], [`LedStatus::Warning`]).
190/// * `elapsed_ms` - Milliseconds since the last status change; every period
191///   starts over from zero (blink begins on, breathing begins dark).
192fn apply(
193    channel: &channel::Channel<'_, LowSpeed>,
194    status: LedStatus,
195    config_brightness: u8,
196    elapsed_ms: u64,
197) {
198    match status {
199        LedStatus::Off => {
200            channel.set_duty_hw(0);
201        }
202        LedStatus::Startup => {
203            breathing(channel, PEAK_BRIGHTNESS, elapsed_ms, STARTUP_PERIOD_MS);
204        }
205        LedStatus::Resetting => {
206            breathing(channel, PEAK_BRIGHTNESS, elapsed_ms, RESETTING_PERIOD_MS);
207        }
208        LedStatus::Ok => {
209            channel.set_duty_hw(u32::from(brightness_counts(config_brightness)));
210        }
211        LedStatus::Warning => {
212            blink(channel, config_brightness, elapsed_ms, WARNING_PERIOD_MS);
213        }
214        LedStatus::Error => {
215            blink(channel, PEAK_BRIGHTNESS, elapsed_ms, ERROR_PERIOD_MS);
216        }
217    }
218}
219
220/// Breathing: raised sine over `period_ms`, scaled to `brightness`.
221///
222/// Raised sine `(sinf(2π·t − π/2) + 1) / 2` over `period_ms`, scaled to
223/// `brightness` — starts dark, peaks at half the period, ends dark.
224fn breathing(
225    channel: &channel::Channel<'_, LowSpeed>,
226    brightness: u8,
227    elapsed_ms: u64,
228    period_ms: u64,
229) {
230    let pos = elapsed_ms.checked_rem(period_ms).unwrap_or(0);
231    // Periods fit u16, so the u64 → f32 step stays cast-free.
232    let pos_f = f32::from(u16::try_from(pos).unwrap_or(u16::MAX));
233    let period_f = f32::from(u16::try_from(period_ms).unwrap_or(u16::MAX));
234    let wave = f32::midpoint(libm::sinf(pos_f / period_f * TAU - FRAC_PI_2), 1.0);
235    let counts = wave * f32::from(brightness_counts(brightness));
236    // The cast is bounded by construction (≤ DUTY_MAX); `core` has no f32 → u32.
237    #[allow(
238        clippy::as_conversions,
239        clippy::cast_possible_truncation,
240        clippy::cast_sign_loss
241    )]
242    channel.set_duty_hw(u32::from(libm::roundf(counts) as u16));
243}
244
245/// Blink: on for the first half of `period_ms`, off for the second.
246fn blink(
247    channel: &channel::Channel<'_, LowSpeed>,
248    brightness: u8,
249    elapsed_ms: u64,
250    period_ms: u64,
251) {
252    let pos = elapsed_ms.checked_rem(period_ms).unwrap_or(0);
253    let on = pos < period_ms / 2;
254    channel.set_duty_hw(u32::from(if on {
255        brightness_counts(brightness)
256    } else {
257        0
258    }));
259}
260
261/// Scale an 8-bit brightness (0–255) to raw duty counts (0–[`DUTY_MAX`]).
262///
263/// The intermediate math runs in `u32`, which cannot overflow here
264/// (`255 * 16383 ≈ 4.2M < u32::MAX`); `saturating_mul` makes that bound
265/// explicit for `clippy::arithmetic_side_effects`.
266fn brightness_counts(brightness: u8) -> u16 {
267    let counts = u32::from(brightness).saturating_mul(u32::from(DUTY_MAX)) / 255;
268    u16::try_from(counts).unwrap_or(DUTY_MAX)
269}