Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
607 changes: 604 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

14 changes: 6 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
[package]
name = "qlight"
version = "0.0.1"
edition = "2021"
[workspace]
members = ["crates/qlight-core", "crates/qlight-cli", "crates/qlight-osc"]
resolver = "2"

[dependencies]
hidapi = "2.6.4"
clap = { version = "4.5.53", features = ["derive"] }
anyhow = "1.0.100"
[workspace.package]
version = "0.0.1"
edition = "2024"
14 changes: 14 additions & 0 deletions crates/qlight-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "qlight-cli"
version.workspace = true
edition.workspace = true

[[bin]]
name = "qlight"
path = "src/main.rs"

[dependencies]
qlight-core = { path = "../qlight-core" }
hidapi = "2.6.4"
clap = { version = "4.5.53", features = ["derive"] }
anyhow = "1.0.100"
221 changes: 221 additions & 0 deletions crates/qlight-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use std::{ffi::CString, io::Write};

use clap::{ArgGroup, Parser};
use hidapi::HidApi;
use qlight_core::{Color, Light, LightCommand, LightMode, LightCommandSet};

use anyhow::{bail, Result};

#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
action: Action,
}

#[derive(clap::Subcommand, Debug)]
enum Action {
Set(SetArgs),
/// List all lights connected to this system
List,
}

/// Set the light to a specific set of colors
#[derive(Parser, Debug)]
#[clap(group(
ArgGroup::new("picker")
.required(true)
.args(&["all", "path"])
))]
struct SetArgs {
/// Apply the commands to a specific light. Use `list` to get the paths.
#[clap(long, value_name = "PATH")]
path: Vec<String>,

/// Apply the commands to all detected lights.
#[clap(long)]
all: bool,

/// If set, any unspecified color will be turned off.
#[clap(long)]
reset: bool,

/// A list of [color]:[state]
///
/// Valid colors: red, yellow, green, blue, white
///
/// Valid states: off, on, blink
#[arg(value_parser = parse_command)]
commands: Vec<LightCommand>,
}

fn parse_command(s: &str) -> Result<LightCommand> {
let Some((color, mode_name)) = s.split_once(':') else {
bail!("Expected format of [red,yellow,green,blue,white]:[on,off,blink] got {s}");
};

let color = Color::try_from(color)?;
let light_mode = LightMode::try_from(mode_name)?;

Ok((color, light_mode))
}

fn list(_args: Args) -> Result<()> {
let hidapi = HidApi::new()?;
let devices = Light::get_devices(&hidapi);

let mut stdout = std::io::stdout().lock();

for device in devices {
stdout.write_all(device.path().to_bytes())?;
writeln!(stdout)?;
}
Ok(())
}

fn set(args: SetArgs) -> Result<()> {

// Parse out --all and --path entries into a list of Lights
let hidapi = HidApi::new()?;
let mut lights = vec![];
if args.all {
for device in Light::get_devices(&hidapi) {
let light = Light::new(device.open_device(&hidapi)?);
lights.push(light);
}
} else {
for path in &args.path {
let path_cstring = CString::new(path.as_str())?;
let device = hidapi.open_path(&path_cstring)?;
let light = Light::new(device);
lights.push(light);
}
}

if lights.is_empty() {
bail!("No lights found");
}

// Calculate LightCommandSet
let mut lightset = if args.reset {
LightCommandSet::all_off()
} else {
LightCommandSet::default()
};

for (color, lightmode) in &args.commands {
lightset.set(*color, *lightmode);
}

// Send command to the lights
for light in &lights {
light.update(&lightset)?;
}

Ok(())
}

fn main() -> Result<()> {
let cli = Args::parse();
match cli.action {
Action::Set(s) => set(s),
Action::List => list(cli),
}
}

