commit 72dfe96827fe6ca09e2aa1308484d277595580b4 Author: Pat Nakajima Date: Sat Apr 18 21:50:36 2026 -0700 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..abb870f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,234 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rgbled" +version = "0.1.0" +dependencies = [ + "clap", + "spidev", +] + +[[package]] +name = "spidev" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f611021817865236902d15bdf8d729b0bd232141b568d56bb88409a8dc27397" +dependencies = [ + "bitflags", + "libc", + "nix", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d3147bd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rgbled" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +spidev = "0.7.1" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cc67b26 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,564 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; +use spidev::{SpiModeFlags, Spidev, SpidevOptions}; +use std::error::Error; +use std::fmt; +use std::io::Write; +use std::str::FromStr; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +const ZERO: u8 = 0b100; +const ONE: u8 = 0b110; +const RESET_LATCH_BYTES: usize = 50; +const DEFAULT_SPI_DEVICE: &str = "/dev/spidev0.0"; +const LERP_FRAMES_PER_SECOND: u32 = 60; +const DEBUG_TEST_COLORS: [(&str, Color); 3] = [ + ("red", Color::new(0xff, 0x00, 0x00)), + ("green", Color::new(0x00, 0xff, 0x00)), + ("blue", Color::new(0x00, 0x00, 0xff)), +]; + +/// Write hex colors to the LED over SPI. +#[derive(Parser)] +#[command( + author, + version, + about, + arg_required_else_help = true, + args_conflicts_with_subcommands = true, + subcommand_precedence_over_arg = true +)] +struct Cli { + #[command(flatten)] + set: Option, + + /// Byte order to send to the LED controller. + #[arg(long, value_enum, global = true, default_value = "brg")] + order: ChannelOrder, + + #[command(subcommand)] + command: Option, +} + +#[derive(Args)] +struct SetColorArgs { + /// Hex color like 00aaff, #00aaff, or 0x00aaff. + #[arg(value_name = "COLOR")] + color: Color, +} + +#[derive(Subcommand)] +enum Command { + /// Turn the LED off. + Off, + /// Smoothly interpolate through two or more hex colors. + Lerp(LerpArgs), + /// Run a debug color test to help identify channel order. + Debug(DebugArgs), +} + +#[derive(Args)] +struct LerpArgs { + /// Hex colors to interpolate through. + #[arg(value_name = "COLOR", num_args = 2..)] + colors: Vec, + + /// Duration for each transition (for example: 250ms, 1s, 2.5s). + #[arg(long, default_value = "1s")] + duration: DurationArg, + + /// Whether to loop the animation. + #[arg( + long = "loop", + action = clap::ArgAction::Set, + default_value_t = false, + num_args = 0..=1, + default_missing_value = "true" + )] + repeat: bool, +} + +#[derive(Args)] +struct DebugArgs { + /// Duration to hold each test color (for example: 250ms, 1s, 2.5s). + #[arg(long, default_value = "1s")] + duration: DurationArg, + + /// Whether to loop the test pattern. + #[arg( + long = "loop", + action = clap::ArgAction::Set, + default_value_t = false, + num_args = 0..=1, + default_missing_value = "true" + )] + repeat: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum ChannelOrder { + /// Send bytes in RGB order. + Rgb, + /// Send bytes in RBG order. + Rbg, + /// Send bytes in GRB order. + Grb, + /// Send bytes in GBR order. + Gbr, + /// Send bytes in BRG order. + Brg, + /// Send bytes in BGR order. + Bgr, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Color { + red: u8, + green: u8, + blue: u8, +} + +impl Color { + const BLACK: Self = Self::new(0, 0, 0); + + const fn new(red: u8, green: u8, blue: u8) -> Self { + Self { red, green, blue } + } + + fn hex(self) -> String { + format!("{:02x}{:02x}{:02x}", self.red, self.green, self.blue) + } + + fn interpolate(self, other: Self, t: f64) -> Self { + let t = t.clamp(0.0, 1.0); + + Self { + red: interpolate_channel(self.red, other.red, t), + green: interpolate_channel(self.green, other.green, t), + blue: interpolate_channel(self.blue, other.blue, t), + } + } +} + +impl FromStr for Color { + type Err = ParseArgError; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + let hex = value + .strip_prefix('#') + .or_else(|| value.strip_prefix("0x")) + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value); + + if hex.len() != 6 || !hex.chars().all(|character| character.is_ascii_hexdigit()) { + return Err(ParseArgError::new(format!( + "expected a 6-digit hex color like 00aaff, got '{value}'" + ))); + } + + let red = u8::from_str_radix(&hex[0..2], 16).unwrap(); + let green = u8::from_str_radix(&hex[2..4], 16).unwrap(); + let blue = u8::from_str_radix(&hex[4..6], 16).unwrap(); + + Ok(Self::new(red, green, blue)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DurationArg(Duration); + +impl DurationArg { + fn into_duration(self) -> Duration { + self.0 + } +} + +impl From for Duration { + fn from(value: DurationArg) -> Self { + value.0 + } +} + +impl FromStr for DurationArg { + type Err = ParseArgError; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + + let (amount, unit) = if let Some(amount) = value.strip_suffix("ms") { + (amount, "ms") + } else if let Some(amount) = value.strip_suffix('s') { + (amount, "s") + } else if let Some(amount) = value.strip_suffix('m') { + (amount, "m") + } else if let Some(amount) = value.strip_suffix('h') { + (amount, "h") + } else { + return Err(ParseArgError::new(format!( + "expected a duration with units like 250ms or 1s, got '{value}'" + ))); + }; + + let amount: f64 = amount.parse().map_err(|_| { + ParseArgError::new(format!( + "expected a numeric duration before the unit, got '{value}'" + )) + })?; + + if !amount.is_finite() || amount < 0.0 { + return Err(ParseArgError::new(format!( + "duration must be a non-negative finite number, got '{value}'" + ))); + } + + let seconds = match unit { + "ms" => amount / 1_000.0, + "s" => amount, + "m" => amount * 60.0, + "h" => amount * 60.0 * 60.0, + _ => unreachable!("duration suffix should already be validated"), + }; + + Ok(Self(Duration::from_secs_f64(seconds))) + } +} + +#[derive(Debug)] +struct ParseArgError(String); + +impl ParseArgError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ParseArgError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for ParseArgError {} + +fn interpolate_channel(start: u8, end: u8, t: f64) -> u8 { + let start = start as f64; + let end = end as f64; + let value = (start + (end - start) * t).round().clamp(0.0, 255.0); + + value as u8 +} + +fn encode_byte(byte: u8, output: &mut Vec) { + for bit_index in (0..8).rev() { + let bit_is_set = (byte >> bit_index) & 1 == 1; + let pattern = if bit_is_set { ONE } else { ZERO }; + + output.push(pattern << 5); + } +} + +fn encode_color(color: Color, order: ChannelOrder) -> Vec { + let mut buffer = Vec::with_capacity(24); + + match order { + ChannelOrder::Rgb => { + encode_byte(color.red / 4, &mut buffer); + encode_byte(color.green / 4, &mut buffer); + encode_byte(color.blue / 4, &mut buffer); + } + ChannelOrder::Rbg => { + encode_byte(color.red / 4, &mut buffer); + encode_byte(color.blue / 4, &mut buffer); + encode_byte(color.green / 4, &mut buffer); + } + ChannelOrder::Grb => { + encode_byte(color.green / 4, &mut buffer); + encode_byte(color.red / 4, &mut buffer); + encode_byte(color.blue / 4, &mut buffer); + } + ChannelOrder::Gbr => { + encode_byte(color.green / 4, &mut buffer); + encode_byte(color.blue / 4, &mut buffer); + encode_byte(color.red / 4, &mut buffer); + } + ChannelOrder::Brg => { + encode_byte(color.blue / 4, &mut buffer); + encode_byte(color.red / 4, &mut buffer); + encode_byte(color.green / 4, &mut buffer); + } + ChannelOrder::Bgr => { + encode_byte(color.blue / 4, &mut buffer); + encode_byte(color.green / 4, &mut buffer); + encode_byte(color.red / 4, &mut buffer); + } + } + + buffer +} + +fn open_spi() -> Result> { + let mut spi = Spidev::open(DEFAULT_SPI_DEVICE)?; + + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(2_400_000) + .mode(SpiModeFlags::SPI_MODE_0) + .build(); + + spi.configure(&options)?; + + Ok(spi) +} + +fn write_color_to_spi( + spi: &mut Spidev, + color: Color, + order: ChannelOrder, +) -> Result<(), Box> { + let mut data = encode_color(color, order); + data.extend_from_slice(&[0; RESET_LATCH_BYTES]); + spi.write_all(&data)?; + Ok(()) +} + +fn write_color(color: Color, order: ChannelOrder) -> Result<(), Box> { + let mut spi = open_spi()?; + write_color_to_spi(&mut spi, color, order) +} + +fn play_transition( + spi: &mut Spidev, + start: Color, + end: Color, + duration: Duration, + order: ChannelOrder, +) -> Result<(), Box> { + if duration.is_zero() { + return write_color_to_spi(spi, end, order); + } + + let steps = (duration.as_secs_f64() * LERP_FRAMES_PER_SECOND as f64) + .ceil() + .max(1.0) as u32; + let started = Instant::now(); + + for step in 0..=steps { + let t = step as f64 / steps as f64; + write_color_to_spi(spi, start.interpolate(end, t), order)?; + + if step == steps { + break; + } + + let next_frame_at = started + + Duration::from_secs_f64(duration.as_secs_f64() * (step + 1) as f64 / steps as f64); + + if let Some(remaining) = next_frame_at.checked_duration_since(Instant::now()) { + sleep(remaining); + } + } + + Ok(()) +} + +fn run_lerp(args: LerpArgs, order: ChannelOrder) -> Result<(), Box> { + let mut spi = open_spi()?; + let duration = args.duration.into_duration(); + + loop { + for window in args.colors.windows(2) { + play_transition(&mut spi, window[0], window[1], duration, order)?; + } + + if !args.repeat { + break; + } + + play_transition( + &mut spi, + *args + .colors + .last() + .expect("clap should require at least two lerp colors"), + args.colors[0], + duration, + order, + )?; + } + + Ok(()) +} + +fn run_debug(args: DebugArgs, order: ChannelOrder) -> Result<(), Box> { + let mut spi = open_spi()?; + let duration = args.duration.into_duration(); + + loop { + for (name, color) in DEBUG_TEST_COLORS { + println!("showing {name}: {}", color.hex()); + write_color_to_spi(&mut spi, color, order)?; + + if !duration.is_zero() { + sleep(duration); + } + } + + println!("showing off: {}", Color::BLACK.hex()); + write_color_to_spi(&mut spi, Color::BLACK, order)?; + + if !args.repeat { + break; + } + + if !duration.is_zero() { + sleep(duration); + } + } + + Ok(()) +} + +fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + match (cli.set, cli.command) { + (_, Some(Command::Off)) => write_color(Color::BLACK, cli.order), + (_, Some(Command::Lerp(args))) => run_lerp(args, cli.order), + (_, Some(Command::Debug(args))) => run_debug(args, cli.order), + (Some(set), None) => write_color(set.color, cli.order), + (None, None) => unreachable!("clap should require either a color or a subcommand"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_color_argument() { + let cli = Cli::try_parse_from(["rgbled", "00aaff"]).unwrap(); + + assert_eq!(cli.set.unwrap().color, Color::new(0x00, 0xaa, 0xff)); + assert_eq!(cli.order, ChannelOrder::Brg); + assert!(cli.command.is_none()); + } + + #[test] + fn parses_off_subcommand() { + let cli = Cli::try_parse_from(["rgbled", "off"]).unwrap(); + + assert!(cli.set.is_none()); + assert!(matches!(cli.command, Some(Command::Off))); + } + + #[test] + fn parses_lerp_subcommand() { + let cli = Cli::try_parse_from([ + "rgbled", + "lerp", + "00aaff", + "ffaa00", + "00ffaa", + "--duration", + "1s", + "--loop", + "true", + "--order", + "rgb", + ]) + .unwrap(); + + assert!(cli.set.is_none()); + assert_eq!(cli.order, ChannelOrder::Rgb); + + match cli.command { + Some(Command::Lerp(args)) => { + assert_eq!( + args.colors, + vec![ + Color::new(0x00, 0xaa, 0xff), + Color::new(0xff, 0xaa, 0x00), + Color::new(0x00, 0xff, 0xaa), + ] + ); + assert_eq!(Duration::from(args.duration), Duration::from_secs(1)); + assert!(args.repeat); + } + _ => panic!("expected lerp subcommand"), + } + } + + #[test] + fn parses_debug_subcommand() { + let cli = Cli::try_parse_from([ + "rgbled", + "debug", + "--duration", + "750ms", + "--loop", + "true", + "--order", + "bgr", + ]) + .unwrap(); + + assert!(cli.set.is_none()); + assert_eq!(cli.order, ChannelOrder::Bgr); + + match cli.command { + Some(Command::Debug(args)) => { + assert_eq!(Duration::from(args.duration), Duration::from_millis(750)); + assert!(args.repeat); + } + _ => panic!("expected debug subcommand"), + } + } + + #[test] + fn parses_prefixed_hex_colors() { + assert_eq!( + "#00AAff".parse::().unwrap(), + Color::new(0x00, 0xaa, 0xff) + ); + assert_eq!( + "0xffaa00".parse::().unwrap(), + Color::new(0xff, 0xaa, 0x00) + ); + } + + #[test] + fn parses_duration_units() { + assert_eq!( + Duration::from("250ms".parse::().unwrap()), + Duration::from_millis(250) + ); + assert_eq!( + Duration::from("1.5s".parse::().unwrap()), + Duration::from_millis(1_500) + ); + } + + #[test] + fn encodes_supported_channel_orders() { + let color = Color::new(4, 8, 12); + + let cases = [ + (ChannelOrder::Rgb, [1, 2, 3]), + (ChannelOrder::Rbg, [1, 3, 2]), + (ChannelOrder::Grb, [2, 1, 3]), + (ChannelOrder::Gbr, [2, 3, 1]), + (ChannelOrder::Brg, [3, 1, 2]), + (ChannelOrder::Bgr, [3, 2, 1]), + ]; + + for (order, channels) in cases { + let mut expected = Vec::new(); + for channel in channels { + encode_byte(channel, &mut expected); + } + + assert_eq!(encode_color(color, order), expected, "order: {order:?}"); + } + } +}