Implement LED test effects with proper cleanup

- Add LED test effects page with multiple test patterns (solid colors, rainbow, breathing, flowing)
- Implement Rust backend for LED test effects with proper task management
- Add automatic cleanup when navigating away from test page using onCleanup hook
- Ensure test mode is properly disabled to resume normal ambient lighting
- Clean up debug logging for production readiness
- Fix menu navigation issues by using SolidJS router components

Features:
- Multiple test patterns: solid colors, rainbow cycle, breathing effect, flowing lights
- Configurable animation speed
- Automatic cleanup prevents LED conflicts with ambient lighting
- Responsive UI with proper error handling
This commit is contained in:
2025-07-06 02:37:15 +08:00
parent 90cace679b
commit 7e2dafa3d2
7 changed files with 820 additions and 35 deletions

1
src-tauri/Cargo.lock generated
View File

@ -4432,6 +4432,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-tungstenite",
"tokio-util",
"toml 0.7.8",
"url-build-parse",
]

View File

@ -23,6 +23,7 @@ core-graphics = "0.22.3"
display-info = "0.4.1"
anyhow = "1.0.69"
tokio = {version = "1.26.0", features = ["full"] }
tokio-util = "0.7"
paris = { version = "1.5", features = ["timestamps", "macros"] }
log = "0.4.17"
env_logger = "0.10.0"

View File