#[cfg(test)]
mod tests {
use super::*;
use qlight_core::{Color, LightMode};

#[test]
fn parse_command_ok_basic() {
let cmd = parse_command("red:on").expect("should parse");
assert_eq!(cmd, (Color::Red, LightMode::On));
}

#[test]
fn parse_command_ok_case_insensitive() {
let cmd = parse_command("GrEeN:BlInK").expect("should parse case-insensitively");
assert_eq!(cmd, (Color::Green, LightMode::Blink));
}

#[test]
fn parse_command_err_missing_colon() {
let err = parse_command("red").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Expected format of"), "got: {msg}");
}

#[test]
fn parse_command_err_bad_color() {
let err = parse_command("purple:on").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Expected one of [red, yellow, green, blue, white]"), "got: {msg}");
}

#[test]
fn parse_command_err_bad_mode() {
let err = parse_command("red:florb").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Expected one of [on, off, blink]"), "got: {msg}");
}

#[test]
fn set_args_mutually_exclusive_all_and_path() {
// specifying both --all and --path should be rejected by clap as an argument conflict
let err = Args::try_parse_from([
"qlight",
"set",
"--all",
"--path",
"/dev/fake1",
"red:on",
])
.unwrap_err();

assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}

#[test]
fn set_args_multiple_paths_allowed() {
// --path may be specified multiple times to target multiple devices
let args = Args::try_parse_from([
"qlight",
"set",
"--path",
"/dev/fake1",
"--path",
"/dev/fake2",
"red:on",
])
.expect("should parse multiple --path entries");

match args.action {
Action::Set(set) => {
assert_eq!(set.path, vec!["/dev/fake1".to_string(), "/dev/fake2".to_string()]);
assert!(!set.all, "--all should not be set");
}
_ => panic!("expected set subcommand"),
}
}

#[test]
fn set_args_all_only_parses() {
// --all alone should parse and set `all` to true with no paths
let args = Args::try_parse_from([
"qlight",
"set",
"--all",
"red:on",
])
.expect("should parse --all");

match args.action {
Action::Set(set) => {
assert!(set.all, "--all should be set");
assert!(set.path.is_empty(), "no paths should be present");
}
_ => panic!("expected set subcommand"),
}
}
}
7 changes: 7 additions & 0 deletions crates/qlight-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "qlight-core"
version.workspace = true
edition.workspace = true

[dependencies]
hidapi = "2.6.4"
9 changes: 4 additions & 5 deletions src/qlight.rs → crates/qlight-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ impl TryFrom<&str> for Color {
"white" => Color::White,
other => {
return Err(ParseError(format!(
"Expected one of [red, yellow, green, blue, white], got {}",
other
"Expected one of [red, yellow, green, blue, white], got {other}"
)))
}
};
Expand All @@ -67,8 +66,7 @@ impl TryFrom<&str> for LightMode {
"blink" => LightMode::Blink,
other => {
return Err(ParseError(format!(
"Expected one of [on, off, blink] in command, got {}",
other
"Expected one of [on, off, blink] in command, got {other}"
)))
}
};
Expand Down Expand Up @@ -112,7 +110,7 @@ pub struct LightCommandSet {
}

impl LightCommandSet {
pub fn default_off() -> Self {
pub fn all_off() -> Self {
Self {
red: LightMode::Off,
yellow: LightMode::Off,
Expand Down Expand Up @@ -146,6 +144,7 @@ impl LightCommandSet {
}
}

#[derive(Debug)]
pub struct Light {
device: HidDevice,
}
Expand Down
16 changes: 16 additions & 0 deletions crates/qlight-osc/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "qlight-osc"
version.workspace = true
edition.workspace = true

[dependencies]
rosc = "~0.10"
qlight-core = { path = "../qlight-core" }
anyhow = "1.0.100"
hidapi = "2.6.4"
matchit = "0.9.0"
tracing = "0.1.44"
tracing-subscriber = "0.3.22"
config = "0.13"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
4 changes: 4 additions & 0 deletions crates/qlight-osc/config_example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
listen="0.0.0.0:8000"

[bindings.device1]
path="DevSrvsID:4301142453"
Loading
Loading