#![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;
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 {
pub fn new() -> DeviceResult<Self> {
let hidapi = HidApi::new()?;
#[cfg(target_os = "macos")]
hidapi.set_open_exclusive(false);
Ok(Litra(hidapi))
}
pub fn get_connected_devices(&self) -> impl Iterator<Item = Device<'_>> {
let mut devices: Vec<Device<'_>> = self
.0
.device_list()
.filter_map(|device_info| Device::try_from(device_info).ok())
.collect();
devices.sort_by_key(|a| a.device_path());
devices.into_iter()
}
pub fn refresh_connected_devices(&mut self) -> DeviceResult<()> {
self.0.refresh_devices()?;
Ok(())
}
#[must_use]
pub fn hidapi(&self) -> &HidApi {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize)]
#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
pub enum DeviceType {
#[serde(rename = "glow")]
LitraGlow,
#[serde(rename = "beam")]
LitraBeam,
#[serde(rename = "beam_lx")]
LitraBeamLX,
}
impl DeviceType {
#[must_use]
pub fn has_back_side(&self) -> bool {
*self == DeviceType::LitraBeamLX
}
}
impl fmt::Display for DeviceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeviceType::LitraGlow => write!(f, "Litra Glow"),
DeviceType::LitraBeam => write!(f, "Litra Beam"),
DeviceType::LitraBeamLX => write!(f, "Litra Beam LX"),
}
}
}
impl std::str::FromStr for DeviceType {
type Err = DeviceError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s_lower = s.to_lowercase().replace(" ", "");
match s_lower.as_str() {
"glow" => Ok(DeviceType::LitraGlow),
"beam" => Ok(DeviceType::LitraBeam),
"beam_lx" => Ok(DeviceType::LitraBeamLX),
_ => Err(DeviceError::UnsupportedDeviceType),
}
}
}
#[derive(Debug)]
pub enum DeviceError {
Unsupported,
InvalidBrightness(u16),
InvalidTemperature(u16),
InvalidPercentage(u8),
HidError(HidError),
UnsupportedDeviceType,
InvalidZone(u8),
InvalidColor(String),
}
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 {} lm is not supported", value)
}
DeviceError::InvalidTemperature(value) => {
write!(f, "Temperature {} K is not supported", value)
}
DeviceError::HidError(error) => write!(f, "HID error occurred: {}", error),
DeviceError::UnsupportedDeviceType => write!(f, "Unsupported device type"),
DeviceError::InvalidZone(zone_id) => write!(
f,
"Back color zone {} is not valid. Only zones 1-7 are allowed.",
zone_id
),
DeviceError::InvalidColor(str) => write!(
f,
"Back color {} is not valid. Only hexadecimal colors are allowed.",
str
),
DeviceError::InvalidPercentage(value) => {
write!(
f,
"Percentage {}% is not valid. Only values between 0 and 100 are allowed.",
value
)
}
}
}
}
impl Error for DeviceError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
if let DeviceError::HidError(error) = self {
Some(error)
} else {
None
}
}
}
impl From<HidError> for DeviceError {
fn from(error: HidError) -> Self {
DeviceError::HidError(error)
}
}
pub type DeviceResult<T> = Result<T, DeviceError>;
#[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<Self, DeviceError> {
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,
})
.ok_or(DeviceError::Unsupported)
}
}
impl Device<'_> {
#[must_use]
pub fn device_info(&self) -> &DeviceInfo {
self.device_info
}
#[must_use]
pub fn device_type(&self) -> DeviceType {
self.device_type
}
#[must_use]
pub fn device_path(&self) -> String {
self.device_info.path().to_string_lossy().to_string()
}
pub fn open(&self, context: &Litra) -> DeviceResult<DeviceHandle> {
let hid_device = self.device_info.open_device(context.hidapi())?;
Ok(DeviceHandle {
hid_device,
device_type: self.device_type,
})
}
}
#[derive(Debug)]
pub struct DeviceHandle {
hid_device: HidDevice,
device_type: DeviceType,
}
impl DeviceHandle {
#[must_use]
pub fn device_type(&self) -> DeviceType {
self.device_type
}
#[must_use]
pub fn hid_device(&self) -> &HidDevice {
&self.hid_device
}
pub fn serial_number(&self) -> DeviceResult<Option<String>> {
match self.hid_device.get_device_info() {
Ok(device_info) => {
if let Some(serial) = device_info.serial_number() {
if !serial.is_empty() {
return Ok(Some(String::from(serial)));
}
}
Ok(None)
}
Err(error) => Err(DeviceError::HidError(error)),
}
}
pub fn device_path(&self) -> DeviceResult<String> {
match self.hid_device.get_device_info() {
Ok(device_info) => Ok(device_info.path().to_string_lossy().to_string()),
Err(error) => Err(DeviceError::HidError(error)),
}
}
pub fn is_on(&self) -> DeviceResult<bool> {
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)
}
pub fn set_on(&self, on: bool) -> DeviceResult<()> {
let message = generate_set_on_bytes(&self.device_type, on);
self.hid_device.write(&message)?;
Ok(())
}
pub fn brightness_in_lumen(&self) -> DeviceResult<u16> {
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(u16::from(response_buffer[..response][4]) * 256
+ u16::from(response_buffer[..response][5]))
}
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(())
}
#[must_use]
pub fn minimum_brightness_in_lumen(&self) -> u16 {
match self.device_type {
DeviceType::LitraGlow => 20,
DeviceType::LitraBeam | DeviceType::LitraBeamLX => 30,
}
}
#[must_use]
pub fn maximum_brightness_in_lumen(&self) -> u16 {
match self.device_type {
DeviceType::LitraGlow => 250,
DeviceType::LitraBeam | DeviceType::LitraBeamLX => 400,
}
}
pub fn temperature_in_kelvin(&self) -> DeviceResult<u16> {
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]))
}
pub fn set_temperature_in_kelvin(&self, temperature_in_kelvin: u16) -> DeviceResult<()> {
if temperature_in_kelvin < self.minimum_temperature_in_kelvin()
|| temperature_in_kelvin > self.maximum_temperature_in_kelvin()
|| !temperature_in_kelvin.is_multiple_of(100)
{
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(())
}
#[must_use]
pub fn minimum_temperature_in_kelvin(&self) -> u16 {
MINIMUM_TEMPERATURE_IN_KELVIN
}
#[must_use]
pub fn maximum_temperature_in_kelvin(&self) -> u16 {
MAXIMUM_TEMPERATURE_IN_KELVIN
}
pub fn set_back_color(&self, zone_id: u8, red: u8, green: u8, blue: u8) -> DeviceResult<()> {
if self.device_type != DeviceType::LitraBeamLX {
return Err(DeviceError::UnsupportedDeviceType);
}
if zone_id == 0 || zone_id > 7 {
return Err(DeviceError::InvalidZone(zone_id));
}
let message = generate_set_back_color_bytes(zone_id, red.max(1), green.max(1), blue.max(1));
self.hid_device.write(&message)?;
self.hid_device
.write(&[0x11, 0xff, 0x0C, 0x7B, 0, 0, 1, 0, 0])?;
Ok(())
}
pub fn set_back_brightness_percentage(&self, brightness: u8) -> DeviceResult<()> {
if self.device_type != DeviceType::LitraBeamLX {
return Err(DeviceError::UnsupportedDeviceType);
}
if brightness == 0 || brightness > 100 {
return Err(DeviceError::InvalidPercentage(brightness));
}
let message = generate_set_back_brightness_percentage_bytes(brightness);
self.hid_device.write(&message)?;
Ok(())
}
pub fn set_back_on(&self, on: bool) -> DeviceResult<()> {
if self.device_type != DeviceType::LitraBeamLX {
return Err(DeviceError::UnsupportedDeviceType);
}
let message = generate_set_back_on_bytes(on);
self.hid_device.write(&message)?;
Ok(())
}
pub fn is_back_on(&self) -> DeviceResult<bool> {
if self.device_type != DeviceType::LitraBeamLX {
return Err(DeviceError::UnsupportedDeviceType);
}
let message = generate_get_back_on_bytes();
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)
}
pub fn back_brightness_percentage(&self) -> DeviceResult<u8> {
if self.device_type != DeviceType::LitraBeamLX {
return Err(DeviceError::UnsupportedDeviceType);
}
let message = generate_get_back_brightness_percentage_bytes();
self.hid_device.write(&message)?;
let mut response_buffer = [0x00; 20];
let response = self.hid_device.read(&mut response_buffer[..])?;
let brightness = u16::from(response_buffer[..response][4]) * 256
+ u16::from(response_buffer[..response][5]);
Ok(brightness as u8)
}
}
const VENDOR_ID: u16 = 0x046d;
const USAGE_PAGE: u16 = 0xff43;
fn device_type_from_product_id(product_id: u16) -> Option<DeviceType> {
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 => [
0x11, 0xff, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
DeviceType::LitraBeamLX => [
0x11, 0xff, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
}
}
fn generate_get_brightness_in_lumen_bytes(device_type: &DeviceType) -> [u8; 20] {
match device_type {
DeviceType::LitraGlow | DeviceType::LitraBeam => [
0x11, 0xff, 0x04, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
DeviceType::LitraBeamLX => [
0x11, 0xff, 0x06, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
}
}
fn generate_get_temperature_in_kelvin_bytes(device_type: &DeviceType) -> [u8; 20] {
match device_type {
DeviceType::LitraGlow | DeviceType::LitraBeam => [
0x11, 0xff, 0x04, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
DeviceType::LitraBeamLX => [
0x11, 0xff, 0x06, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
}
}
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, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
DeviceType::LitraBeamLX => [
0x11, 0xff, 0x06, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
],
}
}
fn generate_set_brightness_in_lumen_bytes(
device_type: &DeviceType,
brightness_in_lumen: u16,
) -> [u8; 20] {
let brightness_bytes = brightness_in_lumen.to_be_bytes();
match device_type {
DeviceType::LitraGlow | DeviceType::LitraBeam => [
0x11,
0xff,
0x04,
0x4c,
brightness_bytes[0],
brightness_bytes[1],
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
],
DeviceType::LitraBeamLX => [
0x11,
0xff,
0x06,
0x4c,
brightness_bytes[0],
brightness_bytes[1],
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
],
}
}
fn generate_set_temperature_in_kelvin_bytes(
device_type: &DeviceType,
temperature_in_kelvin: u16,
) -> [u8; 20] {
let temperature_bytes = temperature_in_kelvin.to_be_bytes();
match device_type {
DeviceType::LitraGlow | DeviceType::LitraBeam => [
0x11,
0xff,
0x04,
0x9c,
temperature_bytes[0],
temperature_bytes[1],
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
],
DeviceType::LitraBeamLX => [
0x11,
0xff,
0x06,
0x9c,
temperature_bytes[0],
temperature_bytes[1],
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
],
}
}
fn generate_set_back_color_bytes(zone_id: u8, red: u8, green: u8, blue: u8) -> [u8; 20] {
[
0x11, 0xff, 0x0C, 0x1B, zone_id, red, green, blue, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00,
0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
]
}
fn generate_set_back_brightness_percentage_bytes(brightness: u8) -> [u8; 20] {
[
0x11, 0xff, 0x0a, 0x2b, 0x00, brightness, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]
}
fn generate_set_back_on_bytes(on: bool) -> [u8; 20] {
[
0x11,
0xff,
0x0a,
0x4b,
if on { 1 } else { 0 },
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
]
}
fn generate_get_back_on_bytes() -> [u8; 20] {
[
0x11, 0xff, 0x0a, 0x3b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
]
}
fn generate_get_back_brightness_percentage_bytes() -> [u8; 20] {
[
0x11, 0xff, 0x0a, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
]
}