@ -26,6 +26,7 @@ pub struct LedColorsPublisher {
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
inner_tasks_version: Arc<RwLock<usize>>,
test_mode_active: Arc<RwLock<bool>>,
}
impl LedColorsPublisher {
@ -44,6 +45,7 @@ impl LedColorsPublisher {
colors_rx: Arc::new(RwLock::new(rx)),
colors_tx: Arc::new(RwLock::new(tx)),
inner_tasks_version: Arc::new(RwLock::new(0)),
test_mode_active: Arc::new(RwLock::new(false)),
}
})
.await
@ -81,12 +83,20 @@ impl LedColorsPublisher {
let mappers = mappers.clone();
match Self::send_colors_by_display(colors, mappers, &strips, &color_calibration).await {
Ok(_) => {
// log::info!("sent colors: #{: >15}", display_id);
}
Err(err) => {
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
// Check if test mode is active before sending normal colors
let test_mode_active = {
let publisher = LedColorsPublisher::global().await;
*publisher.test_mode_active.read().await
};
if !test_mode_active {
match Self::send_colors_by_display(colors, mappers, &strips, &color_calibration).await {
Ok(_) => {
// log::info!("sent colors: #{: >15}", display_id);
}
Err(err) => {
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
}
}
}
@ -532,6 +542,35 @@ impl LedColorsPublisher {
pub async fn clone_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
self.colors_rx.read().await.clone()
}
/// Enable test mode - this will pause normal LED data publishing
pub async fn enable_test_mode(&self) {
let mut test_mode = self.test_mode_active.write().await;
*test_mode = true;
log::info!("Test mode enabled - normal LED publishing paused");
}
/// Disable test mode - this will resume normal LED data publishing
pub async fn disable_test_mode(&self) {
let mut test_mode = self.test_mode_active.write().await;
*test_mode = false;
log::info!("Test mode disabled - normal LED publishing resumed");
}
/// Disable test mode with a delay to ensure clean transition
pub async fn disable_test_mode_with_delay(&self, delay_ms: u64) {
// Wait for the specified delay
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
let mut test_mode = self.test_mode_active.write().await;
*test_mode = false;
log::info!("Test mode disabled with delay - normal LED publishing resumed");
}
/// Check if test mode is currently active
pub async fn is_test_mode_active(&self) -> bool {
*self.test_mode_active.read().await
}
}
#[derive(Debug)]

View File

@ -0,0 +1,239 @@
use std::f64::consts::PI;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TestEffectType {
FlowingRainbow,
GroupCounting,
SingleScan,
Breathing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestEffectConfig {
pub effect_type: TestEffectType,
pub led_count: u32,
pub led_type: LedType,
pub speed: f64, // Speed multiplier
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LedType {
RGB,
RGBW,
}
pub struct LedTestEffects;
impl LedTestEffects {
/// Generate LED colors for a specific test effect at a given time
pub fn generate_colors(config: &TestEffectConfig, time_ms: u64) -> Vec<u8> {
let time_seconds = time_ms as f64 / 1000.0;
match config.effect_type {
TestEffectType::FlowingRainbow => {
Self::flowing_rainbow(config.led_count, config.led_type.clone(), time_seconds, config.speed)
}
TestEffectType::GroupCounting => {
Self::group_counting(config.led_count, config.led_type.clone())
}
TestEffectType::SingleScan => {
Self::single_scan(config.led_count, config.led_type.clone(), time_seconds, config.speed)
}
TestEffectType::Breathing => {
Self::breathing(config.led_count, config.led_type.clone(), time_seconds, config.speed)
}
}
}
/// Flowing rainbow effect - smooth rainbow colors flowing along the strip
fn flowing_rainbow(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
let mut buffer = Vec::new();
let time_offset = (time * speed * 60.0) % 360.0; // 60 degrees per second at speed 1.0
for i in 0..led_count {
// Create longer wavelength for smoother color transitions
let hue = ((i as f64 * 720.0 / led_count as f64) + time_offset) % 360.0;
let rgb = Self::hsv_to_rgb(hue, 1.0, 1.0);
buffer.push(rgb.0);
buffer.push(rgb.1);
buffer.push(rgb.2);
if matches!(led_type, LedType::RGBW) {
buffer.push(0); // White channel
}
}
buffer
}
/// Group counting effect - every 10 LEDs have different colors
fn group_counting(led_count: u32, led_type: LedType) -> Vec<u8> {
let mut buffer = Vec::new();
let group_colors = [
(255, 0, 0), // Red (1-10)
(0, 255, 0), // Green (11-20)
(0, 0, 255), // Blue (21-30)
(255, 255, 0), // Yellow (31-40)
(255, 0, 255), // Magenta (41-50)
(0, 255, 255), // Cyan (51-60)
(255, 128, 0), // Orange (61-70)
(128, 255, 0), // Lime (71-80)
(255, 255, 255), // White (81-90)
(128, 128, 128), // Gray (91-100)
];
for i in 0..led_count {
let group_index = (i / 10) % group_colors.len() as u32;
let color = group_colors[group_index as usize];
buffer.push(color.0);
buffer.push(color.1);
buffer.push(color.2);
if matches!(led_type, LedType::RGBW) {
buffer.push(0); // White channel
}
}
buffer
}
/// Single LED scan effect - one LED moves along the strip
fn single_scan(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
let mut buffer = Vec::new();
let scan_period = 2.0 / speed; // 2 seconds per full scan at speed 1.0
let active_index = ((time / scan_period * led_count as f64) as u32) % led_count;
for i in 0..led_count {
if i == active_index {
// Bright white LED
buffer.push(255);
buffer.push(255);
buffer.push(255);
if matches!(led_type, LedType::RGBW) {
buffer.push(255); // White channel
}
} else {
// Off
buffer.push(0);
buffer.push(0);
buffer.push(0);
if matches!(led_type, LedType::RGBW) {
buffer.push(0); // White channel
}
}
}
buffer
}
/// Breathing effect - entire strip breathes with white light
fn breathing(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
let mut buffer = Vec::new();
let breathing_period = 4.0 / speed; // 4 seconds per breath at speed 1.0
let brightness = ((time / breathing_period * 2.0 * PI).sin() * 0.5 + 0.5) * 255.0;
let brightness = brightness as u8;
for _i in 0..led_count {
buffer.push(brightness);
buffer.push(brightness);
buffer.push(brightness);
if matches!(led_type, LedType::RGBW) {
buffer.push(brightness); // White channel
}
}
buffer
}
/// Convert HSV to RGB
/// H: 0-360, S: 0-1, V: 0-1
/// Returns: (R, G, B) where each component is 0-255
fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r_prime, g_prime, b_prime) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
let r = ((r_prime + m) * 255.0).round() as u8;
let g = ((g_prime + m) * 255.0).round() as u8;
let b = ((b_prime + m) * 255.0).round() as u8;
(r, g, b)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hsv_to_rgb() {
// Test red
let (r, g, b) = LedTestEffects::hsv_to_rgb(0.0, 1.0, 1.0);
assert_eq!((r, g, b), (255, 0, 0));
// Test green
let (r, g, b) = LedTestEffects::hsv_to_rgb(120.0, 1.0, 1.0);
assert_eq!((r, g, b), (0, 255, 0));
// Test blue
let (r, g, b) = LedTestEffects::hsv_to_rgb(240.0, 1.0, 1.0);
assert_eq!((r, g, b), (0, 0, 255));
}
#[test]
fn test_flowing_rainbow() {
let config = TestEffectConfig {
effect_type: TestEffectType::FlowingRainbow,
led_count: 10,
led_type: LedType::RGB,
speed: 1.0,
};
let colors = LedTestEffects::generate_colors(&config, 0);
assert_eq!(colors.len(), 30); // 10 LEDs * 3 bytes each
}
#[test]
fn test_group_counting() {
let config = TestEffectConfig {
effect_type: TestEffectType::GroupCounting,
led_count: 20,
led_type: LedType::RGB,
speed: 1.0,
};
let colors = LedTestEffects::generate_colors(&config, 0);
assert_eq!(colors.len(), 60); // 20 LEDs * 3 bytes each
// First 10 should be red
assert_eq!(colors[0], 255); // R
assert_eq!(colors[1], 0); // G
assert_eq!(colors[2], 0); // B
// Next 10 should be green
assert_eq!(colors[30], 0); // R
assert_eq!(colors[31], 255); // G
assert_eq!(colors[32], 0); // B
}
}

View File

@ -4,6 +4,7 @@
mod ambient_light;
mod display;
mod led_color;
mod led_test_effects;
mod rpc;
mod screenshot;
mod screenshot_manager;
@ -13,6 +14,7 @@ mod volume;
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup, LedType};
use display::{DisplayManager, DisplayState};
use display_info::DisplayInfo;
use led_test_effects::{LedTestEffects, TestEffectConfig, TestEffectType};
use paris::{error, info, warn};
use rpc::{BoardInfo, UdpRpc};
use screenshot::Screenshot;
@ -24,6 +26,14 @@ use tauri::{Manager, Emitter, Runtime};
use regex;
use tauri::http::{Request, Response};
use volume::VolumeManager;
use std::sync::Arc;
use tokio::sync::RwLock;
// Global static variables for LED test effect management
static EFFECT_HANDLE: tokio::sync::OnceCell<Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>> =
tokio::sync::OnceCell::const_new();
static CANCEL_TOKEN: tokio::sync::OnceCell<Arc<RwLock<Option<tokio_util::sync::CancellationToken>>>> =
tokio::sync::OnceCell::const_new();
#[derive(Serialize, Deserialize)]
#[serde(remote = "DisplayInfo")]
struct DisplayInfoDef {
@ -140,10 +150,6 @@ async fn patch_led_strip_len(display_id: u32, border: Border, delta_len: i8) ->
#[tauri::command]
async fn patch_led_strip_type(display_id: u32, border: Border, led_type: LedType) -> Result<(), String> {
info!(
"patch_led_strip_type: {} {:?} {:?}",
display_id, border, led_type
);
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.patch_led_strip_type(display_id, border, led_type)
@ -153,7 +159,6 @@ async fn patch_led_strip_type(display_id: u32, border: Border, led_type: LedType
e.to_string()
})?;
info!("patch_led_strip_type: ok");
Ok(())
}
@ -167,6 +172,193 @@ async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
})
}
#[tauri::command]
async fn send_test_colors_to_board(board_address: String, offset: u16, buffer: Vec<u8>) -> Result<(), String> {
use tokio::net::UdpSocket;
let socket = UdpSocket::bind("0.0.0.0:0").await.map_err(|e| {
error!("Failed to bind UDP socket: {}", e);
e.to_string()
})?;
let mut packet = vec![0x02]; // Header
packet.push((offset >> 8) as u8); // Offset high
packet.push((offset & 0xff) as u8); // Offset low
packet.extend_from_slice(&buffer); // Color data
socket.send_to(&packet, &board_address).await.map_err(|e| {
error!("Failed to send test colors to board {}: {}", board_address, e);
e.to_string()
})?;
info!("Sent test colors to board {} with offset {} and {} bytes", board_address, offset, buffer.len());
Ok(())
}
#[tauri::command]
async fn enable_test_mode() -> Result<(), String> {
let publisher = ambient_light::LedColorsPublisher::global().await;
publisher.enable_test_mode().await;
Ok(())
}
#[tauri::command]
async fn disable_test_mode() -> Result<(), String> {
info!("🔄 disable_test_mode command called from frontend");
let publisher = ambient_light::LedColorsPublisher::global().await;
publisher.disable_test_mode().await;
info!("✅ disable_test_mode command completed");
Ok(())
}
#[tauri::command]
async fn is_test_mode_active() -> Result<bool, String> {
let publisher = ambient_light::LedColorsPublisher::global().await;
Ok(publisher.is_test_mode_active().await)
}
#[tauri::command]
async fn start_led_test_effect(
board_address: String,
effect_config: TestEffectConfig,
update_interval_ms: u64,
) -> Result<(), String> {
use tokio::time::{interval, Duration};
// Enable test mode first
let publisher = ambient_light::LedColorsPublisher::global().await;
publisher.enable_test_mode().await;
let handle_storage = EFFECT_HANDLE.get_or_init(|| async {
Arc::new(RwLock::new(None))
}).await;
let cancel_storage = CANCEL_TOKEN.get_or_init(|| async {
Arc::new(RwLock::new(None))
}).await;
// Stop any existing effect
{
let mut cancel_guard = cancel_storage.write().await;
if let Some(token) = cancel_guard.take() {
token.cancel();
}
let mut handle_guard = handle_storage.write().await;
if let Some(handle) = handle_guard.take() {
let _ = handle.await; // Wait for graceful shutdown
}
}
// Start new effect
let effect_config = Arc::new(effect_config);
let board_address = Arc::new(board_address);
let start_time = std::time::Instant::now();
// Create new cancellation token
let cancel_token = tokio_util::sync::CancellationToken::new();
let cancel_token_clone = cancel_token.clone();
let handle = tokio::spawn(async move {
let mut interval = interval(Duration::from_millis(update_interval_ms));
loop {
tokio::select! {
_ = interval.tick() => {
let elapsed_ms = start_time.elapsed().as_millis() as u64;
let colors = LedTestEffects::generate_colors(&effect_config, elapsed_ms);
// Send to board
if let Err(e) = send_test_colors_to_board_internal(&board_address, 0, colors).await {
error!("Failed to send test effect colors: {}", e);
break;
}
}
_ = cancel_token_clone.cancelled() => {
info!("LED test effect cancelled gracefully");
break;
}
}
}
info!("LED test effect task ended");
});
// Store the handle and cancel token
{
let mut handle_guard = handle_storage.write().await;
*handle_guard = Some(handle);
let mut cancel_guard = cancel_storage.write().await;
*cancel_guard = Some(cancel_token);
}
Ok(())
}
#[tauri::command]
async fn stop_led_test_effect(board_address: String, led_count: u32, led_type: led_test_effects::LedType) -> Result<(), String> {
// Stop the effect task first
info!("🛑 Stopping LED test effect - board: {}", board_address);
// Cancel the task gracefully first
if let Some(cancel_storage) = CANCEL_TOKEN.get() {
let mut cancel_guard = cancel_storage.write().await;
if let Some(token) = cancel_guard.take() {
info!("🔄 Cancelling test effect task gracefully");
token.cancel();
}
}
// Wait for the task to finish
if let Some(handle_storage) = EFFECT_HANDLE.get() {
let mut handle_guard = handle_storage.write().await;
if let Some(handle) = handle_guard.take() {
info!("⏳ Waiting for test effect task to finish");
match handle.await {
Ok(_) => info!("✅ Test effect task finished successfully"),
Err(e) => warn!("⚠️ Test effect task finished with error: {}", e),
}
}
}
// Turn off all LEDs
let bytes_per_led = match led_type {
led_test_effects::LedType::RGB => 3,
led_test_effects::LedType::RGBW => 4,
};
let buffer = vec![0u8; (led_count * bytes_per_led) as usize];
send_test_colors_to_board_internal(&board_address, 0, buffer).await
.map_err(|e| e.to_string())?;
info!("💡 Sent LED off command");
// Disable test mode to resume normal publishing
let publisher = ambient_light::LedColorsPublisher::global().await;
publisher.disable_test_mode().await;
info!("🔄 Test mode disabled, normal publishing resumed");
info!("✅ LED test effect stopped completely");
Ok(())
}
// Internal helper function
async fn send_test_colors_to_board_internal(board_address: &str, offset: u16, buffer: Vec<u8>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::net::UdpSocket;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
let mut packet = vec![0x02]; // Header
packet.push((offset >> 8) as u8); // Offset high
packet.push((offset & 0xff) as u8); // Offset low
packet.extend_from_slice(&buffer); // Color data
socket.send_to(&packet, board_address).await?;
Ok(())
}
#[tauri::command]
async fn move_strip_part(
display_id: u32,
@ -356,20 +548,7 @@ fn handle_ambient_light_protocol<R: Runtime>(
async fn main() {
env_logger::init();
// Debug: Print available displays
match display_info::DisplayInfo::all() {
Ok(displays) => {
println!("=== AVAILABLE DISPLAYS ===");
for (index, display) in displays.iter().enumerate() {
println!(" Display {}: ID={}, Scale={}, Width={}, Height={}",
index, display.id, display.scale_factor, display.width, display.height);
}
println!("=== END DISPLAYS ===");
}
Err(e) => {
println!("Error getting display info: {}", e);
}
}
// Initialize display info (removed debug output)
tokio::spawn(async move {
let screenshot_manager = ScreenshotManager::global().await;
@ -404,6 +583,12 @@ async fn main() {
patch_led_strip_len,
patch_led_strip_type,
send_colors,
send_test_colors_to_board,
enable_test_mode,
disable_test_mode,
is_test_mode_active,
start_led_test_effect,
stop_led_test_effect,
move_strip_part,
reverse_led_strip_part,
set_color_calibration,