1#![warn(unsafe_code)]
20#![warn(missing_docs)]
21#![cfg_attr(not(debug_assertions), deny(warnings))]
22#![deny(rust_2018_idioms)]
23#![deny(rust_2021_compatibility)]
24#![deny(missing_debug_implementations)]
25#![deny(rustdoc::broken_intra_doc_links)]
26#![deny(clippy::all)]
27#![deny(clippy::explicit_deref_methods)]
28#![deny(clippy::explicit_into_iter_loop)]
29#![deny(clippy::explicit_iter_loop)]
30#![deny(clippy::must_use_candidate)]
31#![cfg_attr(not(test), deny(clippy::panic_in_result_fn))]
32#![cfg_attr(not(debug_assertions), deny(clippy::used_underscore_binding))]
33
34use hidapi::{DeviceInfo, HidApi, HidDevice, HidError};
35use std::error::Error;
36use std::fmt;
37
38pub struct Litra(HidApi);
42
43impl fmt::Debug for Litra {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 f.debug_tuple("Litra").finish()
46 }
47}
48
49impl Litra {
50 pub fn new() -> DeviceResult<Self> {
52 let hidapi = HidApi::new()?;
53 #[cfg(target_os = "macos")]
54 hidapi.set_open_exclusive(false);
55 Ok(Litra(hidapi))
56 }
57
58 pub fn get_connected_devices(&self) -> impl Iterator<Item = Device<'_>> {
60 let mut devices: Vec<Device<'_>> = self
61 .0
62 .device_list()
63 .filter_map(|device_info| Device::try_from(device_info).ok())
64 .collect();
65 devices.sort_by_key(|a| a.device_path());
66 devices.into_iter()
67 }
68
69 pub fn refresh_connected_devices(&mut self) -> DeviceResult<()> {
71 self.0.refresh_devices()?;
72 Ok(())
73 }
74
75 #[must_use]
77 pub fn hidapi(&self) -> &HidApi {
78 &self.0
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize)]
84#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
85pub enum DeviceType {
86 #[serde(rename = "glow")]
90 LitraGlow,
91 #[serde(rename = "beam")]
95 LitraBeam,
96 #[serde(rename = "beam_lx")]
100 LitraBeamLX,
101}
102
103impl DeviceType {
104 #[must_use]
106 pub fn has_back_side(&self) -> bool {
107 *self == DeviceType::LitraBeamLX
108 }
109}
110
111impl fmt::Display for DeviceType {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 DeviceType::LitraGlow => write!(f, "Litra Glow"),
115 DeviceType::LitraBeam => write!(f, "Litra Beam"),
116 DeviceType::LitraBeamLX => write!(f, "Litra Beam LX"),
117 }
118 }
119}
120
121impl std::str::FromStr for DeviceType {
122 type Err = DeviceError;
123
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 let s_lower = s.to_lowercase().replace(" ", "");
126 match s_lower.as_str() {
127 "glow" => Ok(DeviceType::LitraGlow),
128 "beam" => Ok(DeviceType::LitraBeam),
129 "beam_lx" => Ok(DeviceType::LitraBeamLX),
130 _ => Err(DeviceError::UnsupportedDeviceType),
131 }
132 }
133}
134
135#[derive(Debug)]
137pub enum DeviceError {
138 Unsupported,
140 InvalidBrightness(u16),
142 InvalidTemperature(u16),
144 InvalidPercentage(u8),
146 HidError(HidError),
148 UnsupportedDeviceType,
150 InvalidZone(u8),
152 InvalidColor(String),
154}
155
156impl fmt::Display for DeviceError {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 match self {
159 DeviceError::Unsupported => write!(f, "Device is not supported"),
160 DeviceError::InvalidBrightness(value) => {
161 write!(f, "Brightness {} lm is not supported", value)
162 }
163 DeviceError::InvalidTemperature(value) => {
164 write!(f, "Temperature {} K is not supported", value)
165 }
166 DeviceError::HidError(error) => write!(f, "HID error occurred: {}", error),
167 DeviceError::UnsupportedDeviceType => write!(f, "Unsupported device type"),
168 DeviceError::InvalidZone(zone_id) => write!(
169 f,
170 "Back color zone {} is not valid. Only zones 1-7 are allowed.",
171 zone_id
172 ),
173 DeviceError::InvalidColor(str) => write!(
174 f,
175 "Back color {} is not valid. Only hexadecimal colors are allowed.",
176 str
177 ),
178 DeviceError::InvalidPercentage(value) => {
179 write!(
180 f,
181 "Percentage {}% is not valid. Only values between 0 and 100 are allowed.",
182 value
183 )
184 }
185 }
186 }
187}
188
189impl Error for DeviceError {
190 fn source(&self) -> Option<&(dyn Error + 'static)> {
191 if let DeviceError::HidError(error) = self {
192 Some(error)
193 } else {
194 None
195 }
196 }
197}
198
199impl From<HidError> for DeviceError {
200 fn from(error: HidError) -> Self {
201 DeviceError::HidError(error)
202 }
203}
204
205pub type DeviceResult<T> = Result<T, DeviceError>;
207
208#[derive(Debug)]
210pub struct Device<'a> {
211 device_info: &'a DeviceInfo,
212 device_type: DeviceType,
213}
214
215impl<'a> TryFrom<&'a DeviceInfo> for Device<'a> {
216 type Error = DeviceError;
217
218 fn try_from(device_info: &'a DeviceInfo) -> Result<Self, DeviceError> {
219 if device_info.vendor_id() != VENDOR_ID || device_info.usage_page() != USAGE_PAGE {
220 return Err(DeviceError::Unsupported);
221 }
222 device_type_from_product_id(device_info.product_id())
223 .map(|device_type| Device {
224 device_info,
225 device_type,
226 })
227 .ok_or(DeviceError::Unsupported)
228 }
229}
230
231impl Device<'_> {
232 #[must_use]
234 pub fn device_info(&self) -> &DeviceInfo {
235 self.device_info
236 }
237
238 #[must_use]
240 pub fn device_type(&self) -> DeviceType {
241 self.device_type
242 }
243
244 #[must_use]
246 pub fn device_path(&self) -> String {
247 self.device_info.path().to_string_lossy().to_string()
248 }
249
250 pub fn open(&self, context: &Litra) -> DeviceResult<DeviceHandle> {
253 let hid_device = self.device_info.open_device(context.hidapi())?;
254 Ok(DeviceHandle {
255 hid_device,
256 device_type: self.device_type,
257 })
258 }
259}
260
261#[derive(Debug)]
263pub struct DeviceHandle {
264 hid_device: HidDevice,
265 device_type: DeviceType,
266}
267
268impl DeviceHandle {
269 #[must_use]
271 pub fn device_type(&self) -> DeviceType {
272 self.device_type
273 }
274
275 #[must_use]
277 pub fn hid_device(&self) -> &HidDevice {
278 &self.hid_device
279 }
280
281 pub fn serial_number(&self) -> DeviceResult<Option<String>> {
285 match self.hid_device.get_device_info() {
286 Ok(device_info) => {
287 if let Some(serial) = device_info.serial_number() {
288 if !serial.is_empty() {
289 return Ok(Some(String::from(serial)));
290 }
291 }
292
293 Ok(None)
294 }
295 Err(error) => Err(DeviceError::HidError(error)),
296 }
297 }
298
299 pub fn device_path(&self) -> DeviceResult<String> {
304 match self.hid_device.get_device_info() {
305 Ok(device_info) => Ok(device_info.path().to_string_lossy().to_string()),
306 Err(error) => Err(DeviceError::HidError(error)),
307 }
308 }
309
310 pub fn is_on(&self) -> DeviceResult<bool> {
312 let message = generate_is_on_bytes(&self.device_type);
313
314 self.hid_device.write(&message)?;
315
316 let mut response_buffer = [0x00; 20];
317 let response = self.hid_device.read(&mut response_buffer[..])?;
318
319 Ok(response_buffer[..response][4] == 1)
320 }
321
322 pub fn set_on(&self, on: bool) -> DeviceResult<()> {
325 let message = generate_set_on_bytes(&self.device_type, on);
326
327 self.hid_device.write(&message)?;
328 Ok(())
329 }
330
331 pub fn brightness_in_lumen(&self) -> DeviceResult<u16> {
333 let message = generate_get_brightness_in_lumen_bytes(&self.device_type);
334
335 self.hid_device.write(&message)?;
336
337 let mut response_buffer = [0x00; 20];
338 let response = self.hid_device.read(&mut response_buffer[..])?;
339
340 Ok(u16::from(response_buffer[..response][4]) * 256
341 + u16::from(response_buffer[..response][5]))
342 }
343
344 pub fn set_brightness_in_lumen(&self, brightness_in_lumen: u16) -> DeviceResult<()> {
346 if brightness_in_lumen < self.minimum_brightness_in_lumen()
347 || brightness_in_lumen > self.maximum_brightness_in_lumen()
348 {
349 return Err(DeviceError::InvalidBrightness(brightness_in_lumen));
350 }
351
352 let message =
353 generate_set_brightness_in_lumen_bytes(&self.device_type, brightness_in_lumen);
354
355 self.hid_device.write(&message)?;
356 Ok(())
357 }
358
359 #[must_use]
361 pub fn minimum_brightness_in_lumen(&self) -> u16 {
362 match self.device_type {
363 DeviceType::LitraGlow => 20,
364 DeviceType::LitraBeam | DeviceType::LitraBeamLX => 30,
365 }
366 }
367
368 #[must_use]
370 pub fn maximum_brightness_in_lumen(&self) -> u16 {
371 match self.device_type {
372 DeviceType::LitraGlow => 250,
373 DeviceType::LitraBeam | DeviceType::LitraBeamLX => 400,
374 }
375 }
376
377 pub fn temperature_in_kelvin(&self) -> DeviceResult<u16> {
379 let message = generate_get_temperature_in_kelvin_bytes(&self.device_type);
380
381 self.hid_device.write(&message)?;
382
383 let mut response_buffer = [0x00; 20];
384 let response = self.hid_device.read(&mut response_buffer[..])?;
385 Ok(u16::from(response_buffer[..response][4]) * 256
386 + u16::from(response_buffer[..response][5]))
387 }
388
389 pub fn set_temperature_in_kelvin(&self, temperature_in_kelvin: u16) -> DeviceResult<()> {
391 if temperature_in_kelvin < self.minimum_temperature_in_kelvin()
392 || temperature_in_kelvin > self.maximum_temperature_in_kelvin()
393 || !temperature_in_kelvin.is_multiple_of(100)
394 {
395 return Err(DeviceError::InvalidTemperature(temperature_in_kelvin));
396 }
397
398 let message =
399 generate_set_temperature_in_kelvin_bytes(&self.device_type, temperature_in_kelvin);
400
401 self.hid_device.write(&message)?;
402 Ok(())
403 }
404
405 #[must_use]
407 pub fn minimum_temperature_in_kelvin(&self) -> u16 {
408 MINIMUM_TEMPERATURE_IN_KELVIN
409 }
410
411 #[must_use]
413 pub fn maximum_temperature_in_kelvin(&self) -> u16 {
414 MAXIMUM_TEMPERATURE_IN_KELVIN
415 }
416
417 pub fn set_back_color(&self, zone_id: u8, red: u8, green: u8, blue: u8) -> DeviceResult<()> {
419 if self.device_type != DeviceType::LitraBeamLX {
420 return Err(DeviceError::UnsupportedDeviceType);
421 }
422
423 if zone_id == 0 || zone_id > 7 {
425 return Err(DeviceError::InvalidZone(zone_id));
426 }
427
428 let message = generate_set_back_color_bytes(zone_id, red.max(1), green.max(1), blue.max(1));
430
431 self.hid_device.write(&message)?;
432 self.hid_device
433 .write(&[0x11, 0xff, 0x0C, 0x7B, 0, 0, 1, 0, 0])?;
434 Ok(())
435 }
436
437 pub fn set_back_brightness_percentage(&self, brightness: u8) -> DeviceResult<()> {
439 if self.device_type != DeviceType::LitraBeamLX {
440 return Err(DeviceError::UnsupportedDeviceType);
441 }
442 if brightness == 0 || brightness > 100 {
443 return Err(DeviceError::InvalidPercentage(brightness));
444 }
445
446 let message = generate_set_back_brightness_percentage_bytes(brightness);
447
448 self.hid_device.write(&message)?;
449 Ok(())
450 }
451
452 pub fn set_back_on(&self, on: bool) -> DeviceResult<()> {
455 if self.device_type != DeviceType::LitraBeamLX {
456 return Err(DeviceError::UnsupportedDeviceType);
457 }
458 let message = generate_set_back_on_bytes(on);
459
460 self.hid_device.write(&message)?;
461 Ok(())
462 }
463
464 pub fn is_back_on(&self) -> DeviceResult<bool> {
466 if self.device_type != DeviceType::LitraBeamLX {
467 return Err(DeviceError::UnsupportedDeviceType);
468 }
469 let message = generate_get_back_on_bytes();
470
471 self.hid_device.write(&message)?;
472
473 let mut response_buffer = [0x00; 20];
474 let response = self.hid_device.read(&mut response_buffer[..])?;
475
476 Ok(response_buffer[..response][4] == 1)
477 }
478
479 pub fn back_brightness_percentage(&self) -> DeviceResult<u8> {
481 if self.device_type != DeviceType::LitraBeamLX {
482 return Err(DeviceError::UnsupportedDeviceType);
483 }
484 let message = generate_get_back_brightness_percentage_bytes();
485
486 self.hid_device.write(&message)?;
487
488 let mut response_buffer = [0x00; 20];
489 let response = self.hid_device.read(&mut response_buffer[..])?;
490
491 let brightness = u16::from(response_buffer[..response][4]) * 256
493 + u16::from(response_buffer[..response][5]);
494 Ok(brightness as u8)
495 }
496}
497
498const VENDOR_ID: u16 = 0x046d;
499const USAGE_PAGE: u16 = 0xff43;
500
501fn device_type_from_product_id(product_id: u16) -> Option<DeviceType> {
502 match product_id {
503 0xc900 => DeviceType::LitraGlow.into(),
504 0xc901 => DeviceType::LitraBeam.into(),
505 0xb901 => DeviceType::LitraBeam.into(),
506 0xc903 => DeviceType::LitraBeamLX.into(),
507 _ => None,
508 }
509}
510
511const MINIMUM_TEMPERATURE_IN_KELVIN: u16 = 2700;
512const MAXIMUM_TEMPERATURE_IN_KELVIN: u16 = 6500;
513
514fn generate_is_on_bytes(device_type: &DeviceType) -> [u8; 20] {
515 match device_type {
516 DeviceType::LitraGlow | DeviceType::LitraBeam => [
517 0x11, 0xff, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
518 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
519 ],
520 DeviceType::LitraBeamLX => [
521 0x11, 0xff, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
522 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
523 ],
524 }
525}
526
527fn generate_get_brightness_in_lumen_bytes(device_type: &DeviceType) -> [u8; 20] {
528 match device_type {
529 DeviceType::LitraGlow | DeviceType::LitraBeam => [
530 0x11, 0xff, 0x04, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
532 ],
533 DeviceType::LitraBeamLX => [
534 0x11, 0xff, 0x06, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
535 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
536 ],
537 }
538}
539
540fn generate_get_temperature_in_kelvin_bytes(device_type: &DeviceType) -> [u8; 20] {
541 match device_type {
542 DeviceType::LitraGlow | DeviceType::LitraBeam => [
543 0x11, 0xff, 0x04, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
544 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
545 ],
546 DeviceType::LitraBeamLX => [
547 0x11, 0xff, 0x06, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
548 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
549 ],
550 }
551}
552
553fn generate_set_on_bytes(device_type: &DeviceType, on: bool) -> [u8; 20] {
554 let on_byte = if on { 0x01 } else { 0x00 };
555 match device_type {
556 DeviceType::LitraGlow | DeviceType::LitraBeam => [
557 0x11, 0xff, 0x04, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
558 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
559 ],
560 DeviceType::LitraBeamLX => [
561 0x11, 0xff, 0x06, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
562 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
563 ],
564 }
565}
566
567fn generate_set_brightness_in_lumen_bytes(
568 device_type: &DeviceType,
569 brightness_in_lumen: u16,
570) -> [u8; 20] {
571 let brightness_bytes = brightness_in_lumen.to_be_bytes();
572
573 match device_type {
574 DeviceType::LitraGlow | DeviceType::LitraBeam => [
575 0x11,
576 0xff,
577 0x04,
578 0x4c,
579 brightness_bytes[0],
580 brightness_bytes[1],
581 0x00,
582 0x00,
583 0x00,
584 0x00,
585 0x00,
586 0x00,
587 0x00,
588 0x00,
589 0x00,
590 0x00,
591 0x00,
592 0x00,
593 0x00,
594 0x00,
595 ],
596 DeviceType::LitraBeamLX => [
597 0x11,
598 0xff,
599 0x06,
600 0x4c,
601 brightness_bytes[0],
602 brightness_bytes[1],
603 0x00,
604 0x00,
605 0x00,
606 0x00,
607 0x00,
608 0x00,
609 0x00,
610 0x00,
611 0x00,
612 0x00,
613 0x00,
614 0x00,
615 0x00,
616 0x00,
617 ],
618 }
619}
620
621fn generate_set_temperature_in_kelvin_bytes(
622 device_type: &DeviceType,
623 temperature_in_kelvin: u16,
624) -> [u8; 20] {
625 let temperature_bytes = temperature_in_kelvin.to_be_bytes();
626
627 match device_type {
628 DeviceType::LitraGlow | DeviceType::LitraBeam => [
629 0x11,
630 0xff,
631 0x04,
632 0x9c,
633 temperature_bytes[0],
634 temperature_bytes[1],
635 0x00,
636 0x00,
637 0x00,
638 0x00,
639 0x00,
640 0x00,
641 0x00,
642 0x00,
643 0x00,
644 0x00,
645 0x00,
646 0x00,
647 0x00,
648 0x00,
649 ],
650 DeviceType::LitraBeamLX => [
651 0x11,
652 0xff,
653 0x06,
654 0x9c,
655 temperature_bytes[0],
656 temperature_bytes[1],
657 0x00,
658 0x00,
659 0x00,
660 0x00,
661 0x00,
662 0x00,
663 0x00,
664 0x00,
665 0x00,
666 0x00,
667 0x00,
668 0x00,
669 0x00,
670 0x00,
671 ],
672 }
673}
674
675fn generate_set_back_color_bytes(zone_id: u8, red: u8, green: u8, blue: u8) -> [u8; 20] {
676 [
677 0x11, 0xff, 0x0C, 0x1B, zone_id, red, green, blue, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00,
678 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
679 ]
680}
681
682fn generate_set_back_brightness_percentage_bytes(brightness: u8) -> [u8; 20] {
683 [
684 0x11, 0xff, 0x0a, 0x2b, 0x00, brightness, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
685 ]
686}
687
688fn generate_set_back_on_bytes(on: bool) -> [u8; 20] {
689 [
690 0x11,
691 0xff,
692 0x0a,
693 0x4b,
694 if on { 1 } else { 0 },
695 0,
696 0,
697 0,
698 0,
699 0,
700 0,
701 0,
702 0,
703 0,
704 0,
705 0,
706 0,
707 0,
708 0,
709 0,
710 ]
711}
712
713fn generate_get_back_on_bytes() -> [u8; 20] {
714 [
715 0x11, 0xff, 0x0a, 0x3b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
716 0x00, 0x00, 0x00, 0x00, 0x00,
717 ]
718}
719
720fn generate_get_back_brightness_percentage_bytes() -> [u8; 20] {
721 [
722 0x11, 0xff, 0x0a, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
723 0x00, 0x00, 0x00, 0x00, 0x00,
724 ]
725}