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}