From 7061b708aa278ffb2e998a5d29f8a1c1059cb9b5 Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Mon, 30 Mar 2026 23:20:36 -0500 Subject: [PATCH 01/10] Add mDNS autodiscovery support for Elgato Keylights Adds --discover/-d flag to automatically find Elgato Keylights on the local network via mDNS (service type _elg._tcp.local.) using the mdns-sd crate. The status command now uses GET instead of PUT to read current settings (power, brightness, temperature) without modifying them. Commands are sent to all discovered lights. Manual --elgato-ip usage remains fully backwards compatible. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 156 +++++++++++++++++++++++++++++++++++++++-------- Cargo.toml | 1 + README.md | 50 ++++++++++++--- src/cli.rs | 12 +++- src/discovery.rs | 68 +++++++++++++++++++++ src/main.rs | 84 ++++++++++++++++--------- 6 files changed, 306 insertions(+), 65 deletions(-) create mode 100644 src/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index ccb99d5..3103067 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -142,6 +142,15 @@ dependencies = [ "log", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -158,6 +167,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "encoding_rs" version = "0.8.31" @@ -189,6 +204,17 @@ dependencies = [ "instant", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -396,6 +422,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "indexmap" version = "1.8.1" @@ -445,6 +481,7 @@ dependencies = [ "clap_logger", "env_logger", "log", + "mdns-sd", "reqwest", "serde", "serde_json", @@ -463,6 +500,15 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.16" @@ -478,6 +524,19 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "mdns-sd" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" +dependencies = [ + "flume", + "if-addrs", + "log", + "polling", + "socket2 0.5.6", +] + [[package]] name = "memchr" version = "2.5.0" @@ -635,6 +694,22 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "proc-macro2" version = "1.0.79" @@ -746,6 +821,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.6.1" @@ -838,6 +919,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "strsim" version = "0.10.0" @@ -1206,7 +1296,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1226,17 +1325,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1247,9 +1347,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -1259,9 +1359,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -1271,9 +1371,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -1283,9 +1389,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -1295,9 +1401,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -1307,9 +1413,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -1319,9 +1425,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winreg" diff --git a/Cargo.toml b/Cargo.toml index 1a222a4..ab8cfa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,5 @@ log = "*" reqwest = { version = "*", features = ["json", "blocking"] } serde = { version = "*", features = ["derive"] } serde_json = "*" +mdns-sd = "0.11" tokio = { version = "1.28.0", features = ["macros"] } diff --git a/README.md b/README.md index 1d08518..e45687b 100644 --- a/README.md +++ b/README.md @@ -11,30 +11,57 @@ This is a cross platform lightweight CLI tool to simply and easily control your - [X] ~~Add status to query for on/off~~ **Added in v0.2.2** - [ ] Support for brightness and temperature via preset arguments, I.E, `low`, `medium`, and `high` or `warm`, `medium`, and `cool`. - [ ] Support for brightness by percentage. -- [ ] Autodiscovery support. -- [ ] Testing with more than 1 Elgato Keylight. +- [X] ~~Autodiscovery support.~~ **Added in v0.3.0** +- [X] ~~Testing with more than 1 Elgato Keylight.~~ **Tested in v0.3.0** ## Building The App -This app should build with minimal dependencies. It's been tested with Rust 1.60 on macOS Sonoma 14.4.1 and 1 Elgato Keylight. +This app should build with minimal dependencies. It's been tested with Rust 1.60+ on macOS and 2 Elgato Keylights. ```sh cargo build sudo mv target/debug/keylight /usr/local/bin/keylight -keylight status --elgato-ip --number-of-lights 1 ``` ## Running The App -This CLI tool has three mandatory parameters and two optional ones (that have default values). There are environment variables that can be provided in place of CLI arguments. +There are two ways to target your Elgato Keylights: autodiscovery via mDNS or manual IP address. +### Autodiscovery (recommended) + +Use the `--discover` / `-d` flag to automatically find all Elgato Keylights on your local network via mDNS: + +```sh +# Check the status of all lights on the network +keylight status --discover + +# Turn all discovered lights on +keylight on --discover + +# Turn all discovered lights off +keylight off --discover ``` -keylight v0.2.3 -Jessica Deen + +### Manual IP address + +You can also specify the IP address directly: + +```sh +keylight status --elgato-ip +keylight on --elgato-ip --brightness 30 --temperature 200 +``` + +Environment variables can be provided in place of CLI arguments. + +### Full usage + +``` +keylight v0.3.0 +Jessica Deen , Thompson Marzagao Easy CLI to control Elgato Keylight USAGE: - keylight [OPTIONS] --elgato-ip --number-of-lights + keylight [OPTIONS] ARGS: Toggle light on, off, or query current power state [possible values: off, @@ -44,14 +71,17 @@ OPTIONS: -b, --brightness Brightness value for light [env: brightness=] [default: 20] + -d, --discover + Auto-discover Elgato Keylights on the local network via mDNS + -h, --help Print help information -i, --elgato-ip - Elgato Keylight IP address [env: elgato_ip=192.168.184.166] + Elgato Keylight IP address [env: elgato_ip=] -n, --number-of-lights - Number of Elgato Keylights in use [env: number_of_lights=1] + Number of Elgato Keylights in use [env: number_of_lights=] [default: 1] -t, --temperature Temperature value for light [env: temperature=] [default: 213] diff --git a/src/cli.rs b/src/cli.rs index 01772a9..0dfc430 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -38,7 +38,7 @@ pub fn get_app_cli(version: &str) -> Command { .long("elgato-ip") .short('i') .help("Elgato Keylight IP address") - .required(true) + .required(false) .aliases(&["elgato_ip", "elgato-ip", "elgato ip"]) .env("elgato_ip") .takes_value(true), @@ -48,11 +48,19 @@ pub fn get_app_cli(version: &str) -> Command { .long("number-of-lights") .short('n') .help("Number of Elgato Keylights in use") - .required(true) + .required(false) .aliases(&["number_of_lights", "number-of-lights", "number of lights"]) .env("number_of_lights") + .default_value("1") .takes_value(true), ) + .arg( + Arg::new("discover") + .long("discover") + .short('d') + .help("Auto-discover Elgato Keylights on the local network via mDNS") + .takes_value(false), + ) .arg( Arg::new("verbose") .long("verbose") diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..edd26f7 --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,68 @@ +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::collections::HashSet; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +const SERVICE_TYPE: &str = "_elg._tcp.local."; + +pub struct DiscoveredLight { + pub name: String, + pub ip: String, + pub port: u16, +} + +pub fn discover_lights( + timeout: Duration, +) -> Result, Box> { + let mdns = ServiceDaemon::new()?; + let receiver = mdns.browse(SERVICE_TYPE)?; + + let mut lights = Vec::new(); + let mut seen_names = HashSet::new(); + let start = Instant::now(); + + while start.elapsed() < timeout { + let remaining = timeout.saturating_sub(start.elapsed()); + if remaining.is_zero() { + break; + } + + match receiver.recv_timeout(remaining) { + Ok(ServiceEvent::ServiceResolved(info)) => { + let name = info.get_fullname().to_string(); + if seen_names.contains(&name) { + continue; + } + + // Prefer IPv4 addresses over IPv6 for local network devices + let ipv4 = info.get_addresses().iter().find(|a| matches!(a, IpAddr::V4(_))); + let ip = match ipv4 { + Some(addr) => addr.to_string(), + None => match info.get_addresses().iter().next() { + Some(addr) => addr.to_string(), + None => continue, + }, + }; + + log::info!( + "Discovered light: {} at {}:{}", + name, + ip, + info.get_port() + ); + + seen_names.insert(name.clone()); + lights.push(DiscoveredLight { + name, + ip, + port: info.get_port(), + }); + } + Ok(_) => {} + Err(_) => break, + } + } + + mdns.shutdown()?; + Ok(lights) +} diff --git a/src/main.rs b/src/main.rs index 878dc0b..8c4094f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,16 @@ mod cli; +mod discovery; // Logging use chrono::Local; use env_logger::Builder; use log::LevelFilter; use std::io::Write; +use std::time::Duration; // Required deps use cli::get_app_cli; +use discovery::discover_lights; use reqwest::Client; use serde_json::{json, Value}; @@ -37,7 +40,28 @@ async fn main() -> Result<(), Box> { .parse_env("LOG_LEVEL") .init(); - let elgato_ip = matches.value_of("elgato_ip").unwrap(); + // Determine target lights + let discover = matches.is_present("discover"); + let elgato_ip = matches.value_of("elgato_ip"); + + let targets: Vec<(String, u16)> = if discover { + println!("Discovering Elgato Keylights on the network..."); + let lights = discover_lights(Duration::from_secs(5))?; + if lights.is_empty() { + eprintln!("Error: No Elgato Keylights found on the network."); + std::process::exit(1); + } + for light in &lights { + println!(" Found: {} ({}:{})", light.name, light.ip, light.port); + } + lights.into_iter().map(|l| (l.ip, l.port)).collect() + } else if let Some(ip) = elgato_ip { + vec![(ip.to_string(), 9123)] + } else { + eprintln!("Error: Either --discover or --elgato-ip must be specified."); + std::process::exit(1); + }; + let numberoflights = matches.value_of("number_of_lights").unwrap(); let switch = match matches.value_of("switch").unwrap() { @@ -47,13 +71,10 @@ async fn main() -> Result<(), Box> { _ => 0, }; - // status if switch == 0 { - let power_status = "off"; - println!("Elgato Keylight is: {}", power_status); + println!("Elgato Keylight is: off"); } else if switch == 1 { - let power_status = "on"; - println!("Elgato Keylight is: {}", power_status); + println!("Elgato Keylight is: on"); } let brightness = matches @@ -77,36 +98,43 @@ async fn main() -> Result<(), Box> { ] }); - let url = format!("http://{}:{}", elgato_ip, "9123/elgato/lights"); - log::info!("status: {}", url); - let client = Client::new(); - let response = client.put(url).json(&body).send().await?; + for (ip, port) in &targets { + let url = format!("http://{}:{}/elgato/lights", ip, port); + log::info!("Sending request to: {}", url); + + if switch == 2 { + // GET to read current settings without modifying them + let response = client.get(&url).send().await?; + log::info!("Response status: {}", response.status()); + + let response_body = response.text().await?; + log::info!("Response text: {}", response_body); - // response status code - let response_success = response.status(); - log::info!("Response status: {}", response_success); + let v: Value = serde_json::from_str(&response_body)?; + let light = &v["lights"][0]; - // body - let response_body = response.text().await?; - log::info!("Response text: {}", response_body); + let power = if light["on"] == 1 { "on" } else { "off" }; + let brightness = &light["brightness"]; + let temperature = &light["temperature"]; - // Json data - let response_json: serde_json::Value = serde_json::from_str(&response_body)?; - log::info!("Response json: {:?}", response_json); + println!("Elgato light at {}:", ip); + println!(" Power: {}", power); + println!(" Brightness: {}", brightness); + println!(" Temperature: {}", temperature); + } else { + // PUT to change settings + let response = client.put(&url).json(&body).send().await?; - if switch == 2 { - let v: Value = serde_json::from_str(&response_body)?; + let response_success = response.status(); + log::info!("Response status: {}", response_success); - let query = &v["lights"][0]["on"]; + let response_body = response.text().await?; + log::info!("Response text: {}", response_body); - if query == 0 { - let status = "off"; - println!("Elgato light is: {}", status); - } else if query == 1 { - let status = "on"; - println!("Elgato light is: {}", status); + let response_json: serde_json::Value = serde_json::from_str(&response_body)?; + log::info!("Response json: {:?}", response_json); } } From 9d41a2fdf91e57bc965a8d3cab71d1655058f0cc Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Mon, 30 Mar 2026 23:38:58 -0500 Subject: [PATCH 02/10] Add brightness/temperature presets, brightness percentage validation, bump to v0.3.0 - Brightness accepts presets (low, medium, high) and % suffix with 0-100 validation - Temperature accepts presets (warm, medium, cool) alongside numeric values - Bump version to 0.3.0 and Rust edition to 2024 - Add Thompson Marzagao as second author - Mark all README TODO items as complete Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- README.md | 8 ++++---- src/cli.rs | 10 ++++------ src/main.rs | 39 ++++++++++++++++++++++++++++++--------- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3103067..b60c7c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,7 +474,7 @@ dependencies = [ [[package]] name = "keylight" -version = "0.2.3" +version = "0.3.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index ab8cfa2..051e03e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "keylight" -version = "0.2.3" -edition = "2021" +version = "0.3.0" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index e45687b..2af4906 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ This is a cross platform lightweight CLI tool to simply and easily control your - [X] ~~Support for `on` / `off` toggle arguements.~~ **Added in v0.2.0** - [X] ~~Add help menu with `-h` flag.~~ **Added in v0.2.0** - [X] ~~Add status to query for on/off~~ **Added in v0.2.2** -- [ ] Support for brightness and temperature via preset arguments, I.E, `low`, `medium`, and `high` or `warm`, `medium`, and `cool`. -- [ ] Support for brightness by percentage. +- [X] ~~Support for brightness and temperature via preset arguments, I.E, `low`, `medium`, and `high` or `warm`, `medium`, and `cool`.~~ **Added in v0.3.0** +- [X] ~~Support for brightness by percentage.~~ **Added in v0.3.0** - [X] ~~Autodiscovery support.~~ **Added in v0.3.0** - [X] ~~Testing with more than 1 Elgato Keylight.~~ **Tested in v0.3.0** @@ -69,7 +69,7 @@ ARGS: OPTIONS: -b, --brightness - Brightness value for light [env: brightness=] [default: 20] + Brightness: percentage (0-100) or preset (low, medium, high) [env: brightness=] [default: 20] -d, --discover Auto-discover Elgato Keylights on the local network via mDNS @@ -84,7 +84,7 @@ OPTIONS: Number of Elgato Keylights in use [env: number_of_lights=] [default: 1] -t, --temperature - Temperature value for light [env: temperature=] [default: 213] + Temperature: value (143-344) or preset (warm, medium, cool) [env: temperature=] [default: 213] -v, --verbose Log Level diff --git a/src/cli.rs b/src/cli.rs index 0dfc430..dbbe1b2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,9 @@ use clap::{Arg, Command}; -pub fn get_app_cli(version: &str) -> Command { +pub fn get_app_cli(version: &str) -> Command<'_> { Command::new("keylight") .version(version) - .author("Jessica Deen ") + .author("Jessica Deen \nThompson Marzagao ") .about("Easy CLI to control Elgato Keylight") .arg( Arg::new("switch") @@ -17,8 +17,7 @@ pub fn get_app_cli(version: &str) -> Command { Arg::new("brightness") .long("brightness") .short('b') - // .possible_values(&["low", "medium", "high"]) - .help("Brightness value for light") + .help("Brightness: percentage (0-100) or preset (low, medium, high)") .required(false) .env("brightness") .default_value("20"), @@ -27,8 +26,7 @@ pub fn get_app_cli(version: &str) -> Command { Arg::new("temperature") .long("temperature") .short('t') - // .possible_values(&["warm", "medium", "cool"]) - .help("Temperature value for light") + .help("Temperature: value (143-344) or preset (warm, medium, cool)") .required(false) .env("temperature") .default_value("213"), diff --git a/src/main.rs b/src/main.rs index 8c4094f..5eaee80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,15 +77,36 @@ async fn main() -> Result<(), Box> { println!("Elgato Keylight is: on"); } - let brightness = matches - .value_of("brightness") - .and_then(|s| s.parse::().ok()) - .unwrap(); + let brightness_str = matches.value_of("brightness").unwrap(); + let brightness: u8 = match brightness_str { + "low" => 10, + "medium" => 50, + "high" => 100, + s => { + let s = s.strip_suffix('%').unwrap_or(s); + match s.parse::() { + Ok(v) if v <= 100 => v, + _ => { + eprintln!("Error: Brightness must be 0-100 or a preset (low, medium, high)."); + std::process::exit(1); + } + } + } + }; - let temperature = matches - .value_of("temperature") - .and_then(|s| s.parse::().ok()) - .unwrap(); + let temperature_str = matches.value_of("temperature").unwrap(); + let temperature: f32 = match temperature_str { + "warm" => 344.0, + "medium" => 213.0, + "cool" => 143.0, + s => match s.parse::() { + Ok(v) => v, + _ => { + eprintln!("Error: Temperature must be a number (143-344) or a preset (warm, medium, cool)."); + std::process::exit(1); + } + }, + }; let body = json!({ "numberOfLights":numberoflights, @@ -121,7 +142,7 @@ async fn main() -> Result<(), Box> { println!("Elgato light at {}:", ip); println!(" Power: {}", power); - println!(" Brightness: {}", brightness); + println!(" Brightness: {}%", brightness); println!(" Temperature: {}", temperature); } else { // PUT to change settings From 494e2ffe135ee6a4d39fa15b88420047301de004 Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Mon, 30 Mar 2026 23:48:55 -0500 Subject: [PATCH 03/10] Improve on/off message to show number of targeted lights Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5eaee80..322f0d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,9 +72,9 @@ async fn main() -> Result<(), Box> { }; if switch == 0 { - println!("Elgato Keylight is: off"); + println!("Turning {} Elgato Keylight(s) off...", targets.len()); } else if switch == 1 { - println!("Elgato Keylight is: on"); + println!("Turning {} Elgato Keylight(s) on...", targets.len()); } let brightness_str = matches.value_of("brightness").unwrap(); From ff479c64836de400f1435fa2083c5d378ab8b7a8 Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Mon, 30 Mar 2026 23:56:35 -0500 Subject: [PATCH 04/10] Improve multi-light UX and clean up CLI - Add error resilience: skip unreachable lights and continue to the rest - Add --timeout flag for configurable discovery timeout (default 5s) - Show device names alongside IPs in status output - Display temperature in human-readable Kelvin (~3257K) - Remove redundant --number-of-lights flag (hardcode to 1 per request) - Strip mDNS suffix from discovered light names for cleaner output Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +-- src/cli.rs | 19 ++++----- src/discovery.rs | 10 ++++- src/main.rs | 107 ++++++++++++++++++++++++++++++----------------- 4 files changed, 88 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 2af4906..2135e4e 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,12 @@ OPTIONS: -i, --elgato-ip Elgato Keylight IP address [env: elgato_ip=] - -n, --number-of-lights - Number of Elgato Keylights in use [env: number_of_lights=] [default: 1] - -t, --temperature Temperature: value (143-344) or preset (warm, medium, cool) [env: temperature=] [default: 213] + --timeout + Discovery timeout in seconds [default: 5] + -v, --verbose Log Level diff --git a/src/cli.rs b/src/cli.rs index dbbe1b2..30d7141 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -41,17 +41,6 @@ pub fn get_app_cli(version: &str) -> Command<'_> { .env("elgato_ip") .takes_value(true), ) - .arg( - Arg::new("number_of_lights") - .long("number-of-lights") - .short('n') - .help("Number of Elgato Keylights in use") - .required(false) - .aliases(&["number_of_lights", "number-of-lights", "number of lights"]) - .env("number_of_lights") - .default_value("1") - .takes_value(true), - ) .arg( Arg::new("discover") .long("discover") @@ -59,6 +48,14 @@ pub fn get_app_cli(version: &str) -> Command<'_> { .help("Auto-discover Elgato Keylights on the local network via mDNS") .takes_value(false), ) + .arg( + Arg::new("timeout") + .long("timeout") + .help("Discovery timeout in seconds") + .required(false) + .default_value("5") + .takes_value(true), + ) .arg( Arg::new("verbose") .long("verbose") diff --git a/src/discovery.rs b/src/discovery.rs index edd26f7..ecf14fd 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -51,9 +51,15 @@ pub fn discover_lights( info.get_port() ); - seen_names.insert(name.clone()); + let display_name = name + .strip_suffix(SERVICE_TYPE) + .unwrap_or(&name) + .trim_end_matches('.') + .to_string(); + + seen_names.insert(name); lights.push(DiscoveredLight { - name, + name: display_name, ip, port: info.get_port(), }); diff --git a/src/main.rs b/src/main.rs index 322f0d1..6fffb59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,9 +44,13 @@ async fn main() -> Result<(), Box> { let discover = matches.is_present("discover"); let elgato_ip = matches.value_of("elgato_ip"); - let targets: Vec<(String, u16)> = if discover { + let targets: Vec<(String, u16, Option)> = if discover { println!("Discovering Elgato Keylights on the network..."); - let lights = discover_lights(Duration::from_secs(5))?; + let timeout_secs: u64 = matches + .value_of("timeout") + .and_then(|s| s.parse().ok()) + .unwrap_or(5); + let lights = discover_lights(Duration::from_secs(timeout_secs))?; if lights.is_empty() { eprintln!("Error: No Elgato Keylights found on the network."); std::process::exit(1); @@ -54,16 +58,17 @@ async fn main() -> Result<(), Box> { for light in &lights { println!(" Found: {} ({}:{})", light.name, light.ip, light.port); } - lights.into_iter().map(|l| (l.ip, l.port)).collect() + lights + .into_iter() + .map(|l| (l.ip, l.port, Some(l.name))) + .collect() } else if let Some(ip) = elgato_ip { - vec![(ip.to_string(), 9123)] + vec![(ip.to_string(), 9123, None)] } else { eprintln!("Error: Either --discover or --elgato-ip must be specified."); std::process::exit(1); }; - let numberoflights = matches.value_of("number_of_lights").unwrap(); - let switch = match matches.value_of("switch").unwrap() { "off" => 0, "on" => 1, @@ -109,7 +114,7 @@ async fn main() -> Result<(), Box> { }; let body = json!({ - "numberOfLights":numberoflights, + "numberOfLights":1, "lights":[ { "on":switch, @@ -120,44 +125,70 @@ async fn main() -> Result<(), Box> { }); let client = Client::new(); + let mut errors = Vec::new(); - for (ip, port) in &targets { + for (ip, port, name) in &targets { let url = format!("http://{}:{}/elgato/lights", ip, port); log::info!("Sending request to: {}", url); - if switch == 2 { - // GET to read current settings without modifying them - let response = client.get(&url).send().await?; - log::info!("Response status: {}", response.status()); - - let response_body = response.text().await?; - log::info!("Response text: {}", response_body); - - let v: Value = serde_json::from_str(&response_body)?; - let light = &v["lights"][0]; - - let power = if light["on"] == 1 { "on" } else { "off" }; - let brightness = &light["brightness"]; - let temperature = &light["temperature"]; - - println!("Elgato light at {}:", ip); - println!(" Power: {}", power); - println!(" Brightness: {}%", brightness); - println!(" Temperature: {}", temperature); - } else { - // PUT to change settings - let response = client.put(&url).json(&body).send().await?; - - let response_success = response.status(); - log::info!("Response status: {}", response_success); - - let response_body = response.text().await?; - log::info!("Response text: {}", response_body); + let label = match name { + Some(n) => format!("{} ({})", n, ip), + None => ip.to_string(), + }; + + let result: Result<(), Box> = async { + if switch == 2 { + // GET to read current settings without modifying them + let response = client.get(&url).send().await?; + log::info!("Response status: {}", response.status()); + + let response_body = response.text().await?; + log::info!("Response text: {}", response_body); + + let v: Value = serde_json::from_str(&response_body)?; + let light = &v["lights"][0]; + + let power = if light["on"] == 1 { "on" } else { "off" }; + let brightness = &light["brightness"]; + let temperature = &light["temperature"]; + + println!("{}:", label); + println!(" Power: {}", power); + println!(" Brightness: {}%", brightness); + let temp_val = temperature.as_f64().unwrap_or(0.0); + let kelvin = if temp_val > 0.0 { + (1_000_000.0 / temp_val) as u32 + } else { + 0 + }; + println!(" Temperature: {} (~{}K)", temperature, kelvin); + } else { + // PUT to change settings + let response = client.put(&url).json(&body).send().await?; + + let response_success = response.status(); + log::info!("Response status: {}", response_success); + + let response_body = response.text().await?; + log::info!("Response text: {}", response_body); + + let response_json: serde_json::Value = serde_json::from_str(&response_body)?; + log::info!("Response json: {:?}", response_json); + } + Ok(()) + } + .await; - let response_json: serde_json::Value = serde_json::from_str(&response_body)?; - log::info!("Response json: {:?}", response_json); + if let Err(e) = result { + eprintln!("Error communicating with light at {}:{}: {}", ip, port, e); + errors.push(format!("{}:{}", ip, port)); } } + if !errors.is_empty() { + eprintln!("Failed to reach {} light(s): {}", errors.len(), errors.join(", ")); + std::process::exit(1); + } + Ok(()) } From 245d8edc07047c3d056624fa5c9ed8458a32c9b1 Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Tue, 31 Mar 2026 00:03:50 -0500 Subject: [PATCH 05/10] Fix discovery not finding all lights reliably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't break out of the recv loop on first timeout error — let the outer loop run for the full duration. Increase default discovery timeout from 5s to 10s for more reliable multi-light discovery. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.rs | 2 +- src/discovery.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 30d7141..a77ebbf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -53,7 +53,7 @@ pub fn get_app_cli(version: &str) -> Command<'_> { .long("timeout") .help("Discovery timeout in seconds") .required(false) - .default_value("5") + .default_value("10") .takes_value(true), ) .arg( diff --git a/src/discovery.rs b/src/discovery.rs index ecf14fd..39cbe0d 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -65,7 +65,7 @@ pub fn discover_lights( }); } Ok(_) => {} - Err(_) => break, + Err(_) => {} } } From 7b2a22f6b13ad6e0c61f9b012323245f96e0af5c Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Tue, 31 Mar 2026 00:12:28 -0500 Subject: [PATCH 06/10] Preserve light settings when toggling on/off Only send brightness and temperature in the PUT body when explicitly provided by the user. Plain on/off commands now only toggle power without overwriting the light's current brightness and temperature. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 73 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6fffb59..cd6d8d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,47 +82,62 @@ async fn main() -> Result<(), Box> { println!("Turning {} Elgato Keylight(s) on...", targets.len()); } - let brightness_str = matches.value_of("brightness").unwrap(); - let brightness: u8 = match brightness_str { - "low" => 10, - "medium" => 50, - "high" => 100, - s => { - let s = s.strip_suffix('%').unwrap_or(s); - match s.parse::() { - Ok(v) if v <= 100 => v, - _ => { - eprintln!("Error: Brightness must be 0-100 or a preset (low, medium, high)."); - std::process::exit(1); + let has_brightness = matches.occurrences_of("brightness") > 0; + let has_temperature = matches.occurrences_of("temperature") > 0; + + let brightness: Option = if has_brightness { + let brightness_str = matches.value_of("brightness").unwrap(); + Some(match brightness_str { + "low" => 10, + "medium" => 50, + "high" => 100, + s => { + let s = s.strip_suffix('%').unwrap_or(s); + match s.parse::() { + Ok(v) if v <= 100 => v, + _ => { + eprintln!("Error: Brightness must be 0-100 or a preset (low, medium, high)."); + std::process::exit(1); + } } } - } + }) + } else { + None }; - let temperature_str = matches.value_of("temperature").unwrap(); - let temperature: f32 = match temperature_str { - "warm" => 344.0, - "medium" => 213.0, - "cool" => 143.0, - s => match s.parse::() { - Ok(v) => v, - _ => { - eprintln!("Error: Temperature must be a number (143-344) or a preset (warm, medium, cool)."); - std::process::exit(1); - } - }, + let temperature: Option = if has_temperature { + let temperature_str = matches.value_of("temperature").unwrap(); + Some(match temperature_str { + "warm" => 344.0, + "medium" => 213.0, + "cool" => 143.0, + s => match s.parse::() { + Ok(v) => v, + _ => { + eprintln!("Error: Temperature must be a number (143-344) or a preset (warm, medium, cool)."); + std::process::exit(1); + } + }, + }) + } else { + None }; - let body = json!({ + let mut body = json!({ "numberOfLights":1, "lights":[ { - "on":switch, - "brightness":brightness, - "temperature":temperature + "on":switch } ] }); + if let Some(b) = brightness { + body["lights"][0]["brightness"] = json!(b); + } + if let Some(t) = temperature { + body["lights"][0]["temperature"] = json!(t); + } let client = Client::new(); let mut errors = Vec::new(); From 5db324c21bed746f9317e3456939f503904f2943 Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Tue, 31 Mar 2026 00:18:37 -0500 Subject: [PATCH 07/10] Simplify daemon setup with autodiscovery Update onair.sh to use --discover flag instead of requiring IP env vars. Remove elgato_ip and number_of_lights from the plist. Simplify README daemon setup instructions by removing the IP address configuration step. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 15 ++++++++------- com.keylight.daemon.plist | 4 ---- onair.sh | 4 ++-- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2135e4e..bf17566 100644 --- a/README.md +++ b/README.md @@ -95,22 +95,23 @@ OPTIONS: ## Setting up as daemon on macOS +The daemon watches your camera state and automatically turns your Elgato Keylights on/off using autodiscovery. + ```sh # clone this repo # from within root of this repo folder -mkdir -p ~/bin && cp onair.sh ~/bin # makes a bin directory in your user's home folder, copies onair script to that folder +mkdir -p ~/bin && cp onair.sh ~/bin -# add your ip address and system username to the plist file -sed -i 's//your-elgato-ip-address-here/g' com.keylight.daemon.plist -sed -i 's//your-username-here/g' com.keylight.daemon.plist +# add your system username to the plist file +sed -i '' 's//your-username-here/g' com.keylight.daemon.plist # copy updated plist to launchdaemon folder cp com.keylight.daemon.plist /Library/LaunchDaemons/com.keylight.daemon.plist -# load/start daemon/plist +# load/start daemon sudo launchctl load -w /Library/LaunchDaemons/com.keylight.daemon.plist # view logs -tail -f /tmp/keylight.stdout #standard out logs -tail -f /tmp/keylight.stderr #standard error logs +tail -f /tmp/keylight.stdout +tail -f /tmp/keylight.stderr ``` \ No newline at end of file diff --git a/com.keylight.daemon.plist b/com.keylight.daemon.plist index 251713f..e76a30c 100644 --- a/com.keylight.daemon.plist +++ b/com.keylight.daemon.plist @@ -6,10 +6,6 @@ PATH /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin: - elgato_ip - - number_of_lights - 1 GroupName wheel diff --git a/onair.sh b/onair.sh index a884604..53c87b6 100755 --- a/onair.sh +++ b/onair.sh @@ -9,9 +9,9 @@ grep -E --line-buffered '(stop|start) stream' | # filter log events echo "Camera state has changed to: $event" if [ "$event" = "start" ]; then # echo "Keylight on" - keylight on + keylight on --discover else # echo "Keylight off" - keylight off + keylight off --discover fi done \ No newline at end of file From 27f113dfa64182de5b3a085d253e2a9f3f53dc33 Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Tue, 31 Mar 2026 00:34:24 -0500 Subject: [PATCH 08/10] Add scheduling section and fix build instructions in README Add cron examples for weekday light scheduling. Update build instructions to use --release flag and cp instead of mv. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf17566..90ad691 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ This is a cross platform lightweight CLI tool to simply and easily control your This app should build with minimal dependencies. It's been tested with Rust 1.60+ on macOS and 2 Elgato Keylights. ```sh -cargo build -sudo mv target/debug/keylight /usr/local/bin/keylight +cargo build --release +sudo cp target/release/keylight /usr/local/bin/keylight ``` ## Running The App @@ -93,6 +93,21 @@ OPTIONS: Print version information ``` +## Scheduling + +You can use cron to automatically turn your lights on and off on a schedule. For example, to turn lights on at 8am and off at 7pm on weekdays: + +```sh +crontab -e +``` + +Add the following lines: + +``` +0 8 * * 1-5 /usr/local/bin/keylight on --discover +0 19 * * 1-5 /usr/local/bin/keylight off --discover +``` + ## Setting up as daemon on macOS The daemon watches your camera state and automatically turns your Elgato Keylights on/off using autodiscovery. From 031549296d3bc77a977a454b577e95a4fe1f68cc Mon Sep 17 00:00:00 2001 From: Thompson Marzagao Date: Tue, 31 Mar 2026 08:27:21 -0500 Subject: [PATCH 09/10] Improve discovery reliability with polling and grace period Use 1-second polling intervals instead of blocking for the full timeout duration. After finding at least one light, wait a 5-second grace period for additional lights before stopping. This reliably finds all lights on the network while keeping response times reasonable. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/discovery.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/discovery.rs b/src/discovery.rs index 39cbe0d..8151f0c 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -20,14 +20,26 @@ pub fn discover_lights( let mut lights = Vec::new(); let mut seen_names = HashSet::new(); let start = Instant::now(); + let mut last_found = None::; + let grace_period = Duration::from_secs(5); + let poll_interval = Duration::from_secs(1); while start.elapsed() < timeout { + // After finding at least one light, stop if no new light found within grace period + if let Some(last) = last_found { + if last.elapsed() >= grace_period { + log::info!("No new lights found in {}s, stopping discovery", grace_period.as_secs()); + break; + } + } + let remaining = timeout.saturating_sub(start.elapsed()); if remaining.is_zero() { break; } + let wait = remaining.min(poll_interval); - match receiver.recv_timeout(remaining) { + match receiver.recv_timeout(wait) { Ok(ServiceEvent::ServiceResolved(info)) => { let name = info.get_fullname().to_string(); if seen_names.contains(&name) { @@ -63,6 +75,7 @@ pub fn discover_lights( ip, port: info.get_port(), }); + last_found = Some(Instant::now()); } Ok(_) => {} Err(_) => {} From 95d4b77e0b00fca2d327ae2693aa8c2603cdd1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thompson=20Marzag=C3=A3o?= Date: Fri, 8 May 2026 15:08:19 -0500 Subject: [PATCH 10/10] Update CI badge link in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90ad691..d9d9399 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![CI](https://github.com/jldeen/keylight-cli/actions/workflows/build-ci.yml/badge.svg)](https://github.com/jldeen/keylight-cli/actions/workflows/build-ci.yml) +[![CI](https://github.com/marzagao/keylight-cli/actions/workflows/build-ci.yml/badge.svg)](https://github.com/marzagao/keylight-cli/actions/workflows/build-ci.yml) # Elgato Keylight CLI @@ -129,4 +129,4 @@ sudo launchctl load -w /Library/LaunchDaemons/com.keylight.daemon.plist # view logs tail -f /tmp/keylight.stdout tail -f /tmp/keylight.stderr -``` \ No newline at end of file +```