This commit is contained in:
Pat Nakajima 2026-04-18 21:50:36 -07:00
commit 72dfe96827
4 changed files with 807 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

234
Cargo.lock generated Normal file
View File

@ -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",
]

8
Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "rgbled"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
spidev = "0.7.1"

564
src/main.rs Normal file
View File

@ -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<SetColorArgs>,
/// Byte order to send to the LED controller.
#[arg(long, value_enum, global = true, default_value = "brg")]
order: ChannelOrder,
#[command(subcommand)]
command: Option<Command>,
}
#[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<Color>,
/// 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<Self, Self::Err> {
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<DurationArg> for Duration {
fn from(value: DurationArg) -> Self {
value.0
}
}
impl FromStr for DurationArg {
type Err = ParseArgError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
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<String>) -> 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<u8>) {
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<u8> {
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<Spidev, Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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::<Color>().unwrap(),
Color::new(0x00, 0xaa, 0xff)
);
assert_eq!(
"0xffaa00".parse::<Color>().unwrap(),
Color::new(0xff, 0xaa, 0x00)
);
}
#[test]
fn parses_duration_units() {
assert_eq!(
Duration::from("250ms".parse::<DurationArg>().unwrap()),
Duration::from_millis(250)
);
assert_eq!(
Duration::from("1.5s".parse::<DurationArg>().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:?}");
}
}
}