litra/
lib.rs

1//! Library to query and control your Logitech Litra lights.
2//!
3//! # Usage
4//!
5//! ```
6//! use litra::Litra;
7//!
8//! let context = Litra::new().expect("Failed to initialize litra.");
9//! for device in context.get_connected_devices() {
10//!     println!("Device {:?}", device.device_type());
11//!     if let Ok(handle) = device.open(&context) {
12//!         println!("| - Is on: {}", handle.is_on()
13//!             .map(|on| if on { "yes" } else { "no" })
14//!             .unwrap_or("unknown"));
15//!     }
16//! }
17//! ```
18
19#![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
38/// Litra context.
39///
40/// This can be used to list available devices.
41pub 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    /// Initialize a new Litra context.
51    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    /// Returns an [`Iterator`] of cached connected devices supported by this library. To refresh the list of connected devices, use [`Litra::refresh_connected_devices`].
59    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    /// Refreshes the list of connected devices, returned by [`Litra::get_connected_devices`].
70    pub fn refresh_connected_devices(&mut self) -> DeviceResult<()> {
71        self.0.refresh_devices()?;
72        Ok(())
73    }
74
75    /// Retrieve the underlying hidapi context.
76    #[must_use]
77    pub fn hidapi(&self) -> &HidApi {
78        &self.0
79    }
80}
81
82/// The model of the device.
83#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize)]
84#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
85pub enum DeviceType {
86    /// Logitech [Litra Glow][glow] streaming light with TrueSoft.
87    ///
88    /// [glow]: https://www.logitech.com/products/lighting/litra-glow.html
89    #[serde(rename = "glow")]
90    LitraGlow,
91    /// Logitech [Litra Beam][beam] LED streaming key light with TrueSoft.
92    ///
93    /// [beam]: https://www.logitechg.com/products/cameras-lighting/litra-beam-streaming-light.html
94    #[serde(rename = "beam")]
95    LitraBeam,
96    /// Logitech [Litra Beam LX][beamlx] dual-sided RGB streaming key light.
97    ///
98    /// [beamlx]: https://www.logitechg.com/products/cameras-lighting/litra-beam-lx-led-light.html
99    #[serde(rename = "beam_lx")]
100    LitraBeamLX,
101}
102
103impl DeviceType {
104    /// Returns true if this device type has a colorful back side (only Litra Beam LX).
105    #[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/// A device-related error.
136#[derive(Debug)]
137pub enum DeviceError {
138    /// Tried to use a device that is not supported.
139    Unsupported,
140    /// Tried to set an invalid brightness value.
141    InvalidBrightness(u16),
142    /// Tried to set an invalid temperature value.
143    InvalidTemperature(u16),
144    /// Tried to set an invalid percentage value.
145    InvalidPercentage(u8),
146    /// A [`hidapi`] operation failed.
147    HidError(HidError),
148    /// Tried to parse an unsupported device type.
149    UnsupportedDeviceType,
150    /// Tried to set an invalid color zone.
151    InvalidZone(u8),
152    /// Tried to set an invalid color value.
153    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
205/// The [`Result`] of a Litra device operation.
206pub type DeviceResult<T> = Result<T, DeviceError>;
207
208/// A device that can be used.
209#[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    /// The model of the device.
233    #[must_use]
234    pub fn device_info(&self) -> &DeviceInfo {
235        self.device_info
236    }
237
238    /// The model of the device.
239    #[must_use]
240    pub fn device_type(&self) -> DeviceType {
241        self.device_type
242    }
243
244    /// Returns the device path, which is a unique identifier for the device.
245    #[must_use]
246    pub fn device_path(&self) -> String {
247        self.device_info.path().to_string_lossy().to_string()
248    }
249
250    /// Opens the device and returns a [`DeviceHandle`] that can be used for getting and setting the
251    /// device status. On macOS, this will open the device in non-exclusive mode.
252    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/// The handle of an opened device that can be used for getting and setting the device status.
262#[derive(Debug)]
263pub struct DeviceHandle {
264    hid_device: HidDevice,
265    device_type: DeviceType,
266}
267
268impl DeviceHandle {
269    /// The model of the device.
270    #[must_use]
271    pub fn device_type(&self) -> DeviceType {
272        self.device_type
273    }
274
275    /// The [`HidDevice`] for the device.
276    #[must_use]
277    pub fn hid_device(&self) -> &HidDevice {
278        &self.hid_device
279    }
280
281    /// Returns the serial number of the device.
282    ///
283    /// This may return None if the device doesn't provide a serial number.
284    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    /// Returns the unique device path.
300    ///
301    /// This is a stable identifier that can be used to target a specific device,
302    /// even when the device doesn't provide a serial number.
303    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    /// Queries the current power status of the device. Returns `true` if the device is currently on.
311    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    /// Sets the power status of the device. Turns the device on if `true` is passed and turns it
323    /// of on `false`.
324    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    /// Queries the device's current brightness in Lumen.
332    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    /// Sets the device's brightness in Lumen.
345    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    /// Returns the minimum brightness supported by the device in Lumen.
360    #[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    /// Returns the maximum brightness supported by the device in Lumen.
369    #[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    /// Queries the device's current color temperature in Kelvin.
378    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    /// Sets the device's color temperature in Kelvin.
390    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    /// Returns the minimum color temperature supported by the device in Kelvin.
406    #[must_use]
407    pub fn minimum_temperature_in_kelvin(&self) -> u16 {
408        MINIMUM_TEMPERATURE_IN_KELVIN
409    }
410
411    /// Returns the maximum color temperature supported by the device in Kelvin.
412    #[must_use]
413    pub fn maximum_temperature_in_kelvin(&self) -> u16 {
414        MAXIMUM_TEMPERATURE_IN_KELVIN
415    }
416
417    /// Sets the color of one or more of the zones on the colorful back side of the Litra Beam LX. Only Litra Beam LX devices are supported.
418    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        // The device is divided into 7 sections
424        if zone_id == 0 || zone_id > 7 {
425            return Err(DeviceError::InvalidZone(zone_id));
426        }
427
428        // The device seems to freak out if these values are 0, prevent it
429        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    /// Sets the brightness of the colorful back side of the Litra Beam LX to a percentage value. Only Litra Beam LX devices are supported.
438    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    /// Sets the power status of the colorful back side of the Litra Beam LX. Only Litra Beam LX devices are supported.
453    /// Turns the device on if `true` is passed and turns it off on `false`.
454    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    /// Queries the current power status of the colorful back side of the Litra Beam LX. Returns `true` if the back light is currently on. Only Litra Beam LX devices are supported.
465    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    /// Queries the brightness of the colorful back side of the Litra Beam LX as a percentage. Only Litra Beam LX devices are supported.
480    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        // The brightness is returned as a 16-bit value but represents a percentage
492        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}