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}