use clap::{builder::TypedValueParser, ArgGroup, Parser, Subcommand, ValueEnum};
use litra::{Device, DeviceError, DeviceHandle, DeviceResult, DeviceType, Litra};
use serde::Serialize;
use std::fmt;
use std::process::ExitCode;
use std::str::FromStr;
#[cfg(feature = "cli")]
use tabled::{Table, Tabled};
#[derive(Debug, Clone)]
struct DeviceTypeValueParser;
impl TypedValueParser for DeviceTypeValueParser {
type Value = DeviceType;
fn parse_ref(
&self,
_cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let value_str = value.to_string_lossy();
DeviceType::from_str(&value_str).map_err(|_| {
let mut err = clap::Error::new(clap::error::ErrorKind::InvalidValue);
if let Some(arg) = arg {
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(arg.to_string()),
);
}
err.insert(
clap::error::ContextKind::Custom,
clap::error::ContextValue::String(format!("Invalid device type: {}", value_str)),
);
err
})
}
}
#[cfg(feature = "mcp")]
mod mcp;
#[cfg(feature = "cli")]
#[derive(Debug, Parser)]
#[clap(name = "litra", version)]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
const SERIAL_NUMBER_ARGUMENT_HELP: &str = "Specify the device to target by its serial number";
const DEVICE_PATH_ARGUMENT_HELP: &str =
"Specify the device to target by its path (useful for devices that don't show a serial number)";
const DEVICE_TYPE_ARGUMENT_HELP: &str =
"Specify the device to target by its type (`glow`, `beam` or `beam_lx`)";
#[cfg(feature = "cli")]
#[derive(Debug, Clone, Copy, ValueEnum)]
enum NamedColor {
Red,
Green,
Blue,
Yellow,
Orange,
Purple,
Pink,
Cyan,
White,
Magenta,
}
#[cfg(feature = "cli")]
impl NamedColor {
fn to_hex(self) -> &'static str {
match self {
NamedColor::Red => "FF0000",
NamedColor::Green => "00FF00",
NamedColor::Blue => "0000FF",
NamedColor::Yellow => "FFFF00",
NamedColor::Orange => "FFA500",
NamedColor::Purple => "800080",
NamedColor::Pink => "FFC0CB",
NamedColor::Cyan => "00FFFF",
NamedColor::White => "FFFFFF",
NamedColor::Magenta => "FF00FF",
}
}
}
#[cfg(feature = "cli")]
#[derive(Debug, Subcommand)]
enum Commands {
On {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
},
Off {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
},
Toggle {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
},
#[clap(group = ArgGroup::new("brightness").required(true).multiple(false))]
Brightness {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
#[clap(
long,
short,
help = "The brightness to set, measured in lumens. This can be set to any value between the minimum and maximum for the device returned by the `devices` command.",
group = "brightness"
)]
value: Option<u16>,
#[clap(
long,
short('b'),
help = "The brightness to set, as a percentage of the maximum brightness",
group = "brightness",
value_parser = clap::value_parser!(u8).range(1..=100)
)]
percentage: Option<u8>,
},
#[clap(group = ArgGroup::new("brightness-up").required(true).multiple(false))]
BrightnessUp {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
#[clap(
long,
short,
help = "The amount to increase the brightness by, measured in lumens.",
group = "brightness-up"
)]
value: Option<u16>,
#[clap(
long,
short,
help = "The number of percentage points to increase the brightness by",
group = "brightness-up",
value_parser = clap::value_parser!(u8).range(1..=100)
)]
percentage: Option<u8>,
},
#[clap(group = ArgGroup::new("brightness-down").required(true).multiple(false))]
BrightnessDown {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
#[clap(
long,
short,
help = "The amount to decrease the brightness by, measured in lumens.",
group = "brightness-down"
)]
value: Option<u16>,
#[clap(
long,
short,
help = "The number of percentage points to reduce the brightness by",
group = "brightness-down",
value_parser = clap::value_parser!(u8).range(1..=100)
)]
percentage: Option<u8>,
},
Temperature {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
#[clap(
long,
short,
help = "The temperature to set, measured in Kelvin. This can be set to any multiple of 100 between the minimum and maximum for the device returned by the `devices` command."
)]
value: u16,
},
TemperatureUp {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
#[clap(
long,
short,
help = "The amount to increase the temperature by, measured in Kelvin. This must be a multiple of 100."
)]
value: u16,
},
TemperatureDown {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with_all = ["device_path", "device_type"]
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with_all = ["serial_number", "device_type"]
)]
device_path: Option<String>,
#[clap(long, short('t'), help = DEVICE_TYPE_ARGUMENT_HELP, value_parser = DeviceTypeValueParser, conflicts_with_all = ["serial_number", "device_path"])]
device_type: Option<DeviceType>,
#[clap(
long,
short,
help = "The amount to decrease the temperature by, measured in Kelvin. This must be a multiple of 100."
)]
value: u16,
},
#[clap(group = ArgGroup::new("color-input").required(true).multiple(false))]
BackColor {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
#[clap(
long,
short,
help = "The hexadecimal color code to use (e.g. FF0000 for red). Either --value or --color must be specified.",
group = "color-input"
)]
value: Option<String>,
#[clap(
long,
short,
help = "A named color to use. Either --value or --color must be specified.",
group = "color-input"
)]
color: Option<NamedColor>,
#[clap(
long,
short('z'),
help = "The zone of the light to control, numbered 1 to 7 from left to right. If not specified, all zones will be targeted."
)]
zone: Option<u8>,
},
BackBrightness {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
#[clap(
long,
short('b'),
help = "The brightness to set, as a percentage of the maximum brightness",
value_parser = clap::value_parser!(u8).range(1..=100)
)]
percentage: u8,
},
BackOff {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
},
BackOn {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
},
BackToggle {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
},
BackBrightnessUp {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
#[clap(
long,
short('b'),
help = "The number of percentage points to increase the brightness by",
value_parser = clap::value_parser!(u8).range(1..=100)
)]
percentage: u8,
},
BackBrightnessDown {
#[clap(
long,
short,
help = SERIAL_NUMBER_ARGUMENT_HELP,
conflicts_with = "device_path"
)]
serial_number: Option<String>,
#[clap(
long,
short('p'),
help = DEVICE_PATH_ARGUMENT_HELP,
conflicts_with = "serial_number"
)]
device_path: Option<String>,
#[clap(
long,
short('b'),
help = "The number of percentage points to decrease the brightness by",
value_parser = clap::value_parser!(u8).range(1..=100)
)]
percentage: u8,
},
Devices {
#[clap(long, short, action, help = "Return the results in JSON format")]
json: bool,
},
#[cfg(feature = "mcp")]
Mcp,
}
fn percentage_within_range(percentage: u32, start_range: u32, end_range: u32) -> u32 {
if percentage == 0 {
return start_range;
}
if percentage == 100 {
return end_range;
}
let range = end_range as f64 - start_range as f64;
let result = (percentage as f64 / 100.0) * range + start_range as f64;
result.ceil() as u32
}
fn get_is_on_text(is_on: bool) -> &'static str {
if is_on {
"On"
} else {
"Off"
}
}
fn get_is_on_emoji(is_on: bool) -> &'static str {
if is_on {
"💡"
} else {
"🌑"
}
}
fn get_is_back_on_emoji(is_on: bool) -> &'static str {
if is_on {
"🌈"
} else {
"🌑"
}
}
fn check_device_filters<'a>(
_context: &'a Litra,
_serial_number: Option<&'a str>,
device_path: Option<&'a str>,
device_type: Option<&'a DeviceType>,
) -> impl Fn(&Device) -> bool + 'a {
move |device| {
if let Some(path) = device_path {
return device.device_path() == path;
}
if let Some(expected_type) = device_type {
if device.device_type() != *expected_type {
return false;
}
}
true
}
}
#[derive(Debug)]
enum CliError {
DeviceError(DeviceError),
SerializationFailed(serde_json::Error),
DeviceNotFound,
MCPError(String),
}
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::DeviceNotFound => write!(f, "Device not found."),
CliError::MCPError(message) => write!(f, "MCP server error: {}", message),
}
}
}
impl From<DeviceError> for CliError {
fn from(error: DeviceError) -> Self {
CliError::DeviceError(error)
}
}
type CliResult = Result<(), CliError>;
fn get_all_supported_devices(
context: &Litra,
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
) -> Result<Vec<DeviceHandle>, CliError> {
let potential_devices: Vec<Device> = context
.get_connected_devices()
.filter(check_device_filters(
context,
serial_number,
device_path,
device_type,
))
.collect();
if let Some(serial) = serial_number {
let mut handles = Vec::new();
for device in potential_devices {
if let Ok(handle) = device.open(context) {
if let Ok(Some(actual_serial)) = handle.serial_number() {
if actual_serial == serial {
handles.push(handle);
}
}
}
}
Ok(handles)
} else {
Ok(potential_devices
.into_iter()
.filter_map(|dev| dev.open(context).ok())
.collect())
}
}
fn with_device<F>(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
callback: F,
) -> CliResult
where
F: Fn(&DeviceHandle) -> DeviceResult<()>,
{
let context = Litra::new()?;
let devices = get_all_supported_devices(&context, serial_number, device_path, device_type)?;
if devices.is_empty() {
return Err(CliError::DeviceNotFound);
}
for device_handle in devices {
if let Err(e) = callback(&device_handle) {
if is_user_input_error(&e) {
return Err(e.into());
}
}
}
Ok(())
}
fn is_user_input_error(error: &DeviceError) -> bool {
matches!(
error,
DeviceError::InvalidBrightness(_)
| DeviceError::InvalidTemperature(_)
| DeviceError::InvalidZone(_)
| DeviceError::InvalidColor(_)
| DeviceError::InvalidPercentage(_)
)
}
#[cfg_attr(feature = "cli", derive(Tabled))]
#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
#[derive(Serialize, Debug)]
pub struct DeviceInfo {
#[cfg_attr(feature = "cli", tabled(skip))]
pub device_type: DeviceType,
#[cfg_attr(feature = "cli", tabled(rename = "Type"))]
pub device_type_display: String,
#[cfg_attr(feature = "cli", tabled(skip))]
pub has_back_side: bool,
#[cfg_attr(feature = "cli", tabled(rename = "Serial Number"))]
pub serial_number: String,
#[cfg_attr(feature = "cli", tabled(rename = "Device Path"))]
pub device_path: String,
#[cfg_attr(feature = "cli", tabled(rename = "Status"))]
pub status_display: String,
#[cfg_attr(feature = "cli", tabled(rename = "Brightness (lm)"))]
pub brightness_display: String,
#[cfg_attr(feature = "cli", tabled(rename = "Temperature (K)"))]
pub temperature_display: String,
#[cfg_attr(feature = "cli", tabled(rename = "Back Status"))]
pub back_status_display: String,
#[cfg_attr(feature = "cli", tabled(rename = "Back Brightness (%)"))]
pub back_brightness_display: String,
#[cfg_attr(feature = "cli", tabled(skip))]
pub is_on: bool,
#[cfg_attr(feature = "cli", tabled(skip))]
pub brightness_in_lumen: u16,
#[cfg_attr(feature = "cli", tabled(skip))]
pub temperature_in_kelvin: u16,
#[cfg_attr(feature = "cli", tabled(skip))]
pub minimum_brightness_in_lumen: u16,
#[cfg_attr(feature = "cli", tabled(skip))]
pub maximum_brightness_in_lumen: u16,
#[cfg_attr(feature = "cli", tabled(skip))]
pub minimum_temperature_in_kelvin: u16,
#[cfg_attr(feature = "cli", tabled(skip))]
pub maximum_temperature_in_kelvin: u16,
#[cfg_attr(feature = "cli", tabled(skip))]
pub is_back_on: Option<bool>,
#[cfg_attr(feature = "cli", tabled(skip))]
pub back_brightness_percentage: Option<u8>,
}
fn get_connected_devices() -> Result<Vec<DeviceInfo>, CliError> {
let context = Litra::new()?;
let litra_devices: Vec<DeviceInfo> = context
.get_connected_devices()
.filter_map(|device| {
let device_handle = match device.open(&context) {
Ok(handle) => handle,
Err(_e) => {
return None;
}
};
let device_path = device.device_path();
let serial = match device_handle.serial_number() {
Ok(Some(s)) => s,
Ok(None) => "UNKNOWN".to_string(),
Err(_e) => "UNKNOWN".to_string(),
};
let is_on = match device_handle.is_on() {
Ok(on) => on,
Err(_e) => {
return None;
}
};
let brightness = match device_handle.brightness_in_lumen() {
Ok(b) => b,
Err(_e) => {
return None;
}
};
let temperature = match device_handle.temperature_in_kelvin() {
Ok(t) => t,
Err(_e) => {
return None;
}
};
let (
is_back_on,
back_brightness_percentage,
back_status_display,
back_brightness_display,
) = if device.device_type() == DeviceType::LitraBeamLX {
let back_on = device_handle.is_back_on().ok();
let back_brightness = device_handle.back_brightness_percentage().ok();
let status_display = match back_on {
Some(on) => format!("{} {}", get_is_on_text(on), get_is_back_on_emoji(on)),
None => "Unknown".to_string(),
};
let brightness_display = match back_brightness {
Some(b) => format!("{}%", b),
None => "Unknown".to_string(),
};
(back_on, back_brightness, status_display, brightness_display)
} else {
(None, None, "N/A".to_string(), "N/A".to_string())
};
Some(DeviceInfo {
device_type: device.device_type(),
device_type_display: device.device_type().to_string(),
has_back_side: device.device_type().has_back_side(),
serial_number: serial,
device_path,
status_display: format!("{} {}", get_is_on_text(is_on), get_is_on_emoji(is_on)),
brightness_display: format!(
"{}/{}",
brightness,
device_handle.maximum_brightness_in_lumen()
),
temperature_display: format!(
"{}/{}",
temperature,
device_handle.maximum_temperature_in_kelvin()
),
back_status_display,
back_brightness_display,
is_on,
brightness_in_lumen: brightness,
temperature_in_kelvin: temperature,
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(),
is_back_on,
back_brightness_percentage,
})
})
.collect();
Ok(litra_devices)
}
#[cfg(feature = "cli")]
fn handle_devices_command(json: bool) -> CliResult {
let litra_devices = get_connected_devices()?;
if json {
println!(
"{}",
serde_json::to_string(&litra_devices).map_err(CliError::SerializationFailed)?
);
Ok(())
} else {
if litra_devices.is_empty() {
println!("No Logitech Litra devices found");
} else {
let table = Table::new(&litra_devices);
println!("{}", table);
}
Ok(())
}
}
fn handle_on_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
) -> CliResult {
with_device(serial_number, device_path, device_type, |device_handle| {
device_handle.set_on(true)
})
}
fn handle_off_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
) -> CliResult {
with_device(serial_number, device_path, device_type, |device_handle| {
device_handle.set_on(false)
})
}
fn handle_toggle_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
) -> CliResult {
let context = Litra::new()?;
let devices = get_all_supported_devices(&context, serial_number, device_path, device_type)?;
if devices.is_empty() {
return Err(CliError::DeviceNotFound);
}
for device_handle in devices {
if let Ok(is_on) = device_handle.is_on() {
let _ = device_handle.set_on(!is_on);
}
}
Ok(())
}
fn with_brightness_setting<F>(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
brightness_fn: F,
) -> CliResult
where
F: Fn(&DeviceHandle) -> Result<u16, DeviceError>,
{
let context = Litra::new()?;
let devices = get_all_supported_devices(&context, serial_number, device_path, device_type)?;
if devices.is_empty() {
return Err(CliError::DeviceNotFound);
}
for device_handle in devices {
if let Ok(brightness) = brightness_fn(&device_handle) {
let _ = device_handle.set_brightness_in_lumen(brightness);
}
}
Ok(())
}
fn handle_brightness_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
value: Option<u16>,
percentage: Option<u8>,
) -> CliResult {
match (value, percentage) {
(Some(brightness), None) => {
with_device(serial_number, device_path, device_type, |device_handle| {
device_handle.set_brightness_in_lumen(brightness)
})
}
(None, Some(pct)) => {
with_brightness_setting(serial_number, device_path, device_type, |device_handle| {
let brightness_in_lumen = percentage_within_range(
pct.into(),
device_handle.minimum_brightness_in_lumen().into(),
device_handle.maximum_brightness_in_lumen().into(),
);
brightness_in_lumen
.try_into()
.map_err(|_| DeviceError::InvalidBrightness(0))
})
}
_ => unreachable!(),
}
}
fn handle_brightness_up_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
value: Option<u16>,
percentage: Option<u8>,
) -> CliResult {
match (value, percentage) {
(Some(brightness_to_add), None) => {
with_brightness_setting(serial_number, device_path, device_type, |device_handle| {
let current_brightness = device_handle.brightness_in_lumen()?;
let new_brightness = current_brightness + brightness_to_add;
Ok(new_brightness)
})
}
(None, Some(pct)) => {
with_brightness_setting(serial_number, device_path, device_type, |device_handle| {
let current_brightness = device_handle.brightness_in_lumen()?;
let brightness_to_add = percentage_within_range(
pct.into(),
device_handle.minimum_brightness_in_lumen().into(),
device_handle.maximum_brightness_in_lumen().into(),
) as u16
- device_handle.minimum_brightness_in_lumen();
let new_brightness = current_brightness + brightness_to_add;
Ok(new_brightness)
})
}
_ => unreachable!(),
}
}
fn handle_brightness_down_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
value: Option<u16>,
percentage: Option<u8>,
) -> CliResult {
match (value, percentage) {
(Some(brightness_to_subtract), None) => {
with_brightness_setting(serial_number, device_path, device_type, |device_handle| {
let current_brightness = device_handle.brightness_in_lumen()?;
if current_brightness <= brightness_to_subtract {
return Err(DeviceError::InvalidBrightness(0));
}
let new_brightness = current_brightness - brightness_to_subtract;
Ok(new_brightness)
})
}
(None, Some(pct)) => {
with_brightness_setting(serial_number, device_path, device_type, |device_handle| {
let current_brightness = device_handle.brightness_in_lumen()?;
let brightness_to_subtract = percentage_within_range(
pct.into(),
device_handle.minimum_brightness_in_lumen().into(),
device_handle.maximum_brightness_in_lumen().into(),
) as u16
- device_handle.minimum_brightness_in_lumen();
let new_brightness = current_brightness as i16 - brightness_to_subtract as i16;
if new_brightness <= 0 {
return Err(DeviceError::InvalidBrightness(0));
}
Ok(new_brightness as u16)
})
}
_ => unreachable!(),
}
}
fn handle_temperature_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
value: u16,
) -> CliResult {
with_device(serial_number, device_path, device_type, |device_handle| {
device_handle.set_temperature_in_kelvin(value)
})
}
fn handle_temperature_up_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
value: u16,
) -> CliResult {
with_device(serial_number, device_path, device_type, |device_handle| {
let current_temperature = device_handle.temperature_in_kelvin()?;
let new_temperature = current_temperature + value;
if new_temperature > device_handle.maximum_temperature_in_kelvin() {
return Err(DeviceError::InvalidTemperature(new_temperature));
}
device_handle.set_temperature_in_kelvin(new_temperature)
})
}
fn handle_temperature_down_command(
serial_number: Option<&str>,
device_path: Option<&str>,
device_type: Option<&DeviceType>,
value: u16,
) -> CliResult {
with_device(serial_number, device_path, device_type, |device_handle| {
let current_temperature = device_handle.temperature_in_kelvin()?;
if current_temperature <= value {
return Err(DeviceError::InvalidTemperature(0));
}
let new_temperature = current_temperature - value;
device_handle.set_temperature_in_kelvin(new_temperature)
})
}
fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), String> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Err("Hex color must be exactly 6 characters long".into());
}
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| "Failed to parse red component from hex color")?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| "Failed to parse green component from hex color")?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| "Failed to parse blue component from hex color")?;
Ok((r, g, b))
}
fn handle_back_color_command(
serial_number: Option<&str>,
device_path: Option<&str>,
hex: &str,
zone_id: Option<u8>,
) -> CliResult {
with_device(
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
|device_handle| match hex_to_rgb(hex) {
Ok((r, g, b)) => match zone_id {
None => {
for i in 1..=7 {
device_handle.set_back_color(i, r, g, b)?;
std::thread::sleep(std::time::Duration::from_millis(10));
}
Ok(())
}
Some(id) => {
device_handle.set_back_color(id, r, g, b)?;
Ok(())
}
},
Err(error) => Err(DeviceError::InvalidColor(error)),
},
)
}
fn handle_back_brightness_command(
serial_number: Option<&str>,
device_path: Option<&str>,
brightness: u8,
) -> CliResult {
with_device(
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
|device_handle| device_handle.set_back_brightness_percentage(brightness),
)
}
fn handle_back_off_command(serial_number: Option<&str>, device_path: Option<&str>) -> CliResult {
with_device(
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
|device_handle| device_handle.set_back_on(false),
)
}
fn handle_back_on_command(serial_number: Option<&str>, device_path: Option<&str>) -> CliResult {
with_device(
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
|device_handle| device_handle.set_back_on(true),
)
}
fn handle_back_toggle_command(serial_number: Option<&str>, device_path: Option<&str>) -> CliResult {
let context = Litra::new()?;
let devices = get_all_supported_devices(
&context,
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
)?;
if devices.is_empty() {
return Err(CliError::DeviceNotFound);
}
for device_handle in devices {
if let Ok(is_on) = device_handle.is_back_on() {
let _ = device_handle.set_back_on(!is_on);
}
}
Ok(())
}
fn handle_back_brightness_up_command(
serial_number: Option<&str>,
device_path: Option<&str>,
percentage: u8,
) -> CliResult {
let context = Litra::new()?;
let devices = get_all_supported_devices(
&context,
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
)?;
if devices.is_empty() {
return Err(CliError::DeviceNotFound);
}
for device_handle in devices {
if let Ok(current_brightness) = device_handle.back_brightness_percentage() {
let new_brightness = current_brightness.saturating_add(percentage).min(100);
let _ = device_handle.set_back_brightness_percentage(new_brightness);
}
}
Ok(())
}
fn handle_back_brightness_down_command(
serial_number: Option<&str>,
device_path: Option<&str>,
percentage: u8,
) -> CliResult {
let context = Litra::new()?;
let devices = get_all_supported_devices(
&context,
serial_number,
device_path,
Some(&DeviceType::LitraBeamLX),
)?;
if devices.is_empty() {
return Err(CliError::DeviceNotFound);
}
for device_handle in devices {
if let Ok(current_brightness) = device_handle.back_brightness_percentage() {
let new_brightness = current_brightness.saturating_sub(percentage).max(1);
let _ = device_handle.set_back_brightness_percentage(new_brightness);
}
}
Ok(())
}
#[cfg(feature = "mcp")]
fn handle_mcp_command() -> CliResult {
mcp::handle_mcp_command()
}
#[cfg(feature = "cli")]
fn main() -> ExitCode {
let args = Cli::parse();
let result = match &args.command {
Commands::Devices { json } => handle_devices_command(*json),
Commands::On {
serial_number,
device_path,
device_type,
} => handle_on_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
),
Commands::Off {
serial_number,
device_path,
device_type,
} => handle_off_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
),
Commands::Toggle {
serial_number,
device_path,
device_type,
} => handle_toggle_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
),
Commands::Brightness {
serial_number,
device_path,
device_type,
value,
percentage,
} => handle_brightness_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
*value,
*percentage,
),
Commands::BrightnessUp {
serial_number,
device_path,
device_type,
value,
percentage,
} => handle_brightness_up_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
*value,
*percentage,
),
Commands::BrightnessDown {
serial_number,
device_path,
device_type,
value,
percentage,
} => handle_brightness_down_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
*value,
*percentage,
),
Commands::Temperature {
serial_number,
device_path,
device_type,
value,
} => handle_temperature_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
*value,
),
Commands::TemperatureUp {
serial_number,
device_path,
device_type,
value,
} => handle_temperature_up_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
*value,
),
Commands::TemperatureDown {
serial_number,
device_path,
device_type,
value,
} => handle_temperature_down_command(
serial_number.as_deref(),
device_path.as_deref(),
device_type.as_ref(),
*value,
),
Commands::BackColor {
serial_number,
device_path,
value,
color,
zone: zone_id,
} => {
let hex = match (value, color) {
(Some(v), None) => v.clone(),
(None, Some(c)) => c.to_hex().to_string(),
_ => unreachable!("clap ensures exactly one of value or color is provided"),
};
handle_back_color_command(
serial_number.as_deref(),
device_path.as_deref(),
&hex,
*zone_id,
)
}
Commands::BackBrightness {
serial_number,
device_path,
percentage,
} => handle_back_brightness_command(
serial_number.as_deref(),
device_path.as_deref(),
*percentage,
),
Commands::BackOff {
serial_number,
device_path,
} => handle_back_off_command(serial_number.as_deref(), device_path.as_deref()),
Commands::BackOn {
serial_number,
device_path,
} => handle_back_on_command(serial_number.as_deref(), device_path.as_deref()),
Commands::BackToggle {
serial_number,
device_path,
} => handle_back_toggle_command(serial_number.as_deref(), device_path.as_deref()),
Commands::BackBrightnessUp {
serial_number,
device_path,
percentage,
} => handle_back_brightness_up_command(
serial_number.as_deref(),
device_path.as_deref(),
*percentage,
),
Commands::BackBrightnessDown {
serial_number,
device_path,
percentage,
} => handle_back_brightness_down_command(
serial_number.as_deref(),
device_path.as_deref(),
*percentage,
),
#[cfg(feature = "mcp")]
Commands::Mcp => handle_mcp_command(),
};
if let Err(error) = result {
eprintln!("{}", error);
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percentage_within_range_zero_percent() {
assert_eq!(percentage_within_range(0, 20, 250), 20);
assert_eq!(percentage_within_range(0, 30, 400), 30);
assert_eq!(percentage_within_range(0, 100, 200), 100);
}
#[test]
fn test_percentage_within_range_hundred_percent() {
assert_eq!(percentage_within_range(100, 20, 250), 250);
assert_eq!(percentage_within_range(100, 30, 400), 400);
assert_eq!(percentage_within_range(100, 100, 200), 200);
}
#[test]
fn test_percentage_within_range_one_percent_greater_than_zero() {
let zero_pct = percentage_within_range(0, 20, 250);
let one_pct = percentage_within_range(1, 20, 250);
assert!(
one_pct > zero_pct,
"1% ({}) should be greater than 0% ({})",
one_pct,
zero_pct
);
let zero_pct_beam = percentage_within_range(0, 30, 400);
let one_pct_beam = percentage_within_range(1, 30, 400);
assert!(
one_pct_beam > zero_pct_beam,
"1% ({}) should be greater than 0% ({})",
one_pct_beam,
zero_pct_beam
);
let zero_pct_small = percentage_within_range(0, 20, 30);
let one_pct_small = percentage_within_range(1, 20, 30);
assert!(
one_pct_small > zero_pct_small,
"1% ({}) should be greater than 0% ({}) even with small range",
one_pct_small,
zero_pct_small
);
}
#[test]
fn test_percentage_within_range_midpoint() {
let result = percentage_within_range(50, 20, 250);
assert_eq!(result, 135);
let result = percentage_within_range(50, 30, 400);
assert_eq!(result, 215);
}
#[test]
fn test_percentage_within_range_litra_glow() {
assert_eq!(percentage_within_range(0, 20, 250), 20);
assert!(percentage_within_range(1, 20, 250) > 20);
assert_eq!(percentage_within_range(100, 20, 250), 250);
let one = percentage_within_range(1, 20, 250);
let two = percentage_within_range(2, 20, 250);
let three = percentage_within_range(3, 20, 250);
assert!(one >= 20);
assert!(two >= 20);
assert!(three >= 20);
assert!(one > 20);
}
#[test]
fn test_percentage_within_range_litra_beam() {
assert_eq!(percentage_within_range(0, 30, 400), 30);
assert!(percentage_within_range(1, 30, 400) > 30);
assert_eq!(percentage_within_range(100, 30, 400), 400);
let one = percentage_within_range(1, 30, 400);
let two = percentage_within_range(2, 30, 400);
assert!(one > 30);
assert!(two > 30);
}
#[test]
fn test_percentage_within_range_small_range() {
let range_start = 20;
let range_end = 30;
assert_eq!(
percentage_within_range(0, range_start, range_end),
range_start
);
assert!(
percentage_within_range(1, range_start, range_end) > range_start,
"1% should be greater than start even with small range"
);
assert!(
percentage_within_range(5, range_start, range_end) > range_start,
"5% should be greater than start even with small range"
);
assert_eq!(
percentage_within_range(100, range_start, range_end),
range_end
);
}
#[test]
fn test_percentage_within_range_monotonic() {
let range_start = 20;
let range_end = 250;
let mut prev_value = percentage_within_range(0, range_start, range_end);
for pct in 1..=100 {
let current_value = percentage_within_range(pct, range_start, range_end);
assert!(
current_value >= prev_value,
"{}% ({}) should be >= {}% ({})",
pct,
current_value,
pct - 1,
prev_value
);
prev_value = current_value;
}
}
}