diff --git a/Cargo.lock b/Cargo.lock index ccb99d5..b60c7c9 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" @@ -438,13 +474,14 @@ dependencies = [ [[package]] name = "keylight" -version = "0.2.3" +version = "0.3.0" dependencies = [ "chrono", "clap", "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..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 @@ -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..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 @@ -9,32 +9,59 @@ 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. -- [ ] Autodiscovery support. -- [ ] Testing with more than 1 Elgato Keylight. +- [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** ## 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 +cargo build --release +sudo cp target/release/keylight /usr/local/bin/keylight ``` ## 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 +``` + +### 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.2.3 -Jessica Deen +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, @@ -42,19 +69,22 @@ 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 -h, --help Print help information -i, --elgato-ip - Elgato Keylight IP address [env: elgato_ip=192.168.184.166] - - -n, --number-of-lights - Number of Elgato Keylights in use [env: number_of_lights=1] + Elgato Keylight IP address [env: elgato_ip=] -t, --temperature - Temperature value for light [env: temperature=] [default: 213] + Temperature: value (143-344) or preset (warm, medium, cool) [env: temperature=] [default: 213] + + --timeout + Discovery timeout in seconds [default: 5] -v, --verbose Log Level @@ -63,24 +93,40 @@ 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. + ```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 -``` \ No newline at end of file +tail -f /tmp/keylight.stdout +tail -f /tmp/keylight.stderr +``` 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 diff --git a/src/cli.rs b/src/cli.rs index 01772a9..a77ebbf 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"), @@ -38,19 +36,24 @@ 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), ) .arg( - Arg::new("number_of_lights") - .long("number-of-lights") - .short('n') - .help("Number of Elgato Keylights in use") - .required(true) - .aliases(&["number_of_lights", "number-of-lights", "number of lights"]) - .env("number_of_lights") + Arg::new("discover") + .long("discover") + .short('d') + .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("10") .takes_value(true), ) .arg( diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..8151f0c --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,87 @@ +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(); + 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(wait) { + 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() + ); + + let display_name = name + .strip_suffix(SERVICE_TYPE) + .unwrap_or(&name) + .trim_end_matches('.') + .to_string(); + + seen_names.insert(name); + lights.push(DiscoveredLight { + name: display_name, + ip, + port: info.get_port(), + }); + last_found = Some(Instant::now()); + } + Ok(_) => {} + Err(_) => {} + } + } + + mdns.shutdown()?; + Ok(lights) +} diff --git a/src/main.rs b/src/main.rs index 878dc0b..cd6d8d7 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,8 +40,34 @@ async fn main() -> Result<(), Box> { .parse_env("LOG_LEVEL") .init(); - let elgato_ip = matches.value_of("elgato_ip").unwrap(); - let numberoflights = matches.value_of("number_of_lights").unwrap(); + // Determine target lights + let discover = matches.is_present("discover"); + let elgato_ip = matches.value_of("elgato_ip"); + + let targets: Vec<(String, u16, Option)> = if discover { + println!("Discovering Elgato Keylights on the network..."); + 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); + } + for light in &lights { + println!(" Found: {} ({}:{})", light.name, light.ip, light.port); + } + 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, None)] + } else { + eprintln!("Error: Either --discover or --elgato-ip must be specified."); + std::process::exit(1); + }; let switch = match matches.value_of("switch").unwrap() { "off" => 0, @@ -47,68 +76,134 @@ async fn main() -> Result<(), Box> { _ => 0, }; - // status if switch == 0 { - let power_status = "off"; - println!("Elgato Keylight is: {}", power_status); + println!("Turning {} Elgato Keylight(s) off...", targets.len()); } else if switch == 1 { - let power_status = "on"; - println!("Elgato Keylight is: {}", power_status); + println!("Turning {} Elgato Keylight(s) on...", targets.len()); } - let brightness = matches - .value_of("brightness") - .and_then(|s| s.parse::().ok()) - .unwrap(); + 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 = matches - .value_of("temperature") - .and_then(|s| s.parse::().ok()) - .unwrap(); + 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!({ - "numberOfLights":numberoflights, + let mut body = json!({ + "numberOfLights":1, "lights":[ { - "on":switch, - "brightness":brightness, - "temperature":temperature + "on":switch } ] }); - - let url = format!("http://{}:{}", elgato_ip, "9123/elgato/lights"); - log::info!("status: {}", url); + 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(); + + for (ip, port, name) in &targets { + let url = format!("http://{}:{}/elgato/lights", ip, port); + log::info!("Sending request to: {}", url); + + 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 = client.put(url).json(&body).send().await?; - - // response status code - let response_success = response.status(); - log::info!("Response status: {}", response_success); - - // body - let response_body = response.text().await?; - log::info!("Response text: {}", response_body); - - // Json data - let response_json: serde_json::Value = serde_json::from_str(&response_body)?; - log::info!("Response json: {:?}", response_json); - - if switch == 2 { - let v: Value = serde_json::from_str(&response_body)?; - - let query = &v["lights"][0]["on"]; - - if query == 0 { - let status = "off"; - println!("Elgato light is: {}", status); - } else if query == 1 { - let status = "on"; - println!("Elgato light is: {}", status); + 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(()) }