From 328af4cfd1f64a42f404c15349d45c9834709c19 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:06:36 -0800 Subject: [PATCH 1/2] manny changes so stopping here --- Cargo.lock | 188 ++++++++++++++++++- Cargo.toml | 2 + src/app.rs | 259 +++++++++++++++++++++++-- src/audio.rs | 35 +++- src/config.rs | 34 +++- src/constants.rs | 5 +- src/graphical_layout.rs | 395 +++++++++++++++++++++++++++++++++++++++ src/layout_visualizer.rs | 203 ++++++++++++++++++++ src/main.rs | 156 +++++++++++++++- src/nanoleaf.rs | 230 ++++++++++++++++++++--- src/visualizer.rs | 44 ++++- 11 files changed, 1498 insertions(+), 53 deletions(-) create mode 100644 src/graphical_layout.rs create mode 100644 src/layout_visualizer.rs diff --git a/Cargo.lock b/Cargo.lock index a116020..7304c62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -110,8 +116,10 @@ dependencies = [ "cpal", "dasp_sample", "dirs", + "macroquad", "num-complex", "palette", + "pollster", "ratatui", "reqwest", "serde", @@ -155,6 +163,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" @@ -238,6 +258,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -324,6 +350,15 @@ dependencies = [ "windows", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -475,12 +510,31 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2152dbcb980c05735e2a651d96011320a949eb31a0c8b38b72645ce97dec676" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -493,6 +547,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -590,6 +654,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + [[package]] name = "h2" version = "0.4.12" @@ -859,6 +929,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1027,6 +1110,35 @@ dependencies = [ "libc", ] +[[package]] +name = "macroquad" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2befbae373456143ef55aa93a73594d080adfb111dc32ec96a1123a3e4ff4ae" +dependencies = [ + "fontdue", + "glam", + "image", + "macroquad_macro", + "miniquad", + "quad-rand", +] + +[[package]] +name = "macroquad_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b1d96218903768c1ce078b657c0d5965465c95a60d2682fd97443c9d2483dd" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1039,6 +1151,28 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniquad" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb3e758e46dbc45716a8a49ca9edc54b15bcca826277e80b1f690708f67f9e3" +dependencies = [ + "libc", + "ndk-sys 0.2.2", + "objc-rs", + "winapi", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1077,7 +1211,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "thiserror 1.0.69", ] @@ -1088,6 +1222,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -1148,6 +1288,15 @@ dependencies = [ "syn", ] +[[package]] +name = "objc-rs" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a1e7069a2525126bf12a9f1f7916835fafade384fb27cabf698e745e2a1eb8" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.3" @@ -1401,6 +1550,25 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1428,6 +1596,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quad-rand" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" + [[package]] name = "quote" version = "1.0.42" @@ -1773,6 +1947,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "siphasher" version = "1.0.1" @@ -2130,6 +2310,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index 2ade2d8..0dc04e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,10 @@ clap = { version = "4.5.16", features = ["derive"] } cpal = "0.16.0" dasp_sample = "0.11.0" dirs = "6.0.0" +macroquad = "0.4" num-complex = "0.4.6" palette = "0.7.6" +pollster = "0.3" ratatui = "0.29.0" reqwest = { version = "0.12.24", features = ["blocking", "json"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/app.rs b/src/app.rs index 5d0aad1..e004ce1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use crate::event_handler::{self, Event}; use crate::utils; use crate::visualizer::VisualizerMsg; use crate::{ - config::{TuiConfig, VisualizerConfig}, + config::{Axis, Sort, TuiConfig, VisualizerConfig}, nanoleaf::{NlDevice, NlEffect}, visualizer, }; @@ -50,6 +50,10 @@ enum AppMsg { ScrollToBottom, ScrollToTop, ChangeGain(f32), + ChangePalette(usize), + ToggleAxis, + TogglePrimarySort, + ToggleSecondarySort, } #[derive(Debug)] @@ -69,6 +73,12 @@ struct EffectList { struct Visualizer { tx: mpsc::Sender, gain: f32, + current_palette_index: usize, + palette_names: Vec, + primary_axis: Axis, + sort_primary: Sort, + sort_secondary: Sort, + global_orientation: u16, } #[derive(Debug)] @@ -113,8 +123,34 @@ impl App { .default_gain .unwrap_or(constants::DEFAULT_GAIN); eprintln!("INFO: Starting with gain: {}", gain); + + // Get global orientation + let global_orientation = nl_device + .get_global_orientation() + .ok() + .and_then(|o| o["value"].as_u64()) + .unwrap_or(0) as u16; + + let primary_axis = visualizer_config.primary_axis.unwrap_or_default(); + let sort_primary = visualizer_config.sort_primary.unwrap_or_default(); + let sort_secondary = visualizer_config.sort_secondary.unwrap_or_default(); + let tx = visualizer::Visualizer::new(visualizer_config, audio_stream, &nl_device)?.init(); - let visualizer = Visualizer { tx, gain }; + + // Initialize palette list + let mut palette_names = crate::palettes::get_palette_names(); + palette_names.sort(); + + let visualizer = Visualizer { + tx, + gain, + current_palette_index: 0, + palette_names, + primary_axis, + sort_primary, + sort_secondary, + global_orientation, + }; let display_colors = tui_config .colorful_effect_names .unwrap_or(constants::DEFAULT_COLORFUL_EFFECT_NAMES); @@ -203,6 +239,97 @@ impl App { AppMsg::NoOp } } + KeyCode::Char('1') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(0) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('2') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(1) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('3') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(2) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('4') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(3) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('5') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(4) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('6') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(5) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('7') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(6) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('8') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(7) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('9') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(8) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('0') => { + if let AppView::Visualizer = self.view { + AppMsg::ChangePalette(9) + } else { + AppMsg::NoOp + } + } + KeyCode::Char('a') | KeyCode::Char('A') => { + if let AppView::Visualizer = self.view { + AppMsg::ToggleAxis + } else { + AppMsg::NoOp + } + } + KeyCode::Char('p') | KeyCode::Char('P') => { + if let AppView::Visualizer = self.view { + AppMsg::TogglePrimarySort + } else { + AppMsg::NoOp + } + } + KeyCode::Char('s') | KeyCode::Char('S') => { + if let AppView::Visualizer = self.view { + AppMsg::ToggleSecondarySort + } else { + AppMsg::NoOp + } + } _ => AppMsg::NoOp, }, } @@ -288,6 +415,55 @@ impl App { .send(VisualizerMsg::SetGain(self.visualizer.gain))?; Ok(()) } + AppMsg::ChangePalette(index) => { + if index < self.visualizer.palette_names.len() { + let palette_name = &self.visualizer.palette_names[index]; + if let Some(hues) = crate::palettes::get_palette(palette_name) { + self.visualizer.current_palette_index = index; + self.visualizer.tx.send(VisualizerMsg::SetPalette(hues))?; + } + } + Ok(()) + } + AppMsg::ToggleAxis => { + self.visualizer.primary_axis = match self.visualizer.primary_axis { + Axis::X => Axis::Y, + Axis::Y => Axis::X, + }; + self.visualizer.tx.send(VisualizerMsg::SetSorting { + primary_axis: self.visualizer.primary_axis, + sort_primary: self.visualizer.sort_primary, + sort_secondary: self.visualizer.sort_secondary, + global_orientation: self.visualizer.global_orientation, + })?; + Ok(()) + } + AppMsg::TogglePrimarySort => { + self.visualizer.sort_primary = match self.visualizer.sort_primary { + Sort::Asc => Sort::Desc, + Sort::Desc => Sort::Asc, + }; + self.visualizer.tx.send(VisualizerMsg::SetSorting { + primary_axis: self.visualizer.primary_axis, + sort_primary: self.visualizer.sort_primary, + sort_secondary: self.visualizer.sort_secondary, + global_orientation: self.visualizer.global_orientation, + })?; + Ok(()) + } + AppMsg::ToggleSecondarySort => { + self.visualizer.sort_secondary = match self.visualizer.sort_secondary { + Sort::Asc => Sort::Desc, + Sort::Desc => Sort::Asc, + }; + self.visualizer.tx.send(VisualizerMsg::SetSorting { + primary_axis: self.visualizer.primary_axis, + sort_primary: self.visualizer.sort_primary, + sort_secondary: self.visualizer.sort_secondary, + global_orientation: self.visualizer.global_orientation, + })?; + Ok(()) + } } } @@ -343,16 +519,71 @@ impl App { ); } AppView::Visualizer => { - frame.render_widget( - Paragraph::new(vec![ - Line::from("Music Visualizer".bold().cyan()), + let current_palette = if self.visualizer.current_palette_index + < self.visualizer.palette_names.len() + { + &self.visualizer.palette_names[self.visualizer.current_palette_index] + } else { + "Unknown" + }; + + let axis_str = match self.visualizer.primary_axis { + Axis::X => "X", + Axis::Y => "Y", + }; + let primary_str = match self.visualizer.sort_primary { + Sort::Asc => "Asc", + Sort::Desc => "Desc", + }; + let secondary_str = match self.visualizer.sort_secondary { + Sort::Asc => "Asc", + Sort::Desc => "Desc", + }; + + let mut lines = vec![ + Line::from("Music Visualizer".bold().cyan()), + Line::from(""), + Line::from(vec![ + "Amplitude gain: ".into(), + format!("{:.2}", self.visualizer.gain).blue(), + ]), + Line::from(vec!["Current palette: ".into(), current_palette.green()]), + Line::from(""), + Line::from("Panel Sorting:".bold()), + Line::from(vec![ + " Primary Axis [A]: ".into(), + axis_str.yellow(), + " | Primary [P]: ".into(), + primary_str.yellow(), + " | Secondary [S]: ".into(), + secondary_str.yellow(), + ]), + Line::from(""), + Line::from("Available Palettes (press number to switch):".bold()), + ]; + + for (i, palette_name) in self.visualizer.palette_names.iter().enumerate().take(10) { + let key = if i == 9 { + "0".to_string() + } else { + (i + 1).to_string() + }; + let is_current = i == self.visualizer.current_palette_index; + let line = if is_current { Line::from(vec![ - "Amplitude gain: ".into(), - format!("{:.2}", self.visualizer.gain).blue(), - ]), - ]) - .block(main_block) - .centered(), + key.bold().yellow(), + " - ".into(), + palette_name.as_str().green().bold(), + " ◀".green(), + ]) + } else { + Line::from(vec![key.bold(), " - ".into(), palette_name.as_str().into()]) + }; + lines.push(line); + } + + frame.render_widget( + Paragraph::new(lines).block(main_block).centered(), frame.area(), ); } @@ -367,7 +598,11 @@ impl App { Line::from(vec!["Enter".bold(), " - play selected effect".into()]), Line::from(vec!["V".bold(), " - toggle music visualizer mode".into()]), Line::from(vec!["-/+".bold(), " - decrease/increase gain (in visualizer mode)".into()]), - Line::from(vec!["(note that this doesn't affect your music volume, only the visuals are amplified)".italic()]), + Line::from(vec!["1-9, 0".bold(), " - switch color palette (in visualizer mode)".into()]), + Line::from(vec!["A".bold(), " - toggle primary axis X/Y (in visualizer mode)".into()]), + Line::from(vec!["P".bold(), " - toggle primary sort Asc/Desc (in visualizer mode)".into()]), + Line::from(vec!["S".bold(), " - toggle secondary sort Asc/Desc (in visualizer mode)".into()]), + Line::from(vec!["(note that gain doesn't affect your music volume, only the visuals are amplified)".italic()]), ]) .block(main_block) .centered(), diff --git a/src/audio.rs b/src/audio.rs index 7c1a908..b2edc18 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -15,12 +15,45 @@ impl AudioStream { None => constants::DEFAULT_AUDIO_BACKEND, }; let host = cpal::default_host(); + + // Try to find the device in input devices (for loopback/monitor devices) let device = match device_name { - constants::DEFAULT_AUDIO_BACKEND => host.default_input_device(), + constants::DEFAULT_AUDIO_BACKEND => { + // Check for common loopback device names first + let loopback_names = ["BlackHole", + "BlackHole 2ch", + "BlackHole 16ch", + "Loopback Audio", + "CABLE Output", + "VB-Audio", + "Monitor", + "monitor"]; + + let mut loopback_device = None; + if let Ok(devices) = host.input_devices() { + for device in devices { + if let Ok(name) = device.name() { + if loopback_names.iter().any(|lb| name.contains(lb)) { + eprintln!("INFO: Found loopback device: {}", name); + loopback_device = Some(device); + break; + } + } + } + } + + // Fall back to default input if no loopback found + loopback_device.or_else(|| { + eprintln!("WARNING: No loopback device found. Using default input device (microphone)."); + eprintln!("To capture system audio on macOS, install BlackHole: https://github.com/ExistentialAudio/BlackHole"); + host.default_input_device() + }) + } _ => host .input_devices()? .find(|x| x.name().map(|y| y == device_name).unwrap_or(false)), }; + let Some(device) = device else { bail!(format!( "Audio backend `{}` not found, available options: {}", diff --git a/src/config.rs b/src/config.rs index 8d9c50f..3432fea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,30 @@ pub struct CliOptions { /// Explicitly add a new Nanoleaf device #[arg(short = 'n', long = "new")] pub add_new: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Parser, Debug)] +pub enum Command { + /// Dump information from device or configuration + Dump { + #[command(subcommand)] + dump_type: DumpType, + }, +} + +#[derive(Parser, Debug)] +pub enum DumpType { + /// Dump panel layout information from the device + Layout, + /// Dump available color palettes + Palettes, + /// Dump device info from /api/v1/ endpoint (no auth required) + Info, + /// Show graphical panel layout visualization + LayoutGraphical, } #[derive(Debug, Serialize)] @@ -50,7 +74,7 @@ pub enum Axis { Y, } -#[derive(Debug, Default, Serialize)] +#[derive(Copy, Clone, Debug, Default, Serialize)] pub enum Sort { #[default] Asc, @@ -63,7 +87,7 @@ pub struct VisualizerConfig { pub freq_range: Option<(u16, u16)>, pub hues: Option>, pub default_gain: Option, - pub transition_time: Option, + pub transition_time: Option, pub time_window: Option, pub primary_axis: Option, pub sort_primary: Option, @@ -178,7 +202,11 @@ impl Config { visualizer_config.default_gain = Some(x as f32); } ("transition_time", Value::Integer(x)) => { - visualizer_config.transition_time = Some(u16::try_from(x)?); + let trans_time = i16::try_from(x)?; + if trans_time < -1 { + bail!("transition_time must be -1 (instant) or a positive value. Note: units are in 100ms (1 = 100ms, 2 = 200ms, etc.)"); + } + visualizer_config.transition_time = Some(trans_time); } ("time_window", Value::Float(x)) => { visualizer_config.time_window = Some(x as f32); diff --git a/src/constants.rs b/src/constants.rs index 535be32..4017977 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -6,7 +6,10 @@ pub const DEFAULT_AUDIO_BACKEND: &str = "default"; pub const DEFAULT_FREQ_RANGE: (u16, u16) = (20, 4500); pub const DEFAULT_HUES: [u16; 12] = [50, 30, 10, 350, 330, 310, 290, 270, 250, 230, 210, 190]; pub const DEFAULT_GAIN: f32 = 1.0; -pub const DEFAULT_TRANSITION_TIME: u16 = 2; +// Transition time in units of 100ms (1 = 100ms, 2 = 200ms, etc.) +// -1 is special: instant transition (start frame), used to avoid lengthy initial transitions +// Recommended: Use values that align with your time_window for smooth transitions +pub const DEFAULT_TRANSITION_TIME: i16 = 2; pub const DEFAULT_TIME_WINDOW: f32 = 0.1875; // other diff --git a/src/graphical_layout.rs b/src/graphical_layout.rs new file mode 100644 index 0000000..862b3b1 --- /dev/null +++ b/src/graphical_layout.rs @@ -0,0 +1,395 @@ +use crate::layout_visualizer::PanelInfo; +use crate::nanoleaf::NlDevice; +use macroquad::prelude::*; +use palette::Hwb; +use std::f32::consts::PI; +use std::thread; +use std::time::Duration; + +pub fn visualize_graphical(panels: Vec, global_orientation: u16, device: NlDevice) { + // Run the visualization synchronously + pollster::block_on(visualize_async(panels, global_orientation, device)); +} + +async fn visualize_async(panels: Vec, global_orientation: u16, device: NlDevice) { + // Initialize macroquad window + macroquad::Window::new("Nanoleaf Panel Layout", async move { + visualize_loop(panels, global_orientation, device).await; + }); +} + +async fn visualize_loop(panels: Vec, global_orientation: u16, device: NlDevice) { + // Find bounds + let min_x = panels.iter().map(|p| p.x).min().unwrap_or(0) as f32; + let max_x = panels.iter().map(|p| p.x).max().unwrap_or(0) as f32; + let min_y = panels.iter().map(|p| p.y).min().unwrap_or(0) as f32; + let max_y = panels.iter().map(|p| p.y).max().unwrap_or(0) as f32; + + let layout_width = max_x - min_x; + let layout_height = max_y - min_y; + + // Window configuration + let window_width = 1200.0; + let window_height = 800.0; + + // Calculate scale to fit layout in window with padding + let padding_top = 100.0; // Extra space at top for title + let padding_bottom = 50.0; + let padding_sides = 50.0; + let available_width = window_width - 2.0 * padding_sides; + let available_height = window_height - padding_top - padding_bottom; + + let scale_x = available_width / layout_width; + let scale_y = available_height / layout_height; + let scale = scale_x.min(scale_y); + + // Setup Nanoleaf controller for sending commands + let nl_controller = match crate::nanoleaf::NlUdp::new(&device) { + Ok(controller) => Some(controller), + Err(e) => { + eprintln!("Warning: Could not initialize Nanoleaf controller: {}", e); + None + } + }; + + loop { + clear_background(Color::from_rgba(20, 20, 30, 255)); + + // Draw title + draw_text( + &format!( + "Nanoleaf Panel Layout - Global Orientation: {}°", + global_orientation + ), + 10.0, + 30.0, + 30.0, + WHITE, + ); + + // Center the layout in the window horizontally, offset from top + let offset_x = (window_width - layout_width * scale) / 2.0; + let offset_y = padding_top + (available_height - layout_height * scale) / 2.0; + + // First pass: calculate all transformed positions + let mut transformed_positions = Vec::new(); + for panel in &panels { + // Apply global orientation rotation to coordinates + let rel_x = (panel.x as f32 - min_x) - layout_width / 2.0; + let rel_y = (panel.y as f32 - min_y) - layout_height / 2.0; + + let angle = -(global_orientation as f32).to_radians(); // Negative for clockwise + let rotated_x = rel_x * angle.cos() - rel_y * angle.sin(); + let rotated_y = rel_x * angle.sin() + rel_y * angle.cos(); + + // Convert to screen coordinates + let screen_x = offset_x + (rotated_x + layout_width / 2.0) * scale; + let screen_y = offset_y + (layout_height / 2.0 - rotated_y) * scale; // Flip Y + + transformed_positions.push((screen_x, screen_y)); + } + + // Second pass: draw all panels with access to all positions + for (i, panel) in panels.iter().enumerate() { + let (screen_x, screen_y) = transformed_positions[i]; + draw_panel( + screen_x, + screen_y, + panel, + scale, + &panels, + &transformed_positions, + ); + } + + // Handle mouse clicks + if is_mouse_button_pressed(MouseButton::Left) && nl_controller.is_some() { + let (mouse_x, mouse_y) = mouse_position(); + + // Check which panel was clicked + for (i, panel) in panels.iter().enumerate() { + if panel.shape_type.side_length < 1.0 { + continue; // Skip controllers + } + + let (screen_x, screen_y) = transformed_positions[i]; + let num_sides = panel.shape_type.num_sides(); + let side_length = panel.shape_type.side_length * scale; + + let radius = if num_sides == 3 { + side_length / f32::sqrt(3.0) + } else if num_sides == 4 { + side_length / f32::sqrt(2.0) + } else { + side_length + }; + + // Simple distance check for click detection + let dist = ((mouse_x - screen_x).powi(2) + (mouse_y - screen_y).powi(2)).sqrt(); + if dist < radius * 1.2 { + // Found the clicked panel - flash it + if let Some(ref controller) = nl_controller { + flash_panel(controller, &panels, panel.panel_id); + } + break; + } + } + } + + // Instructions + draw_text( + "Press ESC to close | Click panels to flash them", + 10.0, + window_height - 20.0, + 20.0, + GRAY, + ); + + if is_key_pressed(KeyCode::Escape) { + break; + } + + next_frame().await + } +} + +fn draw_panel( + x: f32, + y: f32, + panel: &PanelInfo, + scale: f32, + all_panels: &[PanelInfo], + transformed_positions: &[(f32, f32)], +) { + // Handle controllers specially (they have side_length 0) + if panel.shape_type.side_length < 1.0 { + // Find the nearest panel to attach to + let mut min_dist = f32::MAX; + let mut nearest_idx = 0; + + for (i, other_panel) in all_panels.iter().enumerate() { + if other_panel.shape_type.side_length >= 1.0 { + let (other_x, other_y) = transformed_positions[i]; + let dist = ((x - other_x).powi(2) + (y - other_y).powi(2)).sqrt(); + if dist < min_dist { + min_dist = dist; + nearest_idx = i; + } + } + } + + let (parent_x, parent_y) = transformed_positions[nearest_idx]; + let parent_panel = &all_panels[nearest_idx]; + + // Calculate angle from parent to controller + let dx = x - parent_x; + let dy = y - parent_y; + let angle_to_controller = dy.atan2(dx); + + // Get parent shape info + let num_sides = parent_panel.shape_type.num_sides(); + let parent_side_length = parent_panel.shape_type.side_length * scale; + + // Calculate parent radius + let parent_radius = if num_sides == 3 { + parent_side_length / f32::sqrt(3.0) + } else if num_sides == 4 { + parent_side_length / f32::sqrt(2.0) + } else { + parent_side_length + }; + + // Find which edge of the parent the controller is closest to + let parent_orientation = (parent_panel.orientation as f32).to_radians(); + let angle_per_side = 2.0 * PI / num_sides as f32; + + let mut closest_edge = 0; + let mut min_angle_diff = f32::MAX; + + for i in 0..num_sides { + let vertex_angle = parent_orientation + (i as f32 * angle_per_side); + let angle_diff = ((angle_to_controller - vertex_angle).abs() % (2.0 * PI)) + .min((2.0 * PI) - ((angle_to_controller - vertex_angle).abs() % (2.0 * PI))); + if angle_diff < min_angle_diff { + min_angle_diff = angle_diff; + closest_edge = i; + } + } + + // Calculate the two vertices of the edge + let v1_angle = parent_orientation + (closest_edge as f32 * angle_per_side); + let v2_angle = parent_orientation + ((closest_edge + 1) as f32 * angle_per_side); + + let v1_x = parent_x + parent_radius * v1_angle.cos(); + let v1_y = parent_y + parent_radius * v1_angle.sin(); + let v2_x = parent_x + parent_radius * v2_angle.cos(); + let v2_y = parent_y + parent_radius * v2_angle.sin(); + + // Draw trapezoid attached to this edge + let trapezoid_height = 20.0; + + // Calculate perpendicular direction (outward from parent) + let edge_mid_x = (v1_x + v2_x) / 2.0; + let edge_mid_y = (v1_y + v2_y) / 2.0; + let perp_dx = edge_mid_x - parent_x; + let perp_dy = edge_mid_y - parent_y; + let perp_len = (perp_dx * perp_dx + perp_dy * perp_dy).sqrt(); + let perp_norm_x = perp_dx / perp_len; + let perp_norm_y = perp_dy / perp_len; + + // Trapezoid vertices: top edge matches parent edge, bottom edge is narrower + let narrow_ratio = 0.6; // Bottom edge is 60% of top edge + + let vertices = [ + Vec2::new(v1_x, v1_y), // Top left (on parent edge) + Vec2::new(v2_x, v2_y), // Top right (on parent edge) + // Bottom right (narrower, extended outward) + Vec2::new( + v2_x + perp_norm_x * trapezoid_height - (v2_x - edge_mid_x) * (1.0 - narrow_ratio), + v2_y + perp_norm_y * trapezoid_height - (v2_y - edge_mid_y) * (1.0 - narrow_ratio), + ), + // Bottom left (narrower, extended outward) + Vec2::new( + v1_x + perp_norm_x * trapezoid_height - (v1_x - edge_mid_x) * (1.0 - narrow_ratio), + v1_y + perp_norm_y * trapezoid_height - (v1_y - edge_mid_y) * (1.0 - narrow_ratio), + ), + ]; + + // Draw filled trapezoid + draw_triangle( + vertices[0], + vertices[1], + vertices[2], + Color::from_rgba(255, 200, 0, 255), + ); + draw_triangle( + vertices[0], + vertices[2], + vertices[3], + Color::from_rgba(255, 200, 0, 255), + ); + + // Draw outline + for i in 0..vertices.len() { + let next = (i + 1) % vertices.len(); + draw_line( + vertices[i].x, + vertices[i].y, + vertices[next].x, + vertices[next].y, + 2.0, + Color::from_rgba(200, 150, 0, 255), + ); + } + + // Draw "C" label in center of trapezoid + let text_size = 10.0; + let text_dims = measure_text("C", None, text_size as u16, 1.0); + let label_x = (vertices[0].x + vertices[1].x + vertices[2].x + vertices[3].x) / 4.0; + let label_y = (vertices[0].y + vertices[1].y + vertices[2].y + vertices[3].y) / 4.0; + draw_text( + "C", + label_x - text_dims.width / 2.0, + label_y + text_size / 3.0, + text_size, + BLACK, + ); + return; + } + + let num_sides = panel.shape_type.num_sides(); + let side_length = panel.shape_type.side_length * scale; + + // Calculate radius from center to vertex + let radius = if num_sides == 3 { + side_length / f32::sqrt(3.0) + } else if num_sides == 4 { + side_length / f32::sqrt(2.0) + } else { + side_length + }; + + // Calculate vertices + let start_angle = (panel.orientation as f32).to_radians(); + let mut vertices = Vec::new(); + + for i in 0..num_sides { + let angle = start_angle + (i as f32 * 2.0 * PI / num_sides as f32); + let vx = x + radius * angle.cos(); + let vy = y + radius * angle.sin(); + vertices.push(Vec2::new(vx, vy)); + } + + // Choose color based on shape type + let color = match panel.shape_type.id { + 0 | 8 | 9 => Color::from_rgba(255, 100, 100, 200), + 2..=4 => Color::from_rgba(100, 255, 100, 200), + 7 | 14 | 15 => Color::from_rgba(100, 150, 255, 200), + 30..=32 => Color::from_rgba(255, 255, 100, 200), + _ => Color::from_rgba(150, 150, 150, 200), + }; + + // Draw filled polygon + for i in 1..(num_sides - 1) { + draw_triangle(vertices[0], vertices[i], vertices[i + 1], color); + } + + // Draw outline + for i in 0..num_sides { + let next = (i + 1) % num_sides; + draw_line( + vertices[i].x, + vertices[i].y, + vertices[next].x, + vertices[next].y, + 2.0, + WHITE, + ); + } + + // Draw panel ID in center + let id_text = format!("{}", panel.panel_id); + let text_size = 16.0; + let text_dims = measure_text(&id_text, None, text_size as u16, 1.0); + draw_text( + &id_text, + x - text_dims.width / 2.0, + y + text_size / 3.0, + text_size, + BLACK, + ); +} + +fn flash_panel( + controller: &crate::nanoleaf::NlUdp, + all_panels: &[PanelInfo], + clicked_panel_id: u16, +) { + // Create color array - white for clicked panel, black for all others + // Only include actual light panels (skip controllers with side_length < 1.0) + let colors: Vec = all_panels + .iter() + .filter(|panel| panel.shape_type.side_length >= 1.0) + .map(|panel| { + if panel.panel_id == clicked_panel_id { + Hwb::new(0.0, 1.0, 0.0) // White + } else { + Hwb::new(0.0, 0.0, 1.0) // Black + } + }) + .collect(); + + // Flash on + let _ = controller.update_panels(&colors, 1); + + // Brief delay + thread::sleep(Duration::from_millis(300)); + + // Flash off - set all panels to black to return to normal + let black_colors: Vec = all_panels + .iter() + .filter(|panel| panel.shape_type.side_length >= 1.0) + .map(|_| Hwb::new(0.0, 0.0, 1.0)) + .collect(); + let _ = controller.update_panels(&black_colors, 1); +} diff --git a/src/layout_visualizer.rs b/src/layout_visualizer.rs new file mode 100644 index 0000000..f0f7b6a --- /dev/null +++ b/src/layout_visualizer.rs @@ -0,0 +1,203 @@ +use anyhow::Result; +use serde_json::Value; + +#[derive(Debug, Clone, Copy)] +pub struct ShapeType { + pub id: u64, + pub name: &'static str, + pub side_length: f32, +} + +impl ShapeType { + pub fn from_id(id: u64) -> Self { + match id { + 0 => ShapeType { + id, + name: "Triangle", + side_length: 150.0, + }, + 1 => ShapeType { + id, + name: "Rhythm", + side_length: 0.0, + }, + 2 => ShapeType { + id, + name: "Square", + side_length: 100.0, + }, + 3 => ShapeType { + id, + name: "Control Square Master", + side_length: 100.0, + }, + 4 => ShapeType { + id, + name: "Control Square Passive", + side_length: 100.0, + }, + 7 => ShapeType { + id, + name: "Hexagon (Shapes)", + side_length: 67.0, + }, + 8 => ShapeType { + id, + name: "Triangle (Shapes)", + side_length: 134.0, + }, + 9 => ShapeType { + id, + name: "Mini Triangle (Shapes)", + side_length: 67.0, + }, + 12 => ShapeType { + id, + name: "Shapes Controller", + side_length: 0.0, + }, + 14 => ShapeType { + id, + name: "Elements Hexagons", + side_length: 134.0, + }, + 15 => ShapeType { + id, + name: "Elements Hexagons - Corner", + side_length: 45.75, + }, + 16 => ShapeType { + id, + name: "Lines Connector", + side_length: 11.0, + }, + 17 => ShapeType { + id, + name: "Light Lines", + side_length: 154.0, + }, + 18 => ShapeType { + id, + name: "Light Lines - Single Zone", + side_length: 77.0, + }, + 19 => ShapeType { + id, + name: "Controller Cap", + side_length: 11.0, + }, + 20 => ShapeType { + id, + name: "Power Connector", + side_length: 11.0, + }, + 29 => ShapeType { + id, + name: "Nanoleaf 4D Lightstrip", + side_length: 50.0, + }, + 30 => ShapeType { + id, + name: "Skylight Panel", + side_length: 180.0, + }, + 31 => ShapeType { + id, + name: "Skylight Controller Primary", + side_length: 180.0, + }, + 32 => ShapeType { + id, + name: "Skylight Controller Passive Mode", + side_length: 180.0, + }, + _ => ShapeType { + id, + name: "Unknown", + side_length: 100.0, + }, + } + } + + pub fn num_sides(&self) -> usize { + match self.id { + 0 | 8 | 9 => 3, // Triangles + 2..=4 => 4, // Squares + 7 | 14 | 15 => 6, // Hexagons + _ => 4, // Default to square + } + } +} + +#[derive(Debug)] +pub struct PanelInfo { + pub panel_id: u16, + pub x: i16, + pub y: i16, + pub orientation: u16, + pub shape_type: ShapeType, +} + +pub fn parse_layout(layout_json: &Value) -> Result> { + let position_data = layout_json["positionData"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("No positionData in layout"))?; + + let mut panels = Vec::new(); + + for panel_data in position_data { + let panel_id = panel_data["panelId"].as_u64().unwrap_or(0) as u16; + let x = panel_data["x"].as_i64().unwrap_or(0) as i16; + let y = panel_data["y"].as_i64().unwrap_or(0) as i16; + let orientation = panel_data["o"].as_u64().unwrap_or(0) as u16; + let shape_type_id = panel_data["shapeType"].as_u64().unwrap_or(0); + let shape_type = ShapeType::from_id(shape_type_id); + + panels.push(PanelInfo { + panel_id, + x, + y, + orientation, + shape_type, + }); + } + + Ok(panels) +} + +pub fn visualize_layout(panels: &[PanelInfo], global_orientation: u16) { + if panels.is_empty() { + println!("No panels to visualize"); + return; + } + + // Find bounds + let min_x = panels.iter().map(|p| p.x).min().unwrap(); + let max_x = panels.iter().map(|p| p.x).max().unwrap(); + let min_y = panels.iter().map(|p| p.y).min().unwrap(); + let max_y = panels.iter().map(|p| p.y).max().unwrap(); + + println!("\n=== Panel Layout Visualization ==="); + println!("Global Orientation: {} degrees", global_orientation); + println!("Bounds: X[{}, {}], Y[{}, {}]", min_x, max_x, min_y, max_y); + println!("\nPanels:"); + println!( + "{:<8} {:<30} {:<10} {:<10} {:<12} {:<12}", + "Panel ID", "Shape Type", "X", "Y", "Orientation", "Side Length" + ); + println!("{}", "-".repeat(100)); + + for panel in panels { + println!( + "{:<8} {:<30} {:<10} {:<10} {:<12} {:<12.1}", + panel.panel_id, + panel.shape_type.name, + panel.x, + panel.y, + format!("{}°", panel.orientation), + panel.shape_type.side_length + ); + } + + println!("\nNote: Use 'dump layout-graphical' for a visual representation"); +} diff --git a/src/main.rs b/src/main.rs index 5c9e2ce..495023b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ mod audio; mod config; mod constants; mod event_handler; +mod graphical_layout; +mod layout_visualizer; mod nanoleaf; mod palettes; mod panic; @@ -15,13 +17,25 @@ mod utils; mod visualizer; fn main() -> Result<()> { + pollster::block_on(main_async()) +} + +async fn main_async() -> Result<()> { panic::register_backtrace_panic_handler(); + let cli_options = config::CliOptions::parse(); + + // Handle dump commands separately - they don't need TUI + if let Some(config::Command::Dump { dump_type }) = &cli_options.command { + return handle_dump_command(dump_type, &cli_options).await; + } + let config::CliOptions { config_file_path, devices_file_path, device_name, add_new, - } = config::CliOptions::parse(); + .. + } = cli_options; let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = config::resolve_paths(config_file_path, devices_file_path)?; let (nl_device, tui_config, visualizer_config) = if !add_new && devices_file_exists { @@ -56,9 +70,149 @@ fn main() -> Result<()> { config.write_to_file(&config_file_path)?; (nl_device, config.tui_config, config.visualizer_config) }; + + // Ensure device is powered on and has brightness set + nl_device.ensure_device_ready()?; + let mut app = app::App::new(nl_device, tui_config, visualizer_config)?; let mut terminal = utils::init_tui()?; app.run(&mut terminal)?; utils::destroy_tui()?; Ok(()) } + +async fn handle_dump_command( + dump_type: &config::DumpType, + cli_options: &config::CliOptions, +) -> Result<()> { + match dump_type { + config::DumpType::Layout => { + // Need to connect to device for layout + let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = + config::resolve_paths( + cli_options.config_file_path.clone(), + cli_options.devices_file_path.clone(), + )?; + + if !devices_file_exists { + anyhow::bail!("No devices file found. Please add a device first."); + } + + let nl_device = if config_file_exists { + let config = config::Config::parse_from_file(&config_file_path)?; + let name_to_search = if cli_options.device_name.is_some() { + &cli_options.device_name + } else { + &config.default_nl_device_name + }; + nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())? + } else { + nanoleaf::NlDevice::find_in_file( + &devices_file_path, + cli_options.device_name.as_deref(), + )? + }; + + println!("Panel Layout Information for: {}", nl_device.name); + println!("Device IP: {}", nl_device.ip); + + let layout = nl_device.get_panel_layout()?; + let orientation = nl_device.get_global_orientation()?; + let global_orientation = orientation["value"].as_u64().unwrap_or(0) as u16; + + // Parse and visualize the layout + let panels = layout_visualizer::parse_layout(&layout)?; + layout_visualizer::visualize_layout(&panels, global_orientation); + + println!("\n=== Raw Panel Layout JSON ==="); + println!("{}", serde_json::to_string_pretty(&layout)?); + + println!("\n=== Raw Global Orientation JSON ==="); + println!("{}", serde_json::to_string_pretty(&orientation)?); + + Ok(()) + } + config::DumpType::Palettes => { + println!("Available Color Palettes:\n"); + let palette_names = palettes::get_palette_names(); + for name in palette_names { + let hues = palettes::get_palette(&name).unwrap(); + println!(" {} = {:?}", name, hues); + } + Ok(()) + } + config::DumpType::LayoutGraphical => { + // Need to connect to device for layout + let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = + config::resolve_paths( + cli_options.config_file_path.clone(), + cli_options.devices_file_path.clone(), + )?; + + if !devices_file_exists { + anyhow::bail!("No devices file found. Please add a device first."); + } + + let nl_device = if config_file_exists { + let config = config::Config::parse_from_file(&config_file_path)?; + let name_to_search = if cli_options.device_name.is_some() { + &cli_options.device_name + } else { + &config.default_nl_device_name + }; + nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())? + } else { + nanoleaf::NlDevice::find_in_file( + &devices_file_path, + cli_options.device_name.as_deref(), + )? + }; + + let layout = nl_device.get_panel_layout()?; + let orientation = nl_device.get_global_orientation()?; + let global_orientation = orientation["value"].as_u64().unwrap_or(0) as u16; + + let panels = layout_visualizer::parse_layout(&layout)?; + + // Call the graphical visualizer - it has its own macroquad::main wrapper + graphical_layout::visualize_graphical(panels, global_orientation, nl_device); + + Ok(()) + } + config::DumpType::Info => { + // Need to connect to device for info + let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = + config::resolve_paths( + cli_options.config_file_path.clone(), + cli_options.devices_file_path.clone(), + )?; + + if !devices_file_exists { + anyhow::bail!("No devices file found. Please add a device first."); + } + + let nl_device = if config_file_exists { + let config = config::Config::parse_from_file(&config_file_path)?; + let name_to_search = if cli_options.device_name.is_some() { + &cli_options.device_name + } else { + &config.default_nl_device_name + }; + nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())? + } else { + nanoleaf::NlDevice::find_in_file( + &devices_file_path, + cli_options.device_name.as_deref(), + )? + }; + + println!("Device Information for: {}", nl_device.name); + println!("Device IP: {}", nl_device.ip); + println!("\n=== Device Info (from /api/v1/) ==="); + let info = nl_device.get_device_info()?; + println!("{}", serde_json::to_string_pretty(&info)?); + + Ok(()) + } + } +} diff --git a/src/nanoleaf.rs b/src/nanoleaf.rs index 9081d43..894c2a6 100644 --- a/src/nanoleaf.rs +++ b/src/nanoleaf.rs @@ -101,7 +101,7 @@ impl NlDevice { } } - pub fn get_panels(&self) -> Result> { + pub fn get_panel_layout(&self) -> Result { let Ok(res) = utils::request_get(&format!( "http://{}:{}/api/v1/{}/panelLayout/layout", self.ip, @@ -111,6 +111,108 @@ impl NlDevice { bail!(utils::generate_connection_error_msg(&self.ip)); }; let res_json: serde_json::Value = serde_json::from_str(&res)?; + Ok(res_json) + } + + pub fn get_global_orientation(&self) -> Result { + let Ok(res) = utils::request_get(&format!( + "http://{}:{}/api/v1/{}/panelLayout/globalOrientation", + self.ip, + constants::NL_API_PORT, + self.token + )) else { + bail!(utils::generate_connection_error_msg(&self.ip)); + }; + let res_json: serde_json::Value = serde_json::from_str(&res)?; + Ok(res_json) + } + + pub fn get_device_info(&self) -> Result { + let Ok(res) = utils::request_get(&format!( + "http://{}:{}/api/v1/{}/", + self.ip, + constants::NL_API_PORT, + self.token + )) else { + bail!(utils::generate_connection_error_msg(&self.ip)); + }; + let res_json: serde_json::Value = serde_json::from_str(&res)?; + Ok(res_json) + } + + pub fn set_state(&self, power_on: Option, brightness: Option) -> Result<()> { + let mut state = serde_json::Map::new(); + + if let Some(on) = power_on { + let mut on_obj = serde_json::Map::new(); + on_obj.insert("value".to_string(), serde_json::Value::Bool(on)); + state.insert("on".to_string(), serde_json::Value::Object(on_obj)); + } + + if let Some(brightness_val) = brightness { + let mut brightness_obj = serde_json::Map::new(); + brightness_obj.insert( + "value".to_string(), + serde_json::Value::Number(brightness_val.into()), + ); + state.insert( + "brightness".to_string(), + serde_json::Value::Object(brightness_obj), + ); + } + + if state.is_empty() { + return Ok(()); + } + + let data = serde_json::Value::Object(state); + let Ok(_) = utils::request_put( + &format!( + "http://{}:{}/api/v1/{}/state", + self.ip, + constants::NL_API_PORT, + self.token + ), + Some(&data), + ) else { + bail!(utils::generate_connection_error_msg(&self.ip)); + }; + Ok(()) + } + + pub fn ensure_device_ready(&self) -> Result<()> { + let info = self.get_device_info()?; + + let is_on = info["state"]["on"]["value"].as_bool().unwrap_or(true); + let brightness = info["state"]["brightness"]["value"].as_u64().unwrap_or(100) as u8; + + let mut needs_power = false; + let mut needs_brightness = false; + + if !is_on { + eprintln!("Device is off. Turning on..."); + needs_power = true; + } + + if brightness == 0 { + eprintln!("Device brightness is 0. Setting to 100..."); + needs_brightness = true; + } + + if needs_power || needs_brightness { + self.set_state( + if needs_power { Some(true) } else { None }, + if needs_brightness { Some(100) } else { None }, + )?; + // Give the device a moment to respond to the state change + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + Ok(()) + } + + pub fn get_panels(&self) -> Result> { + let res_json = self.get_panel_layout()?; let res_panels = res_json["positionData"].as_array().unwrap(); let mut panels = Vec::new(); for panel in res_panels.iter() { @@ -288,49 +390,112 @@ impl NlUdp { }) } - pub fn sort_panels( + pub fn sort_panels_with_orientation( &mut self, primary_axis: Option, primary_sort: Option, secondary_sort: Option, + global_orientation: u16, ) { let primary_axis = primary_axis.unwrap_or_default(); let primary_sort = primary_sort.unwrap_or_default(); let secondary_sort = secondary_sort.unwrap_or_default(); + + // Apply global orientation rotation to coordinates if needed + let angle = -(global_orientation as f32).to_radians(); + let needs_rotation = global_orientation != 0; + let sort_func = match primary_axis { Axis::X => match (primary_sort, secondary_sort) { - (Sort::Asc, Sort::Asc) => { - |lhs: &Panel, rhs: &Panel| (lhs.x, lhs.y).cmp(&(rhs.x, rhs.y)) - } - (Sort::Asc, Sort::Desc) => { - |lhs: &Panel, rhs: &Panel| (lhs.x, -lhs.y).cmp(&(rhs.x, -rhs.y)) - } - (Sort::Desc, Sort::Asc) => { - |lhs: &Panel, rhs: &Panel| (-lhs.x, lhs.y).cmp(&(-rhs.x, rhs.y)) - } - (Sort::Desc, Sort::Desc) => { - |lhs: &Panel, rhs: &Panel| (-lhs.x, -lhs.y).cmp(&(-rhs.x, -rhs.y)) - } + (Sort::Asc, Sort::Asc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (lx, ly).partial_cmp(&(rx, ry)).unwrap() + } else { + (lhs.x, lhs.y).cmp(&(rhs.x, rhs.y)) + } + }, + (Sort::Asc, Sort::Desc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (lx, -ly).partial_cmp(&(rx, -ry)).unwrap() + } else { + (lhs.x, -lhs.y).cmp(&(rhs.x, -rhs.y)) + } + }, + (Sort::Desc, Sort::Asc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (-lx, ly).partial_cmp(&(-rx, ry)).unwrap() + } else { + (-lhs.x, lhs.y).cmp(&(-rhs.x, rhs.y)) + } + }, + (Sort::Desc, Sort::Desc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (-lx, -ly).partial_cmp(&(-rx, -ry)).unwrap() + } else { + (-lhs.x, -lhs.y).cmp(&(-rhs.x, -rhs.y)) + } + }, }, Axis::Y => match (primary_sort, secondary_sort) { - (Sort::Asc, Sort::Asc) => { - |lhs: &Panel, rhs: &Panel| (lhs.y, lhs.x).cmp(&(rhs.y, rhs.x)) - } - (Sort::Asc, Sort::Desc) => { - |lhs: &Panel, rhs: &Panel| (lhs.y, -lhs.x).cmp(&(rhs.y, -rhs.x)) - } - (Sort::Desc, Sort::Asc) => { - |lhs: &Panel, rhs: &Panel| (-lhs.y, lhs.x).cmp(&(-rhs.y, rhs.x)) - } - (Sort::Desc, Sort::Desc) => { - |lhs: &Panel, rhs: &Panel| (-lhs.y, -lhs.x).cmp(&(-rhs.y, -rhs.x)) - } + (Sort::Asc, Sort::Asc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (ly, lx).partial_cmp(&(ry, rx)).unwrap() + } else { + (lhs.y, lhs.x).cmp(&(rhs.y, rhs.x)) + } + }, + (Sort::Asc, Sort::Desc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (ly, -lx).partial_cmp(&(ry, -rx)).unwrap() + } else { + (lhs.y, -lhs.x).cmp(&(rhs.y, -rhs.x)) + } + }, + (Sort::Desc, Sort::Asc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (-ly, lx).partial_cmp(&(-ry, rx)).unwrap() + } else { + (-lhs.y, lhs.x).cmp(&(-rhs.y, rhs.x)) + } + }, + (Sort::Desc, Sort::Desc) => |lhs: &Panel, rhs: &Panel, angle: f32, rotate: bool| { + if rotate { + let (lx, ly) = Self::rotate_coords(lhs.x, lhs.y, angle); + let (rx, ry) = Self::rotate_coords(rhs.x, rhs.y, angle); + (-ly, -lx).partial_cmp(&(-ry, -rx)).unwrap() + } else { + (-lhs.y, -lhs.x).cmp(&(-rhs.y, -rhs.x)) + } + }, }, }; - self.panels.sort_by(|a: &Panel, b: &Panel| sort_func(a, b)); + self.panels + .sort_by(|a: &Panel, b: &Panel| sort_func(a, b, angle, needs_rotation)); + } + + fn rotate_coords(x: i16, y: i16, angle: f32) -> (i32, i32) { + let x_f = x as f32; + let y_f = y as f32; + let rotated_x = (x_f * angle.cos() - y_f * angle.sin()).round() as i32; + let rotated_y = (x_f * angle.sin() + y_f * angle.cos()).round() as i32; + (rotated_x, rotated_y) } - pub fn update_panels(&self, colors: &[palette::Hwb], trans_time: u16) -> Result<()> { + pub fn update_panels(&self, colors: &[palette::Hwb], trans_time: i16) -> Result<()> { let mut buf = vec![0; 8 * self.panels.len() + 2]; (buf[0], buf[1]) = utils::split_into_bytes(self.panels.len() as u16); for (i, color) in colors.iter().enumerate() { @@ -348,7 +513,14 @@ impl NlUdp { buf[offset + 4], buf[offset + 5], ) = (r, g, b, 0); - (buf[offset + 6], buf[offset + 7]) = utils::split_into_bytes(trans_time); + // Convert i16 to bytes (supporting -1 for instant transition) + // trans_time is in units of 100ms: 1 = 100ms, 2 = 200ms, -1 = instant + let trans_time_u16 = if trans_time == -1 { + 0xFFFF // -1 as u16 (two's complement) + } else { + trans_time as u16 + }; + (buf[offset + 6], buf[offset + 7]) = utils::split_into_bytes(trans_time_u16); } self.socket.send(&buf)?; diff --git a/src/visualizer.rs b/src/visualizer.rs index ae63652..85c578d 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -27,6 +27,13 @@ pub enum VisualizerMsg { Resume, End, SetGain(f32), + SetPalette(Vec), + SetSorting { + primary_axis: crate::config::Axis, + sort_primary: crate::config::Sort, + sort_secondary: crate::config::Sort, + global_orientation: u16, + }, } pub struct Visualizer { @@ -35,7 +42,7 @@ pub struct Visualizer { audio_stream: AudioStream, gain: f32, time_window: f32, - trans_time: u16, + trans_time: i16, min_freq: u16, max_freq: u16, hues: Vec, @@ -49,10 +56,19 @@ impl Visualizer { ) -> Result { let state = VisualizerState::default(); let mut nl_udp = nanoleaf::NlUdp::new(nl_device)?; - nl_udp.sort_panels( + + // Get global orientation and apply it when sorting panels + let global_orientation = nl_device + .get_global_orientation() + .ok() + .and_then(|o| o["value"].as_u64()) + .unwrap_or(0) as u16; + + nl_udp.sort_panels_with_orientation( config.primary_axis, config.sort_primary, config.sort_secondary, + global_orientation, ); let gain = config.default_gain.unwrap_or(constants::DEFAULT_GAIN); let time_window = config.time_window.unwrap_or(constants::DEFAULT_TIME_WINDOW); @@ -74,12 +90,30 @@ impl Visualizer { }) } - fn update_state(&mut self, event: VisualizerMsg) { + fn update_state(&mut self, event: VisualizerMsg, colors: &mut Vec) { match event { VisualizerMsg::Resume => self.state = VisualizerState::Running, VisualizerMsg::Pause => self.state = VisualizerState::Paused, VisualizerMsg::End => self.state = VisualizerState::Done, VisualizerMsg::SetGain(gain) => self.gain = gain, + VisualizerMsg::SetPalette(hues) => { + self.hues = hues; + *colors = utils::colors_from_hues(&self.hues, colors.len()); + } + VisualizerMsg::SetSorting { + primary_axis, + sort_primary, + sort_secondary, + global_orientation, + } => { + self.nl_udp.sort_panels_with_orientation( + Some(primary_axis), + Some(sort_primary), + Some(sort_secondary), + global_orientation, + ); + *colors = utils::colors_from_hues(&self.hues, self.nl_udp.panels.len()); + } } } @@ -155,10 +189,10 @@ impl Visualizer { VisualizerState::Done => break, VisualizerState::Paused => { let event = rx_events.recv().expect("events sender disconnected"); - self.update_state(event); + self.update_state(event, &mut colors); } VisualizerState::Running => match rx_events.try_recv() { - Ok(event) => self.update_state(event), + Ok(event) => self.update_state(event, &mut colors), Err(err) => { if err == TryRecvError::Disconnected { panic!("events sender disconnected"); From 6dcf156fe2b86156d087930c3076eb232b01263b Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:12:55 -0800 Subject: [PATCH 2/2] manny changes so stopping here --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- src/audio.rs | 6 ++++-- src/layout_visualizer.rs | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7304c62..dd6dd05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,9 +198,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -1565,9 +1565,9 @@ dependencies = [ [[package]] name = "pollster" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "potential_utf" @@ -2215,9 +2215,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.8" +version = "0.23.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9b7ac41d92f2d2803f233e297127bac397df7b337e0460a1cc39d6c006dee4" +checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ "indexmap", "toml_datetime", diff --git a/Cargo.toml b/Cargo.toml index 0dc04e9..ff494a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ dirs = "6.0.0" macroquad = "0.4" num-complex = "0.4.6" palette = "0.7.6" -pollster = "0.3" +pollster = "0.4" ratatui = "0.29.0" reqwest = { version = "0.12.24", features = ["blocking", "json"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/audio.rs b/src/audio.rs index b2edc18..cc37b50 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -20,14 +20,16 @@ impl AudioStream { let device = match device_name { constants::DEFAULT_AUDIO_BACKEND => { // Check for common loopback device names first - let loopback_names = ["BlackHole", + let loopback_names = [ + "BlackHole", "BlackHole 2ch", "BlackHole 16ch", "Loopback Audio", "CABLE Output", "VB-Audio", "Monitor", - "monitor"]; + "monitor", + ]; let mut loopback_device = None; if let Ok(devices) = host.input_devices() { diff --git a/src/layout_visualizer.rs b/src/layout_visualizer.rs index f0f7b6a..a9ae23a 100644 --- a/src/layout_visualizer.rs +++ b/src/layout_visualizer.rs @@ -122,7 +122,7 @@ impl ShapeType { pub fn num_sides(&self) -> usize { match self.id { 0 | 8 | 9 => 3, // Triangles - 2..=4 => 4, // Squares + 2..=4 => 4, // Squares 7 | 14 | 15 => 6, // Hexagons _ => 4, // Default to square }