first
This commit is contained in:
commit
72dfe96827
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
234
Cargo.lock
generated
Normal file
234
Cargo.lock
generated
Normal 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
8
Cargo.toml
Normal 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
564
src/main.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user