Skip to main content

rmk_q6_he_ansi/backlight/
init.rs

1//! Backlight task initialization and runtime loop.
2
3use crate::backlight::{
4    gamma_correction::gamma_correction,
5    led_processor::{BACKLIGHT_CH, BacklightCmd, CalibPhase},
6    mapping::LED_LAYOUT,
7};
8use CalibPhase::{AllAccepted, Done, Full, Zero};
9use embassy_stm32::{
10    gpio::Output,
11    mode::Async,
12    spi::{self, Spi},
13};
14use embassy_time::{Duration, Ticker, Timer};
15use embedded_hal_async::spi::ErrorType;
16use rmk::embassy_futures::select::{Either, select};
17use snled27351_driver::{
18    driver::Driver,
19    transport::spi::{SpiTransport, SpiTransportError},
20};
21
22/// Concrete SPI transport type for this keyboard.
23type Transport = SpiTransport<Spi<'static, Async, spi::mode::Master>, Output<'static>, Output<'static>, 2>;
24/// Concrete driver type for this keyboard.
25type BacklightDriver = Driver<Transport, 2>;
26/// Bus error type shared by all async driver helpers.
27type BusError = SpiTransportError<<Spi<'static, Async, spi::mode::Master> as ErrorType>::Error>;
28
29/// LED index for the Caps Lock indicator key.
30const CAPS_LOCK_LED_INDEX: usize = 62;
31/// LED index for the Num Lock indicator key.
32const NUM_LOCK_LED_INDEX: usize = 37;
33/// Brightness percentage applied to indicator LEDs (0–100).
34const INDICATOR_BRIGHTNESS: u8 = 100;
35/// RGB color for the active-lock indicator state (red).
36const INDICATOR_RED: (u8, u8, u8) = (255, 0, 0);
37/// RGB color for the inactive-lock indicator state (white).
38const INDICATOR_WHITE: (u8, u8, u8) = (255, 255, 255);
39/// RGB color for a disabled indicator (off).
40const INDICATOR_OFF: (u8, u8, u8) = (0, 0, 0);
41/// Background colour during the zero-travel calibration phase and the
42/// EEPROM-write-failure signal (amber).
43const CALIB_AMBER: (u8, u8, u8) = (255, 120, 0);
44/// Solid colour used for the post-calibration success hold and for each
45/// individually confirmed key during the full-travel pass (green).
46const CALIB_GREEN: (u8, u8, u8) = (0, 220, 80);
47/// Number of brightness steps in the soft-start ramp.
48const SOFTSTART_STEPS: u8 = 50;
49/// Total duration of the soft-start ramp in milliseconds.
50const SOFTSTART_RAMP_MS: u32 = 1000;
51/// Brightness percentage applied when a driver chip reports temperature ≥ 70
52/// °C.
53const THERMAL_THROTTLE_BRIGHTNESS: u8 = 50;
54/// Interval between thermal register polls.
55const THERMAL_POLL: Duration = Duration::from_secs(5);
56/// Duration in milliseconds to hold the solid-green display after calibration
57/// is successfully stored, before returning to normal white backlight.
58const CALIB_DONE_HOLD_MS: u64 = 2000;
59/// Number of on/off cycles in the "all keys accepted" blink animation.
60const CALIB_ALL_DONE_BLINK_COUNT: u8 = 3;
61/// Duration of each on and each off half-period of the blink (milliseconds).
62const CALIB_ALL_DONE_BLINK_HALF_MS: u64 = 150;
63
64/// Scale `value` by `brightness_percent` (0–100), rounding to nearest.
65///
66/// Computes `value × percent / 100` with half-up rounding by adding 50 before
67/// the integer division. The intermediate value fits in [`u16`] because
68/// `255 × 100 + 50 = 25 550 < 65 535`; neither saturating path can trigger.
69#[inline]
70const fn scale(value: u8, brightness_percent: u8) -> u8 {
71    let v = u16::from(value);
72    let p = u16::from(brightness_percent.min(100));
73    // +50 rounds to nearest; /100 cannot overflow u8 because v*p/100 ≤ 255.
74    u8::try_from(v.saturating_mul(p).saturating_add(50).checked_div(100).unwrap_or_default()).unwrap_or(255)
75}
76
77/// Apply brightness scaling and gamma correction to an RGB colour.
78///
79/// Each channel is scaled by `brightness_percent` via [`scale`], then passed
80/// through the gamma-2.2 LUT, producing values ready for the LED driver.
81#[inline]
82const fn correct(red: u8, green: u8, blue: u8, brightness_percent: u8) -> (u8, u8, u8) {
83    (
84        gamma_correction(scale(red, brightness_percent)),
85        gamma_correction(scale(green, brightness_percent)),
86        gamma_correction(scale(blue, brightness_percent)),
87    )
88}
89
90/// Apply [`correct`] to a pre-packaged `(R, G, B)` colour tuple.
91///
92/// Avoids destructuring at every call site when working with the named colour
93/// constants defined in this module.
94#[inline]
95const fn correct_color(color: (u8, u8, u8), brightness_percent: u8) -> (u8, u8, u8) {
96    correct(color.0, color.1, color.2, brightness_percent)
97}
98
99/// Paint every LED with `color` at `brightness` and flush to hardware.
100///
101/// Combines the three-step correct → stage_all_leds → flush sequence that
102/// appears in every solid-colour calibration frame, so each call site is a
103/// single expression.
104async fn fill_all_leds(driver: &mut BacklightDriver, color: (u8, u8, u8), brightness: u8) -> Result<(), BusError> {
105    let (r, g, b) = correct_color(color, brightness);
106    driver.stage_all_leds(r, g, b);
107    driver.flush().await
108}
109
110/// Full backlight state, kept in one place so any path can do a consistent
111/// redraw without losing information.
112#[derive(Clone, Copy)]
113struct BacklightState {
114    /// Whether Caps Lock is currently active.
115    caps_lock: bool,
116    /// Whether Num Lock is currently active.
117    num_lock: bool,
118    /// Global brightness percentage (0–100); reduced under thermal throttle.
119    brightness: u8,
120    /// Whether a first-boot calibration pass is currently in progress.
121    ///
122    /// `true` from [`Zero`] until [`Done`] completes.
123    /// Used by the thermal throttle path to call [`render_calib`] instead of
124    /// [`render_all`] so a thermal event never clobbers the calibration
125    /// display.
126    in_calib: bool,
127    /// Bitset of LED indices confirmed calibrated during the full-travel pass.
128    ///
129    /// Bit `i` set means LED `i` should be painted solid green by
130    /// [`render_calib`]. Cleared to zero when [`Done`] completes
131    /// and the keyboard returns to normal white operation.
132    calib_leds_done: u128,
133    /// Whole-keyboard gradient percentage (0–100) during the full-travel pass.
134    ///
135    /// Drives the blue→green background interpolation in [`render_calib`].
136    /// Cleared to zero alongside [`self::BacklightState::calib_leds_done`] on
137    /// calibration completion.
138    calib_pct: u8,
139}
140
141impl BacklightState {
142    const fn new() -> Self {
143        Self { caps_lock: false, num_lock: false, brightness: 100, in_calib: false, calib_leds_done: 0, calib_pct: 0 }
144    }
145}
146
147/// Writes every LED to hardware according to `state`.
148///
149/// Paints the background white at the current brightness, then overlays the
150/// indicator LEDs on top. Called after any state change requiring a full
151/// redraw (e.g. thermal-throttle transitions during normal operation).
152///
153/// # Errors
154///
155/// Returns `Err` if any bus transaction fails. See [`BacklightDriver::flush`].
156async fn render_all(driver: &mut BacklightDriver, state: BacklightState) -> Result<(), BusError> {
157    let (r, g, b) = correct_color(INDICATOR_WHITE, state.brightness);
158    driver.stage_all_leds(r, g, b);
159    render_indicators(driver, state).await
160}
161
162/// Render the full-travel calibration frame.
163///
164/// The background gradient starts red at 0 % progress and shifts toward blue
165/// at 100 % via `state.calib_pct`. Every LED whose bit is set in
166/// `state.calib_leds_done` is overlaid with solid green regardless of the
167/// background, giving immediate per-key visual confirmation as each key is
168/// pressed to the bottom.
169///
170/// # Errors
171///
172/// Returns `Err` if any bus transaction fails. See [`BacklightDriver::flush`].
173async fn render_calib(driver: &mut BacklightDriver, state: BacklightState) -> Result<(), BusError> {
174    // Gradient background: starts red (0 %), shifts toward blue (100 %).
175    let bg_red = scale(255, 100_u8.saturating_sub(state.calib_pct));
176    let bg_blue = scale(220, state.calib_pct);
177    let (bg_r, bg_g, bg_b) = correct(bg_red, 0, bg_blue, state.brightness);
178
179    // Per-key confirmed color: solid green.
180    let (cal_r, cal_g, cal_b) = correct_color(CALIB_GREEN, state.brightness);
181
182    driver.stage_all_leds(bg_r, bg_g, bg_b);
183
184    // Overlay individually confirmed LEDs in green.
185    let mut bits = state.calib_leds_done;
186    while bits != 0 {
187        // Isolate and consume the lowest set bit.
188        let idx = usize::try_from(bits.trailing_zeros()).unwrap_or(usize::MAX);
189        driver.stage_led(idx, cal_r, cal_g, cal_b);
190        bits &= bits.saturating_sub(1);
191    }
192
193    driver.flush().await
194}
195
196/// Writes only the two indicator LEDs according to `state`.
197///
198/// Used when only indicator state has changed and the background is already
199/// correct, avoiding a full redraw.
200///
201/// # Errors
202///
203/// Returns `Err` if any bus transaction fails. See [`BacklightDriver::flush`].
204async fn render_indicators(driver: &mut BacklightDriver, state: BacklightState) -> Result<(), BusError> {
205    // Caps Lock: red when active, white when inactive.
206    let caps_color = if state.caps_lock { INDICATOR_RED } else { INDICATOR_WHITE };
207    let (r, g, b) = correct_color(caps_color, INDICATOR_BRIGHTNESS);
208    driver.stage_led(CAPS_LOCK_LED_INDEX, r, g, b);
209
210    // Num Lock: white when active, off when inactive.
211    let num_color = if state.num_lock { INDICATOR_WHITE } else { INDICATOR_OFF };
212    let (r, g, b) = correct_color(num_color, INDICATOR_BRIGHTNESS);
213    driver.stage_led(NUM_LOCK_LED_INDEX, r, g, b);
214
215    driver.flush().await
216}
217
218/// Performs a soft-start brightness ramp on all LEDs at initialization.
219///
220/// Linearly steps brightness from 0 up to `target_brightness` over
221/// [`SOFTSTART_STEPS`] increments across [`SOFTSTART_RAMP_MS`] milliseconds.
222///
223/// # Errors
224///
225/// Returns `Err` if any bus transaction fails during the ramp.
226async fn softstart(
227    driver: &mut BacklightDriver,
228    base_red: u8,
229    base_green: u8,
230    base_blue: u8,
231    target_brightness: u8,
232) -> Result<(), BusError> {
233    let target = u32::from(target_brightness.min(100));
234    // SOFTSTART_STEPS is a non-zero constant; .max(1) is a belt-and-suspenders
235    // guard ensuring checked_div always returns Some.
236    let steps = u32::from(SOFTSTART_STEPS.max(1));
237    let delay_ms = u64::from(SOFTSTART_RAMP_MS.checked_div(steps).unwrap_or(0));
238
239    // `0..=steps` produces SOFTSTART_STEPS + 1 iterations so that both the
240    // 0 % start and the 100 % end are written. The final delay is skipped
241    // (step == steps guard) so the function returns immediately after the
242    // last write without an unnecessary pause.
243    for step in 0..=steps {
244        let percent = u8::try_from(target.saturating_mul(step).checked_div(steps).unwrap_or(0)).unwrap_or(0);
245        let (r, g, b) = correct(base_red, base_green, base_blue, percent);
246        driver.set_all_leds(r, g, b).await?;
247        if step < steps {
248            Timer::after_millis(delay_ms).await;
249        }
250    }
251    Ok(())
252}
253
254/// Runs the backlight controller loop.
255///
256/// Initializes both SNLED27351 driver chips, performs a soft-start brightness
257/// ramp, then enters an event loop that concurrently handles two concerns:
258///
259/// - **Indicator and calibration commands** arriving on [`BACKLIGHT_CH`].
260/// - **Thermal polling** on a [`THERMAL_POLL`] ticker: reads the TDF register
261///   from both chips and reduces overall brightness to
262///   [`THERMAL_THROTTLE_BRIGHTNESS`] if either exceeds 70 °C, restoring it
263///   automatically when both cool down. During calibration [`render_calib`] is
264///   used instead of [`render_all`] so the calibration display is never
265///   clobbered by a thermal event.
266pub async fn backlight_runner(
267    spi: Spi<'static, Async, spi::mode::Master>,
268    cs0: Output<'static>,
269    cs1: Output<'static>,
270    sdb: Output<'static>,
271) -> ! {
272    let transport = SpiTransport::new(spi, [cs0, cs1], sdb);
273    let mut driver = BacklightDriver::new(transport, LED_LAYOUT);
274
275    // Backlight failures are non-critical: the keyboard remains fully functional
276    // without LEDs.  All driver errors are intentionally ignored throughout
277    // this task via `let _ = ...`.
278    if driver.init(0xFF).await.is_ok() {
279        let _ = softstart(&mut driver, 255, 255, 255, 100).await;
280    }
281
282    let rx = BACKLIGHT_CH.receiver();
283    let mut thermal_ticker = Ticker::every(THERMAL_POLL);
284    let mut state = BacklightState::new();
285
286    loop {
287        match select(rx.receive(), thermal_ticker.next()).await {
288            Either::First(cmd) => match cmd {
289                BacklightCmd::CalibPhase(phase) => match phase {
290                    Zero => {
291                        // Amber: zero-travel pass, all keys should be fully released.
292                        state.in_calib = true;
293                        let _ = fill_all_leds(&mut driver, CALIB_AMBER, state.brightness).await;
294                    }
295                    Full => {
296                        // Reset calib tracking and render the initial red frame via
297                        // render_calib so the full-travel phase starts consistently.
298                        state.calib_leds_done = 0;
299                        state.calib_pct = 0;
300                        let _ = render_calib(&mut driver, state).await;
301                    }
302                    AllAccepted => {
303                        // Blink solid green × CALIB_ALL_DONE_BLINK_COUNT to signal
304                        // that every key has been recorded and keys may be released.
305                        // calibration is still in progress (in_calib stays true).
306                        for i in 0..CALIB_ALL_DONE_BLINK_COUNT {
307                            let _ = fill_all_leds(&mut driver, CALIB_GREEN, state.brightness).await;
308                            Timer::after_millis(CALIB_ALL_DONE_BLINK_HALF_MS).await;
309                            if i < CALIB_ALL_DONE_BLINK_COUNT.saturating_sub(1) {
310                                let _ = fill_all_leds(&mut driver, INDICATOR_OFF, state.brightness).await;
311                                Timer::after_millis(CALIB_ALL_DONE_BLINK_HALF_MS).await;
312                            }
313                        }
314                        // Leave the keyboard solid green heading into Done.
315                    }
316                    Done => {
317                        // Solid green hold for 2 s to confirm calibration is stored.
318                        let _ = fill_all_leds(&mut driver, CALIB_GREEN, state.brightness).await;
319                        Timer::after_millis(CALIB_DONE_HOLD_MS).await;
320                        // Clear all calibration state before returning to normal
321                        // white so stale bits cannot bleed into the next render.
322                        state.in_calib = false;
323                        state.calib_leds_done = 0;
324                        state.calib_pct = 0;
325                        let _ = render_all(&mut driver, state).await;
326                    }
327                },
328                BacklightCmd::CalibProgress(pct) => {
329                    state.calib_pct = pct;
330                    let _ = render_calib(&mut driver, state).await;
331                }
332                BacklightCmd::CalibKeyDone(led_idx) => {
333                    // Mark this LED calibrated and repaint immediately so the
334                    // key turns green the moment it crosses the threshold.
335                    // The `< 128` guard matches the bit-width of calib_leds_done
336                    // (u128); checked_shl is an additional defensive layer.
337                    if usize::from(led_idx) < 128 {
338                        state.calib_leds_done |= 1_u128.checked_shl(u32::from(led_idx)).unwrap_or(0);
339                    }
340                    let _ = render_calib(&mut driver, state).await;
341                }
342                BacklightCmd::Indicators { caps, num } => {
343                    state.caps_lock = caps;
344                    state.num_lock = num;
345                    // Background is already correct; only repaint indicator LEDs.
346                    let _ = render_indicators(&mut driver, state).await;
347                }
348            },
349            Either::Second(_) => {
350                let hot = driver.check_thermal_flag_set(0).await || driver.check_thermal_flag_set(1).await;
351                let new_brightness = if hot { THERMAL_THROTTLE_BRIGHTNESS } else { 100 };
352                if new_brightness != state.brightness {
353                    state.brightness = new_brightness;
354                    // During calibration use render_calib so the gradient and
355                    // per-key green state are preserved after the brightness change.
356                    // Outside calibration restore the normal white background.
357                    if state.in_calib {
358                        let _ = render_calib(&mut driver, state).await;
359                    } else {
360                        let _ = render_all(&mut driver, state).await;
361                    }
362                }
363            }
364        }
365    }
366}