From 3216d007d0caa5a2b15ad47523783aaccb365ec1 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sun, 28 Sep 2025 18:54:05 +0200 Subject: [PATCH] Updated breathing effect --- rp2040/src/main.rs | 8 ++- rp2040/src/status.rs | 142 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/rp2040/src/main.rs b/rp2040/src/main.rs index f2fc33f..af5ae73 100644 --- a/rp2040/src/main.rs +++ b/rp2040/src/main.rs @@ -1,4 +1,9 @@ -//! Firmware entry orchestrating the CMDR Joystick runtime loop. +//! Project: CMtec CMDR Joystick +//! Date: 2025-03-09 +//! Author: Christoffer Martinsson +//! Email: cm@cmtec.se +//! License: Please refer to LICENSE in root directory + #![no_std] #![no_main] @@ -14,6 +19,7 @@ use panic_halt as _; use usb_device::prelude::*; use usbd_human_interface_device::prelude::{UsbHidClassBuilder, UsbHidError}; +// Embed the boot2 image for the W25Q080 flash; required for RP2040 to boot from external flash. #[link_section = ".boot2"] #[no_mangle] #[used] diff --git a/rp2040/src/status.rs b/rp2040/src/status.rs index 80a27f9..635b956 100644 --- a/rp2040/src/status.rs +++ b/rp2040/src/status.rs @@ -64,6 +64,7 @@ struct ModeDescriptor { const HEARTBEAT_POWER_MS: u32 = 800; const HEARTBEAT_IDLE_MS: u32 = 3200; +const HEARTBEAT_PAUSE_MS: u32 = 3000; impl LedEffect { fn update_interval_ms(self) -> u32 { @@ -92,7 +93,12 @@ impl LedEffect { } LedEffect::Heartbeat { period_ms } => { let period = period_ms.max(1); - let phase = elapsed_ms % period; + let cycle = period.saturating_add(HEARTBEAT_PAUSE_MS); + let phase = elapsed_ms % cycle; + if phase >= period { + return COLOR_OFF; + } + let half = (period / 2).max(1); let ramp = if phase < half { ((phase * 255) / half) as u8 @@ -199,12 +205,94 @@ const fn descriptor_for(mode: StatusMode, base_mode: StatusMode) -> ModeDescript } fn scale_color(base: RGB8, brightness: u8) -> RGB8 { - // Scale each RGB component proportionally to the requested brightness factor. - let scale = brightness as u16; + if brightness == 0 { + return COLOR_OFF; + } + + let components = [base.r, base.g, base.b]; + let mut scaled = [0u8; 3]; + let mut remainders = [0u16; 3]; + let mut total_floor: u16 = 0; + let mut total_base: u16 = 0; + let brightness_u16 = brightness as u16; + + for (index, &component) in components.iter().enumerate() { + total_base += component as u16; + if component == 0 { + continue; + } + + let value = component as u32 * brightness 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 * brightness_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: ((base.r as u16 * scale) / 255) as u8, - g: ((base.g as u16 * scale) / 255) as u8, - b: ((base.b as u16 * scale) / 255) as u8, + r: scaled[0], + g: scaled[1], + b: scaled[2], } } @@ -429,6 +517,27 @@ mod tests { assert_eq!(end.g, 0); } + #[test] + fn heartbeat_pause_keeps_led_off() { + // Added pause should hold the LED dark between breaths while allowing a clean restart. + let base = StatusMode::Normal; + let descriptor = descriptor_for(StatusMode::Idle, base); + let LedEffect::Heartbeat { period_ms } = descriptor.effect else { + panic!("Idle should use heartbeat effect"); + }; + + let pause_sample = descriptor + .effect + .color_for(descriptor.color, period_ms + HEARTBEAT_PAUSE_MS / 2); + assert_eq!(pause_sample, COLOR_OFF); + + let cycle = period_ms + HEARTBEAT_PAUSE_MS; + let restart = descriptor + .effect + .color_for(descriptor.color, cycle + period_ms / 4); + assert!(restart.g > 0); + } + #[test] fn blink_effect_toggles() { // Blink descriptor should alternate between the color and off state. @@ -443,6 +552,27 @@ mod tests { assert_eq!(off, COLOR_OFF); } + #[test] + fn low_brightness_preserves_color_mix() { + // Low brightness scaling must not drop any non-zero channel from the blend. + 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_brightness_turns_off_led() { + // Zero brightness should fully blank the LED regardless of the base color. + let base = COLOR_BLUE; + let off = scale_color(base, 0); + assert_eq!(off, COLOR_OFF); + } + #[test] fn determine_base_mode_before_usb() { // Before USB comes up the controller should stay in Power mode.