diff --git a/rp2040/src/hardware.rs b/rp2040/src/hardware.rs index 1a6852a..743d898 100644 --- a/rp2040/src/hardware.rs +++ b/rp2040/src/hardware.rs @@ -35,7 +35,8 @@ pub mod usb { /// Timing cadence helpers used throughout the firmware. pub mod timers { pub const USB_REPORT_INTERVAL_MS: u32 = 10; - pub const USB_TICK_INTERVAL_US: u32 = 250; + pub const USB_TICK_INTERVAL_MS: u32 = 1; + pub const SCAN_TICK_INTERVAL_US: u32 = 250; pub const STATUS_LED_INTERVAL_MS: u32 = 10; pub const IDLE_TIMEOUT_MS: u32 = 5_000; } diff --git a/rp2040/src/main.rs b/rp2040/src/main.rs index d3aadfc..fd29d8f 100644 --- a/rp2040/src/main.rs +++ b/rp2040/src/main.rs @@ -60,8 +60,11 @@ fn main() -> ! { } // Timers driving periodic USB polls and status LED updates. + let mut scan_tick = timer.count_down(); + scan_tick.start(timers::SCAN_TICK_INTERVAL_US.micros()); + let mut usb_tick = timer.count_down(); - usb_tick.start(timers::USB_TICK_INTERVAL_US.micros()); + usb_tick.start(timers::USB_TICK_INTERVAL_MS.millis()); let mut status_tick = timer.count_down(); status_tick.start(timers::STATUS_LED_INTERVAL_MS.millis()); @@ -82,58 +85,63 @@ fn main() -> ! { status_led.apply_summary(summary, status_time_ms); } - let should_scan = { - const SUSPENDED_SCAN_PERIOD: u8 = 20; - if keyboard_state.usb().suspended { - suspended_scan_counter = (suspended_scan_counter + 1) % SUSPENDED_SCAN_PERIOD; - suspended_scan_counter == 0 - } else { - suspended_scan_counter = 0; - true - } - }; + if scan_tick.wait().is_ok() { + let should_scan = { + const SUSPENDED_SCAN_PERIOD: u8 = 20; + if keyboard_state.usb().suspended { + suspended_scan_counter = + (suspended_scan_counter + 1) % SUSPENDED_SCAN_PERIOD; + suspended_scan_counter == 0 + } else { + suspended_scan_counter = 0; + true + } + }; - if usb_tick.wait().is_ok() && should_scan { - // Scan the key matrix, handle bootloader chord, and produce a report. - button_matrix.scan_matrix(&mut delay); - let pressed_keys = button_matrix.buttons_pressed(); + if should_scan { + // Scan the key matrix, handle bootloader chord, and produce a report. + button_matrix.scan_matrix(&mut delay); + let pressed_keys = button_matrix.buttons_pressed(); - if bootloader::chord_requested(&pressed_keys) { - if !keyboard_state.usb().suspended { - for _ in 0..3 { - let clear_report: KeyReport = - [Keyboard::NoEventIndicated; hardware::NUMBER_OF_KEYS]; - match keyboard.device().write_report(clear_report) { - Ok(_) => break, - Err(UsbHidError::WouldBlock) | Err(UsbHidError::Duplicate) => { - let _ = keyboard.tick(); + if bootloader::chord_requested(&pressed_keys) { + if !keyboard_state.usb().suspended { + for _ in 0..3 { + let clear_report: KeyReport = + [Keyboard::NoEventIndicated; hardware::NUMBER_OF_KEYS]; + match keyboard.device().write_report(clear_report) { + Ok(_) => break, + Err(UsbHidError::WouldBlock) | Err(UsbHidError::Duplicate) => { + let _ = keyboard.tick(); + } + Err(_) => break, } - Err(_) => break, + } + } + + delay.delay_ms(5); + bootloader::enter(&mut status_led); + } + + if pressed_keys.iter().any(|pressed| *pressed) { + keyboard_state.usb_state().handle_input_activity(); + } + + let keyboard_report = keyboard_state.process_scan(pressed_keys); + + if !keyboard_state.usb().suspended { + // Try to send the generated report to the host. + match keyboard.device().write_report(keyboard_report) { + Err(UsbHidError::WouldBlock) | Err(UsbHidError::Duplicate) => {} + Ok(_) => {} + Err(_) => { + keyboard_state.mark_stopped(); } } } - - delay.delay_ms(5); - bootloader::enter(&mut status_led); - } - - if pressed_keys.iter().any(|pressed| *pressed) { - keyboard_state.usb_state().handle_input_activity(); - } - - let keyboard_report = keyboard_state.process_scan(pressed_keys); - - if !keyboard_state.usb().suspended { - // Try to send the generated report to the host. - match keyboard.device().write_report(keyboard_report) { - Err(UsbHidError::WouldBlock) | Err(UsbHidError::Duplicate) => {} - Ok(_) => {} - Err(_) => { - keyboard_state.mark_stopped(); - } - } } + } + if usb_tick.wait().is_ok() { match keyboard.tick() { Err(UsbHidError::WouldBlock) | Ok(_) => {} Err(_) => { diff --git a/rp2040/src/status.rs b/rp2040/src/status.rs index ccb1a15..f408896 100644 --- a/rp2040/src/status.rs +++ b/rp2040/src/status.rs @@ -14,6 +14,7 @@ const COLOR_ORANGE: RGB8 = RGB8 { r: 5, g: 10, b: 0 }; const COLOR_RED: RGB8 = RGB8 { r: 20, g: 0, b: 0 }; const COLOR_PURPLE: RGB8 = RGB8 { r: 0, g: 10, b: 10 }; const BREATH_PERIOD_MS: u32 = 3200; +const BREATH_PAUSE_MS: u32 = 3000; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StatusMode { @@ -105,8 +106,9 @@ where let mode = summary_to_mode(summary); let elapsed = if self.current_mode != mode { self.current_mode = mode; - self.mode_started_at = Some(current_time_ms); - 0 + let start = mode_start_time(mode, current_time_ms); + self.mode_started_at = Some(start); + current_time_ms.saturating_sub(start) } else { let start = self.mode_started_at.unwrap_or(current_time_ms); current_time_ms.saturating_sub(start) @@ -162,6 +164,13 @@ fn mode_color(mode: StatusMode, elapsed_ms: u32) -> RGB8 { } } +fn mode_start_time(mode: StatusMode, now: u32) -> u32 { + match mode { + StatusMode::Idle => now.saturating_sub(BREATH_PERIOD_MS / 2), + _ => now, + } +} + fn blink(color: RGB8, elapsed_ms: u32, period_ms: u32) -> RGB8 { // Toggle between the provided colour and off at the requested period. if period_ms == 0 { @@ -178,22 +187,176 @@ fn breathe(color: RGB8, elapsed_ms: u32, period_ms: u32) -> RGB8 { } let period = period_ms.max(1); - let phase = (elapsed_ms % period) as f32 / period as f32; - let brightness = if phase < 0.5 { - 1.0 - (phase * 2.0) - } else { - (phase - 0.5) * 2.0 - }; - let brightness_factor = ((brightness.clamp(0.0, 1.0) * 255.0) + 0.5) as u8; + let cycle = period.saturating_add(BREATH_PAUSE_MS); + let phase = elapsed_ms % cycle; + if phase >= period { + return COLOR_OFF; + } - scale_color(color, brightness_factor) + let half = (period / 2).max(1); + let ramp = if phase < half { + ((phase * 255) / half) as u8 + } else { + (((period - phase) * 255) / half) as u8 + }; + + scale_color(color, ramp) } fn scale_color(color: RGB8, factor: u8) -> RGB8 { - // Linearly scale each colour component by the provided brightness factor. + // Scale components while maintaining colour balance at low brightness. + if factor == 0 { + return COLOR_OFF; + } + + let components = [color.r, color.g, color.b]; + let mut scaled = [0u8; 3]; + let mut remainders = [0u16; 3]; + let mut total_floor: u16 = 0; + let mut total_base: u16 = 0; + let factor_u16 = factor as u16; + + for (index, &component) in components.iter().enumerate() { + total_base += component as u16; + if component == 0 { + continue; + } + + let value = component as u32 * factor as u32; + let div = (value / 255) as u16; + let rem = (value % 255) as u16; + + scaled[index] = div as u8; + remainders[index] = rem; + total_floor += div; + } + + if total_base == 0 { + return COLOR_OFF; + } + + let mut target_total = ((total_base as u32 * factor_u16 as u32) + 127) / 255; + if target_total > total_base as u32 { + target_total = total_base as u32; + } + + let mut extra = target_total.saturating_sub(total_floor as u32) as u16; + + while extra > 0 { + let mut best_index: Option = None; + let mut best_remainder = 0u16; + + for idx in 0..components.len() { + if components[idx] == 0 { + continue; + } + if remainders[idx] == 0 { + continue; + } + if scaled[idx] as u16 >= components[idx] as u16 { + continue; + } + + if remainders[idx] > best_remainder { + best_remainder = remainders[idx]; + best_index = Some(idx); + } + } + + let Some(idx) = best_index else { + break; + }; + + scaled[idx] += 1; + remainders[idx] = 0; + extra -= 1; + } + + if extra > 0 { + for idx in 0..components.len() { + if extra == 0 { + break; + } + if components[idx] == 0 { + continue; + } + if scaled[idx] as u16 >= components[idx] as u16 { + continue; + } + + scaled[idx] += 1; + extra -= 1; + } + } + RGB8 { - r: (u16::from(color.r) * u16::from(factor) / 255) as u8, - g: (u16::from(color.g) * u16::from(factor) / 255) as u8, - b: (u16::from(color.b) * u16::from(factor) / 255) as u8, + r: scaled[0], + g: scaled[1], + b: scaled[2], + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn breathing_pause_turns_led_off() { + // The breathing pause should hold the LED dark between cycles. + let pause = breathe(COLOR_GREEN, BREATH_PERIOD_MS + BREATH_PAUSE_MS / 2, BREATH_PERIOD_MS); + assert_eq!(pause, COLOR_OFF); + + let restart = breathe( + COLOR_GREEN, + BREATH_PERIOD_MS + BREATH_PAUSE_MS + BREATH_PERIOD_MS / 4, + BREATH_PERIOD_MS, + ); + assert!(restart.g > 0); + } + + #[test] + fn breathing_waveform_is_symmetric() { + // Breathing should ramp up from dark, peak at the midpoint, and return to dark. + let start = breathe(COLOR_GREEN, 0, BREATH_PERIOD_MS); + let quarter = breathe(COLOR_GREEN, BREATH_PERIOD_MS / 4, BREATH_PERIOD_MS); + let half = breathe(COLOR_GREEN, BREATH_PERIOD_MS / 2, BREATH_PERIOD_MS); + let end = breathe(COLOR_GREEN, BREATH_PERIOD_MS, BREATH_PERIOD_MS); + + assert_eq!(start.g, 0); + assert!(quarter.g > start.g); + assert_eq!(half, COLOR_GREEN); + assert_eq!(end.g, 0); + } + + #[test] + fn scale_preserves_color_mix() { + // Scaling at low brightness must keep the same colour ratios. + let base = COLOR_ORANGE; + let dimmed = scale_color(base, 50); + assert!(dimmed.r > 0); + assert!(dimmed.g > 0); + assert_eq!(dimmed.b, 0); + assert!(dimmed.r <= base.r); + assert!(dimmed.g <= base.g); + assert_eq!( + dimmed.r as u16 * base.g as u16, + dimmed.g as u16 * base.r as u16 + ); + } + + #[test] + fn zero_factor_switches_off() { + // A zero factor should always blank the LED regardless of colour. + let off = scale_color(COLOR_BLUE, 0); + assert_eq!(off, COLOR_OFF); + } + + #[test] + fn idle_mode_backdates_start_time() { + // Idle mode should start mid-breath so the LED resumes smoothly. + let now = 10_000; + let expected = now.saturating_sub(BREATH_PERIOD_MS / 2); + assert_eq!(mode_start_time(StatusMode::Idle, now), expected); + assert_eq!(mode_start_time(StatusMode::Active, now), now); } }