diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index b75f029..9f2149b 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -23,11 +23,6 @@ jobs: with: fetch-depth: 2 - - name: Generate Cargo.lock - uses: actions-rs/cargo@v1 - with: - command: generate-lockfile - - name: Set up Python uses: actions/setup-python@v2 diff --git a/Cargo.lock b/Cargo.lock index b330982..acdc5b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,24 +178,24 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] name = "serde" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", @@ -204,9 +204,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.113" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "itoa", "ryu", @@ -221,9 +221,9 @@ checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" [[package]] name = "syn" -version = "2.0.49" +version = "2.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 78d4e8e..964745e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,15 @@ keywords = ["logitech", "litra", "glow", "beam", "light"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = { version = "4.5.0", features = ["derive"] } hidapi = "2.6.0" -serde = { version = "1.0.196", features = ["derive"] } -serde_json = "1.0.113" +clap = { version = "4.5.0", features = ["derive"], optional = true } +serde = { version = "1.0.196", features = ["derive"], optional = true } +serde_json = { version = "1.0.113", optional = true } + +[features] +default = ["cli"] +cli = ["dep:clap", "dep:serde", "dep:serde_json"] + +[[bin]] +name = "litra" +required-features = ["cli"] diff --git a/src/lib.rs b/src/lib.rs index cab1757..4c59c36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,90 @@ -use hidapi::{HidApi, HidDevice}; -use serde::Serialize; +//! Library to query and control your Logitech Litra lights. +//! +//! # Usage +//! +//! ``` +//! use litra::Litra; +//! +//! let context = Litra::new().expect("Failed to initialize litra."); +//! for device in context.get_connected_devices() { +//! println!("Device {:?}", device.device_type()); +//! if let Ok(handle) = device.open(&context) { +//! println!("| - Is on: {}", handle.is_on() +//! .map(|on| if on { "yes" } else { "no" }) +//! .unwrap_or("unknown")); +//! } +//! } +//! ``` + +#![warn(unsafe_code)] +#![warn(missing_docs)] +#![cfg_attr(not(debug_assertions), deny(warnings))] +#![deny(rust_2018_idioms)] +#![deny(rust_2021_compatibility)] +#![deny(missing_debug_implementations)] +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(clippy::all)] +#![deny(clippy::explicit_deref_methods)] +#![deny(clippy::explicit_into_iter_loop)] +#![deny(clippy::explicit_iter_loop)] +#![deny(clippy::must_use_candidate)] +#![cfg_attr(not(test), deny(clippy::panic_in_result_fn))] +#![cfg_attr(not(debug_assertions), deny(clippy::used_underscore_binding))] + +use hidapi::{DeviceInfo, HidApi, HidDevice, HidError}; +use std::error::Error; use std::fmt; -#[derive(Debug, Serialize)] +/// Litra context. +/// +/// This can be used to list available devices. +pub struct Litra(HidApi); + +impl fmt::Debug for Litra { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Litra").finish() + } +} + +impl Litra { + /// Initialize a new Litra context. + pub fn new() -> DeviceResult { + Ok(HidApi::new().map(Litra)?) + } + + /// Returns an [`Iterator`] of connected devices supported by this library. + pub fn get_connected_devices(&self) -> impl Iterator> { + self.0 + .device_list() + .filter_map(|device_info| Device::try_from(device_info).ok()) + } + + /// Retrieve the underlying hidapi context. + #[must_use] + pub fn hidapi(&self) -> &HidApi { + &self.0 + } +} + +/// The model of the device. +#[derive(Debug, Clone, Copy, PartialEq)] pub enum DeviceType { - #[serde(rename = "Litra Glow")] + /// Logitech [Litra Glow][glow] streaming light with TrueSoft. + /// + /// [glow]: https://www.logitech.com/products/lighting/litra-glow.html LitraGlow, - #[serde(rename = "Litra Beam")] + /// Logitech [Litra Beam][beam] LED streaming key light with TrueSoft. + /// + /// [beam]: https://www.logitechg.com/products/cameras-lighting/litra-beam-streaming-light.html LitraBeam, - #[serde(rename = "Litra Beam LX")] + /// Logitech [Litra Beam LX][beamlx] dual-sided RGB streaming key light. + /// + /// [beamlx]: https://www.logitechg.com/products/cameras-lighting/litra-beam-lx-led-light.html LitraBeamLX, } impl fmt::Display for DeviceType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { DeviceType::LitraGlow => write!(f, "Litra Glow"), DeviceType::LitraBeam => write!(f, "Litra Beam"), @@ -22,103 +93,237 @@ impl fmt::Display for DeviceType { } } -#[derive(Serialize, Debug)] -pub struct Device { - pub serial_number: String, - pub device_type: DeviceType, - pub is_on: bool, - pub brightness_in_lumen: u16, - pub temperature_in_kelvin: u16, - #[serde(skip_serializing)] - pub device_handle: HidDevice, - pub minimum_brightness_in_lumen: u16, - pub maximum_brightness_in_lumen: u16, - pub minimum_temperature_in_kelvin: u16, - pub maximum_temperature_in_kelvin: u16, +/// A device-relatred error. +#[derive(Debug)] +pub enum DeviceError { + /// Tried to use a device that is not supported. + Unsupported, + /// Tried to set an invalid brightness value. + InvalidBrightness(u16), + /// Tried to set an invalid temperature value. + InvalidTemperature(u16), + /// A [`hidapi`] operation failed. + HidError(HidError), } -const VENDOR_ID: u16 = 0x046d; -const PRODUCT_IDS: [u16; 4] = [0xc900, 0xc901, 0xb901, 0xc903]; -const USAGE_PAGE: u16 = 0xff43; - -fn get_device_type(product_id: u16) -> DeviceType { - match product_id { - 0xc900 => DeviceType::LitraGlow, - 0xc901 => DeviceType::LitraBeam, - 0xb901 => DeviceType::LitraBeam, - 0xc903 => DeviceType::LitraBeamLX, - _ => panic!("Unknown product ID"), +impl fmt::Display for DeviceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DeviceError::Unsupported => write!(f, "Device is not supported."), + DeviceError::InvalidBrightness(value) => { + write!(f, "Brightness in Lumen '{}' is not supported.", value) + } + DeviceError::InvalidTemperature(value) => { + write!(f, "Temperature in Kelvin '{}' is not supported.", value) + } + DeviceError::HidError(error) => write!(f, "HID error occurred: {}", error), + } } } -fn get_minimum_brightness_in_lumen(device_type: &DeviceType) -> u16 { - match device_type { - DeviceType::LitraGlow => 20, - DeviceType::LitraBeam | DeviceType::LitraBeamLX => 30, +impl Error for DeviceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + if let DeviceError::HidError(error) = self { + Some(error) + } else { + None + } } } -fn get_maximum_brightness_in_lumen(device_type: &DeviceType) -> u16 { - match device_type { - DeviceType::LitraGlow => 250, - DeviceType::LitraBeam | DeviceType::LitraBeamLX => 400, +impl From for DeviceError { + fn from(error: HidError) -> Self { + DeviceError::HidError(error) } } -const MINIMUM_TEMPERATURE_IN_KELVIN: u16 = 2700; -const MAXIMUM_TEMPERATURE_IN_KELVIN: u16 = 6500; +/// The [`Result`] of a Litra device operation. +pub type DeviceResult = Result; -pub fn get_connected_devices<'a>( - api: &'a HidApi, - serial_number: Option<&'a str>, -) -> impl Iterator + 'a { - let litra_devices = api - .device_list() - .filter(|device| { - device.vendor_id() == VENDOR_ID - && PRODUCT_IDS.contains(&device.product_id()) - && device.usage_page() == USAGE_PAGE - }) - .filter(move |device| { - serial_number.is_none() - || serial_number.as_ref().is_some_and(|expected| { - device - .serial_number() - .is_some_and(|actual| &actual == expected) - }) - }); - - litra_devices - .filter_map(|device| match api.open_path(device.path()) { - Ok(device_handle) => Some((device, device_handle)), - Err(err) => { - println!("Failed to open device {:?}: {:?}", device.path(), err); - None - } - }) - .map(|(device, device_handle)| { - let device_type = get_device_type(device.product_id()); - let is_on = is_on(&device_handle, &device_type); - let brightness_in_lumen = get_brightness_in_lumen(&device_handle, &device_type); - let temperature_in_kelvin = get_temperature_in_kelvin(&device_handle, &device_type); - let minimum_brightness_in_lumen = get_minimum_brightness_in_lumen(&device_type); - let maximum_brightness_in_lumen = get_maximum_brightness_in_lumen(&device_type); - - Device { - serial_number: device.serial_number().unwrap_or("").to_string(), +/// A device that can be used. +#[derive(Debug)] +pub struct Device<'a> { + device_info: &'a DeviceInfo, + device_type: DeviceType, +} + +impl<'a> TryFrom<&'a DeviceInfo> for Device<'a> { + type Error = DeviceError; + + fn try_from(device_info: &'a DeviceInfo) -> Result { + if device_info.vendor_id() != VENDOR_ID || device_info.usage_page() != USAGE_PAGE { + return Err(DeviceError::Unsupported); + } + device_type_from_product_id(device_info.product_id()) + .map(|device_type| Device { + device_info, device_type, - is_on, - brightness_in_lumen, - temperature_in_kelvin, - device_handle, - minimum_brightness_in_lumen, - maximum_brightness_in_lumen, - minimum_temperature_in_kelvin: MINIMUM_TEMPERATURE_IN_KELVIN, - maximum_temperature_in_kelvin: MAXIMUM_TEMPERATURE_IN_KELVIN, - } + }) + .ok_or(DeviceError::Unsupported) + } +} + +impl Device<'_> { + /// The model of the device. + #[must_use] + pub fn device_info(&self) -> &DeviceInfo { + self.device_info + } + + /// The model of the device. + #[must_use] + pub fn device_type(&self) -> DeviceType { + self.device_type + } + + /// Opens the device and returns a [`DeviceHandle`] that can be used for getting and setting the + /// device status. + pub fn open(&self, context: &Litra) -> DeviceResult { + let hid_device = self.device_info.open_device(context.hidapi())?; + Ok(DeviceHandle { + hid_device, + device_type: self.device_type, }) + } +} + +/// The handle of an opened device that can be used for getting and setting the device status. +#[derive(Debug)] +pub struct DeviceHandle { + hid_device: HidDevice, + device_type: DeviceType, +} + +impl DeviceHandle { + /// The model of the device. + #[must_use] + pub fn device_type(&self) -> DeviceType { + self.device_type + } + + /// Queries the current power status of the device. Returns `true` if the device is currently on. + pub fn is_on(&self) -> DeviceResult { + let message = generate_is_on_bytes(&self.device_type); + + self.hid_device.write(&message)?; + + let mut response_buffer = [0x00; 20]; + let response = self.hid_device.read(&mut response_buffer[..])?; + + Ok(response_buffer[..response][4] == 1) + } + + /// Sets the power status of the device. Turns the device on if `true` is passed and turns it + /// of on `false`. + pub fn set_on(&self, on: bool) -> DeviceResult<()> { + let message = generate_set_on_bytes(&self.device_type, on); + + self.hid_device.write(&message)?; + Ok(()) + } + + /// Queries the device's current brightness in Lumen. + pub fn brightness_in_lumen(&self) -> DeviceResult { + let message = generate_get_brightness_in_lumen_bytes(&self.device_type); + + self.hid_device.write(&message)?; + + let mut response_buffer = [0x00; 20]; + let response = self.hid_device.read(&mut response_buffer[..])?; + + Ok(response_buffer[..response][5].into()) + } + + /// Sets the device's brightness in Lumen. + pub fn set_brightness_in_lumen(&self, brightness_in_lumen: u16) -> DeviceResult<()> { + if brightness_in_lumen < self.minimum_brightness_in_lumen() + || brightness_in_lumen > self.maximum_brightness_in_lumen() + { + return Err(DeviceError::InvalidBrightness(brightness_in_lumen)); + } + + let message = + generate_set_brightness_in_lumen_bytes(&self.device_type, brightness_in_lumen); + + self.hid_device.write(&message)?; + Ok(()) + } + + /// Returns the minimum brightness supported by the device in Lumen. + #[must_use] + pub fn minimum_brightness_in_lumen(&self) -> u16 { + match self.device_type { + DeviceType::LitraGlow => 20, + DeviceType::LitraBeam | DeviceType::LitraBeamLX => 30, + } + } + + /// Returns the maximum brightness supported by the device in Lumen. + #[must_use] + pub fn maximum_brightness_in_lumen(&self) -> u16 { + match self.device_type { + DeviceType::LitraGlow => 250, + DeviceType::LitraBeam | DeviceType::LitraBeamLX => 400, + } + } + + /// Queries the device's current color temperature in Kelvin. + pub fn temperature_in_kelvin(&self) -> DeviceResult { + let message = generate_get_temperature_in_kelvin_bytes(&self.device_type); + + self.hid_device.write(&message)?; + + let mut response_buffer = [0x00; 20]; + let response = self.hid_device.read(&mut response_buffer[..])?; + Ok(u16::from(response_buffer[..response][4]) * 256 + + u16::from(response_buffer[..response][5])) + } + + /// Sets the device's color temperature in Kelvin. + pub fn set_temperature_in_kelvin(&self, temperature_in_kelvin: u16) -> DeviceResult<()> { + if self.minimum_temperature_in_kelvin() < temperature_in_kelvin + || temperature_in_kelvin > self.maximum_temperature_in_kelvin() + || (temperature_in_kelvin % 100) != 0 + { + return Err(DeviceError::InvalidTemperature(temperature_in_kelvin)); + } + + let message = + generate_set_temperature_in_kelvin_bytes(&self.device_type, temperature_in_kelvin); + + self.hid_device.write(&message)?; + Ok(()) + } + + /// Returns the minimum color temperature supported by the device in Kelvin. + #[must_use] + pub fn minimum_temperature_in_kelvin(&self) -> u16 { + MINIMUM_TEMPERATURE_IN_KELVIN + } + + /// Returns the maximum color temperature supported by the device in Kelvin. + #[must_use] + pub fn maximum_temperature_in_kelvin(&self) -> u16 { + MAXIMUM_TEMPERATURE_IN_KELVIN + } } +const VENDOR_ID: u16 = 0x046d; +const USAGE_PAGE: u16 = 0xff43; + +fn device_type_from_product_id(product_id: u16) -> Option { + match product_id { + 0xc900 => DeviceType::LitraGlow.into(), + 0xc901 => DeviceType::LitraBeam.into(), + 0xb901 => DeviceType::LitraBeam.into(), + 0xc903 => DeviceType::LitraBeamLX.into(), + _ => None, + } +} + +const MINIMUM_TEMPERATURE_IN_KELVIN: u16 = 2700; +const MAXIMUM_TEMPERATURE_IN_KELVIN: u16 = 6500; + fn generate_is_on_bytes(device_type: &DeviceType) -> [u8; 20] { match device_type { DeviceType::LitraGlow | DeviceType::LitraBeam => [ @@ -132,17 +337,6 @@ fn generate_is_on_bytes(device_type: &DeviceType) -> [u8; 20] { } } -pub fn is_on(device_handle: &HidDevice, device_type: &DeviceType) -> bool { - let message = generate_is_on_bytes(device_type); - - device_handle.write(&message).unwrap(); - - let mut response_buffer = [0x00; 20]; - let response = device_handle.read(&mut response_buffer[..]).unwrap(); - - response_buffer[..response][4] == 1 -} - fn generate_get_brightness_in_lumen_bytes(device_type: &DeviceType) -> [u8; 20] { match device_type { DeviceType::LitraGlow | DeviceType::LitraBeam => [ @@ -156,17 +350,6 @@ fn generate_get_brightness_in_lumen_bytes(device_type: &DeviceType) -> [u8; 20] } } -pub fn get_brightness_in_lumen(device_handle: &HidDevice, device_type: &DeviceType) -> u16 { - let message = generate_get_brightness_in_lumen_bytes(device_type); - - device_handle.write(&message).unwrap(); - - let mut response_buffer = [0x00; 20]; - let response = device_handle.read(&mut response_buffer[..]).unwrap(); - - response_buffer[..response][5].into() -} - fn generate_get_temperature_in_kelvin_bytes(device_type: &DeviceType) -> [u8; 20] { match device_type { DeviceType::LitraGlow | DeviceType::LitraBeam => [ @@ -180,54 +363,20 @@ fn generate_get_temperature_in_kelvin_bytes(device_type: &DeviceType) -> [u8; 20 } } -pub fn get_temperature_in_kelvin(device_handle: &HidDevice, device_type: &DeviceType) -> u16 { - let message = generate_get_temperature_in_kelvin_bytes(device_type); - - device_handle.write(&message).unwrap(); - - let mut response_buffer = [0x00; 20]; - let response = device_handle.read(&mut response_buffer[..]).unwrap(); - u16::from(response_buffer[..response][4]) * 256 + u16::from(response_buffer[..response][5]) -} - -fn generate_turn_on_bytes(device_type: &DeviceType) -> [u8; 20] { +fn generate_set_on_bytes(device_type: &DeviceType, on: bool) -> [u8; 20] { + let on_byte = if on { 0x01 } else { 0x00 }; match device_type { DeviceType::LitraGlow | DeviceType::LitraBeam => [ - 0x11, 0xff, 0x04, 0x1c, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x11, 0xff, 0x04, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ], DeviceType::LitraBeamLX => [ - 0x11, 0xff, 0x06, 0x1c, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x11, 0xff, 0x06, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ], } } -pub fn turn_on(device_handle: &HidDevice, device_type: &DeviceType) { - let message = generate_turn_on_bytes(device_type); - - device_handle.write(&message).unwrap(); -} - -fn generate_turn_off_bytes(device_type: &DeviceType) -> [u8; 20] { - match device_type { - DeviceType::LitraGlow | DeviceType::LitraBeam => [ - 0x11, 0xff, 0x04, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - DeviceType::LitraBeamLX => [ - 0x11, 0xff, 0x06, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ], - } -} - -pub fn turn_off(device_handle: &HidDevice, device_type: &DeviceType) { - let message = generate_turn_off_bytes(device_type); - - device_handle.write(&message).unwrap(); -} - fn generate_set_brightness_in_lumen_bytes( device_type: &DeviceType, brightness_in_lumen: u16, @@ -282,16 +431,6 @@ fn generate_set_brightness_in_lumen_bytes( } } -pub fn set_brightness_in_lumen( - device_handle: &HidDevice, - device_type: &DeviceType, - brightness_in_lumen: u16, -) { - let message = generate_set_brightness_in_lumen_bytes(device_type, brightness_in_lumen); - - device_handle.write(&message).unwrap(); -} - fn generate_set_temperature_in_kelvin_bytes( device_type: &DeviceType, temperature_in_kelvin: u16, @@ -345,13 +484,3 @@ fn generate_set_temperature_in_kelvin_bytes( ], } } - -pub fn set_temperature_in_kelvin( - device_handle: &HidDevice, - device_type: &DeviceType, - temperature_in_kelvin: u16, -) { - let message = generate_set_temperature_in_kelvin_bytes(device_type, temperature_in_kelvin); - - device_handle.write(&message).unwrap(); -} diff --git a/src/main.rs b/src/main.rs index 06d10ed..e7308d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ use clap::{ArgGroup, Parser, Subcommand}; -use litra::{ - get_connected_devices, set_brightness_in_lumen, set_temperature_in_kelvin, turn_off, turn_on, - Device, -}; +use litra::{Device, DeviceError, DeviceHandle, Litra}; +use serde::Serialize; +use std::fmt; +use std::num::TryFromIntError; use std::process::ExitCode; /// Control your USB-connected Logitech Litra lights from the command line @@ -91,164 +91,215 @@ fn get_is_on_emoji(is_on: bool) -> &'static str { } } -fn multiples_within_range(multiples_of: u16, start_range: u16, end_range: u16) -> Vec { - (start_range..=end_range) - .filter(|n| n % multiples_of == 0) - .collect() +fn check_serial_number_if_some(serial_number: Option<&str>) -> impl Fn(&Device) -> bool + '_ { + move |device| { + serial_number.as_ref().map_or(true, |expected| { + device + .device_info() + .serial_number() + .is_some_and(|actual| &actual == expected) + }) + } } -fn main() -> ExitCode { - let args = Cli::parse(); - let api = hidapi::HidApi::new().unwrap(); - - match &args.command { - Commands::Devices { json } => { - let litra_devices: Vec = get_connected_devices(&api, None).collect(); - - if *json { - println!("{}", serde_json::to_string(&litra_devices).unwrap()); - } else { - for device in &litra_devices { - println!( - "- {} ({}): {} {}", - device.device_type, - device.serial_number, - get_is_on_text(device.is_on), - get_is_on_emoji(device.is_on) - ); - - println!(" - Brightness: {} lm", device.brightness_in_lumen,); - println!(" - Minimum: {} lm", device.minimum_brightness_in_lumen); - println!(" - Maximum: {} lm", device.maximum_brightness_in_lumen); - println!(" - Temperature: {} K", device.temperature_in_kelvin); - println!(" - Minimum: {} K", device.minimum_temperature_in_kelvin); - println!(" - Maximum: {} K", device.maximum_temperature_in_kelvin); - } - - if litra_devices.is_empty() { - println!("No devices found"); - } +#[derive(Debug)] +enum CliError { + DeviceError(DeviceError), + SerializationFailed(serde_json::Error), + BrightnessPrecentageCalculationFailed(TryFromIntError), + DeviceNotFound, +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CliError::DeviceError(error) => error.fmt(f), + CliError::SerializationFailed(error) => error.fmt(f), + CliError::BrightnessPrecentageCalculationFailed(error) => { + write!(f, "Failed to calculate brightness: {}", error) } - ExitCode::SUCCESS + CliError::DeviceNotFound => write!(f, "Device not found."), } - Commands::On { serial_number } => { - let device = match get_connected_devices(&api, serial_number.as_deref()).next() { - Some(dev) => dev, - None => { - println!("Device not found"); - return ExitCode::FAILURE; - } - }; - - turn_on(&device.device_handle, &device.device_type); - ExitCode::SUCCESS + } +} + +impl From for CliError { + fn from(error: DeviceError) -> Self { + CliError::DeviceError(error) + } +} + +type CliResult = Result<(), CliError>; + +fn get_first_supported_device( + context: &Litra, + serial_number: Option<&str>, +) -> Result { + context + .get_connected_devices() + .find(check_serial_number_if_some(serial_number)) + .ok_or(CliError::DeviceNotFound) + .and_then(|dev| dev.open(context).map_err(CliError::DeviceError)) +} + +#[derive(Serialize, Debug)] +struct DeviceInfo { + pub serial_number: String, + pub device_type: String, + pub is_on: bool, + pub brightness_in_lumen: u16, + pub temperature_in_kelvin: u16, + pub minimum_brightness_in_lumen: u16, + pub maximum_brightness_in_lumen: u16, + pub minimum_temperature_in_kelvin: u16, + pub maximum_temperature_in_kelvin: u16, +} + +fn handle_devices_command(json: bool) -> CliResult { + let context = Litra::new()?; + let litra_devices: Vec = context + .get_connected_devices() + .filter_map(|device| { + let device_handle = device.open(&context).ok()?; + Some(DeviceInfo { + serial_number: device + .device_info() + .serial_number() + .unwrap_or("") + .to_string(), + device_type: device.device_type().to_string(), + is_on: device_handle.is_on().ok()?, + brightness_in_lumen: device_handle.brightness_in_lumen().ok()?, + temperature_in_kelvin: device_handle.temperature_in_kelvin().ok()?, + minimum_brightness_in_lumen: device_handle.minimum_brightness_in_lumen(), + maximum_brightness_in_lumen: device_handle.maximum_brightness_in_lumen(), + minimum_temperature_in_kelvin: device_handle.minimum_temperature_in_kelvin(), + maximum_temperature_in_kelvin: device_handle.maximum_temperature_in_kelvin(), + }) + }) + .collect(); + + if json { + println!( + "{}", + serde_json::to_string(&litra_devices).map_err(CliError::SerializationFailed)? + ); + Ok(()) + } else { + for device_info in &litra_devices { + println!( + "- {} ({}): {} {}", + device_info.device_type, + device_info.serial_number, + get_is_on_text(device_info.is_on), + get_is_on_emoji(device_info.is_on) + ); + + println!(" - Brightness: {} lm", device_info.brightness_in_lumen); + println!( + " - Minimum: {} lm", + device_info.minimum_brightness_in_lumen + ); + println!( + " - Maximum: {} lm", + device_info.maximum_brightness_in_lumen + ); + println!(" - Temperature: {} K", device_info.temperature_in_kelvin); + println!( + " - Minimum: {} K", + device_info.minimum_temperature_in_kelvin + ); + println!( + " - Maximum: {} K", + device_info.maximum_temperature_in_kelvin + ); } - Commands::Off { serial_number } => { - let device = match get_connected_devices(&api, serial_number.as_deref()).next() { - Some(dev) => dev, - None => { - println!("Device not found"); - return ExitCode::FAILURE; - } - }; - - turn_off(&device.device_handle, &device.device_type); - ExitCode::SUCCESS + Ok(()) + } +} + +fn handle_on_command(serial_number: Option<&str>) -> CliResult { + let context = Litra::new()?; + let device_handle = get_first_supported_device(&context, serial_number)?; + device_handle.set_on(true)?; + Ok(()) +} + +fn handle_off_command(serial_number: Option<&str>) -> CliResult { + let context = Litra::new()?; + let device_handle = get_first_supported_device(&context, serial_number)?; + device_handle.set_on(false)?; + Ok(()) +} + +fn handle_toggle_command(serial_number: Option<&str>) -> CliResult { + let context = Litra::new()?; + let device_handle = get_first_supported_device(&context, serial_number)?; + let is_on = device_handle.is_on()?; + device_handle.set_on(!is_on)?; + Ok(()) +} + +fn handle_brightness_command( + serial_number: Option<&str>, + value: Option, + percentage: Option, +) -> CliResult { + let context = Litra::new()?; + let device_handle = get_first_supported_device(&context, serial_number)?; + + match (value, percentage) { + (Some(_), None) => { + let brightness_in_lumen = value.unwrap(); + device_handle.set_brightness_in_lumen(brightness_in_lumen)?; } - Commands::Toggle { serial_number } => { - let device = match get_connected_devices(&api, serial_number.as_deref()).next() { - Some(dev) => dev, - None => { - println!("Device not found"); - return ExitCode::FAILURE; - } - }; - - if device.is_on { - turn_off(&device.device_handle, &device.device_type); - } else { - turn_on(&device.device_handle, &device.device_type); - } - ExitCode::SUCCESS + (None, Some(_)) => { + let brightness_in_lumen = percentage_within_range( + percentage.unwrap().into(), + device_handle.minimum_brightness_in_lumen().into(), + device_handle.maximum_brightness_in_lumen().into(), + ) + .try_into() + .map_err(CliError::BrightnessPrecentageCalculationFailed)?; + + device_handle.set_brightness_in_lumen(brightness_in_lumen)?; } + _ => unreachable!(), + } + Ok(()) +} + +fn handle_temperature_command(serial_number: Option<&str>, value: u16) -> CliResult { + let context = Litra::new()?; + let device_handle = get_first_supported_device(&context, serial_number)?; + + device_handle.set_temperature_in_kelvin(value)?; + Ok(()) +} + +fn main() -> ExitCode { + let args = Cli::parse(); + + let result = match &args.command { + Commands::Devices { json } => handle_devices_command(*json), + Commands::On { serial_number } => handle_on_command(serial_number.as_deref()), + Commands::Off { serial_number } => handle_off_command(serial_number.as_deref()), + Commands::Toggle { serial_number } => handle_toggle_command(serial_number.as_deref()), Commands::Brightness { serial_number, value, percentage, - } => { - let device = match get_connected_devices(&api, serial_number.as_deref()).next() { - Some(dev) => dev, - None => { - println!("Device not found"); - return ExitCode::FAILURE; - } - }; - - match (value, percentage) { - (Some(_), None) => { - let brightness_in_lumen = value.unwrap(); - - if brightness_in_lumen < device.minimum_brightness_in_lumen - || brightness_in_lumen > device.maximum_brightness_in_lumen - { - println!( - "Brightness must be set to a value between {} lm and {} lm", - device.minimum_brightness_in_lumen, device.maximum_brightness_in_lumen - ); - return ExitCode::FAILURE; - } - - set_brightness_in_lumen( - &device.device_handle, - &device.device_type, - brightness_in_lumen, - ); - } - (None, Some(_)) => { - let brightness_in_lumen = percentage_within_range( - percentage.unwrap().into(), - device.minimum_brightness_in_lumen.into(), - device.maximum_brightness_in_lumen.into(), - ); - - set_brightness_in_lumen( - &device.device_handle, - &device.device_type, - brightness_in_lumen.try_into().unwrap(), - ); - } - _ => unreachable!(), - } - ExitCode::SUCCESS - } + } => handle_brightness_command(serial_number.as_deref(), *value, *percentage), Commands::Temperature { serial_number, value, - } => { - let device = match get_connected_devices(&api, serial_number.as_deref()).next() { - Some(dev) => dev, - None => { - println!("Device not found"); - return ExitCode::FAILURE; - } - }; - - let allowed_temperatures_in_kelvin = multiples_within_range( - 100, - device.minimum_temperature_in_kelvin, - device.maximum_temperature_in_kelvin, - ); - - if !allowed_temperatures_in_kelvin.contains(value) { - println!( - "Temperature must be set to a multiple of 100 between {} K and {} K", - device.minimum_temperature_in_kelvin, device.maximum_temperature_in_kelvin - ); - return ExitCode::FAILURE; - } + } => handle_temperature_command(serial_number.as_deref(), *value), + }; - set_temperature_in_kelvin(&device.device_handle, &device.device_type, *value); - ExitCode::SUCCESS - } + if let Err(error) = result { + eprintln!("{}", error); + ExitCode::FAILURE + } else { + ExitCode::SUCCESS } }