Skip to main content

openlogi_device/write/
litra.rs

1//! Raw HID driver for Logitech Litra lights.
2//!
3//! Litra is deliberately implemented beside, not inside, the HID++ feature
4//! writers. The driver owns product matching, semantic-range conversion, and
5//! the fixed report encoding; the generic transport only owns enumeration and
6//! opening the selected raw HID node.
7
8use std::collections::HashMap;
9use std::sync::{Arc, LazyLock};
10use std::time::Duration;
11
12use openlogi_core::device::{LightCapabilities, LightValueRange, LightValueUnit};
13use tokio::sync::{Mutex, OwnedMutexGuard};
14use tracing::debug;
15
16use crate::LOGITECH_VENDOR_ID;
17use crate::backend::HidBackend;
18use crate::channel::route::{DeviceRoute, open_route_writer};
19
20use super::WriteError;
21
22// LightCommand is pure IPC wire data with no HID++ I/O, so it lives in
23// `openlogi_core::hid::light`; re-exported here unchanged so this module's
24// own API surface doesn't churn.
25pub use openlogi_core::hid::light::LightCommand;
26
27/// Stable driver-family identifier carried by standalone inventory records.
28pub const LITRA_DRIVER_ID: &str = "litra";
29/// Litra Glow product ID.
30pub const LITRA_GLOW_PRODUCT_ID: u16 = 0xc900;
31/// Litra Beam product ID.
32pub const LITRA_BEAM_PRODUCT_ID: u16 = 0xc901;
33/// Litra vendor usage page.
34pub const LITRA_USAGE_PAGE: u16 = 0xff43;
35/// Litra Glow usage ID.
36pub const LITRA_USAGE_ID: u16 = 0x0202;
37
38const REPORT_LEN: usize = 20;
39const REPORT_ID: u8 = 0x11;
40const REPORT_PREFIX: [u8; 2] = [0xff, 0x04];
41const COMMAND_POWER: u8 = 0x1c;
42const COMMAND_BRIGHTNESS: u8 = 0x4c;
43const COMMAND_TEMPERATURE: u8 = 0x9c;
44const MIN_BRIGHTNESS_LUMENS: u16 = 20;
45const MAX_BRIGHTNESS_LUMENS: u16 = 250;
46const MIN_TEMPERATURE_KELVIN: u16 = 2700;
47const MAX_TEMPERATURE_KELVIN: u16 = 6500;
48const TEMPERATURE_STEP_KELVIN: u16 = 100;
49const RAW_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
50
51const fn validated_range(min: u16, max: u16, step: u16, unit: LightValueUnit) -> LightValueRange {
52    match LightValueRange::new(min, max, step, unit) {
53        Ok(range) => range,
54        Err(_) => panic!("invalid static Litra capability range"),
55    }
56}
57
58const GLOW_BRIGHTNESS_RANGE: LightValueRange = validated_range(
59    MIN_BRIGHTNESS_LUMENS,
60    MAX_BRIGHTNESS_LUMENS,
61    1,
62    LightValueUnit::Lumens,
63);
64const GLOW_TEMPERATURE_RANGE: LightValueRange = validated_range(
65    MIN_TEMPERATURE_KELVIN,
66    MAX_TEMPERATURE_KELVIN,
67    TEMPERATURE_STEP_KELVIN,
68    LightValueUnit::Kelvin,
69);
70
71/// A supported Litra product family variant.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum LitraModel {
74    /// Logitech Litra Glow.
75    Glow,
76    /// Logitech Litra Beam.
77    Beam,
78}
79
80impl LitraModel {
81    /// Resolve a Litra model from its USB product ID.
82    #[must_use]
83    pub const fn from_product_id(product_id: u16) -> Option<Self> {
84        match product_id {
85            LITRA_GLOW_PRODUCT_ID => Some(Self::Glow),
86            LITRA_BEAM_PRODUCT_ID => Some(Self::Beam),
87            _ => None,
88        }
89    }
90
91    /// Resolve a model only when the complete raw-HID route matches a known
92    /// Litra interface. Product ID alone is insufficient protection against
93    /// writing a vendor report to an unrelated HID collection.
94    #[must_use]
95    pub fn from_route(route: &DeviceRoute) -> Option<Self> {
96        let DeviceRoute::RawHid {
97            vendor_id,
98            product_id,
99            usage_page,
100            usage_id,
101            ..
102        } = route
103        else {
104            return None;
105        };
106        matches_litra(*vendor_id, *product_id, *usage_page, *usage_id)
107            .then(|| Self::from_product_id(*product_id))
108            .flatten()
109    }
110
111    /// Stable driver-family identifier for this model.
112    #[must_use]
113    pub const fn driver_id(self) -> &'static str {
114        LITRA_DRIVER_ID
115    }
116
117    /// Exact model identifier used by the OpenLogi asset registry.
118    #[must_use]
119    pub const fn registry_model_id(self) -> Option<&'static str> {
120        match self {
121            Self::Glow => Some("8c900"),
122            Self::Beam => Some("8c901"),
123        }
124    }
125
126    /// Static capabilities exposed by the model.
127    #[must_use]
128    pub const fn capabilities(self) -> LightCapabilities {
129        match self {
130            Self::Glow | Self::Beam => LightCapabilities {
131                power: true,
132                brightness: Some(GLOW_BRIGHTNESS_RANGE),
133                temperature: Some(GLOW_TEMPERATURE_RANGE),
134                color: false,
135                zones: false,
136            },
137        }
138    }
139}
140
141/// Whether an HID descriptor identifies a supported Litra interface.
142#[must_use]
143pub fn matches_litra(vendor_id: u16, product_id: u16, usage_page: u16, usage_id: u16) -> bool {
144    vendor_id == LOGITECH_VENDOR_ID
145        && usage_page == LITRA_USAGE_PAGE
146        && usage_id == LITRA_USAGE_ID
147        && LitraModel::from_product_id(product_id).is_some()
148}
149
150/// Encode a semantic command into the exact fixed-width Litra report.
151pub fn encode_command(
152    model: LitraModel,
153    command: LightCommand,
154) -> Result<[u8; REPORT_LEN], WriteError> {
155    let mut report = [0; REPORT_LEN];
156    report[0] = REPORT_ID;
157    report[1..3].copy_from_slice(&REPORT_PREFIX);
158    match command {
159        LightCommand::Power(enabled) => {
160            report[3] = COMMAND_POWER;
161            report[4] = u8::from(enabled);
162        }
163        LightCommand::BrightnessPercent(percent) => {
164            report[3] = COMMAND_BRIGHTNESS;
165            let range = model
166                .capabilities()
167                .brightness
168                .ok_or_else(|| unsupported("brightness"))?;
169            let lumens = percent_to_native(percent, range)?;
170            report[4..6].copy_from_slice(&lumens.to_be_bytes());
171        }
172        LightCommand::TemperatureKelvin(kelvin) => {
173            report[3] = COMMAND_TEMPERATURE;
174            let range = model
175                .capabilities()
176                .temperature
177                .ok_or_else(|| unsupported("temperature"))?;
178            if !range.contains(kelvin) {
179                return Err(WriteError::InvalidLightValue {
180                    control: "temperature_kelvin".into(),
181                    value: kelvin,
182                });
183            }
184            report[4..6].copy_from_slice(&kelvin.to_be_bytes());
185        }
186        LightCommand::BrightnessNative(value) => {
187            report[3] = COMMAND_BRIGHTNESS;
188            let range = model
189                .capabilities()
190                .brightness
191                .ok_or_else(|| unsupported("brightness"))?;
192            if !range.contains(value) {
193                return Err(WriteError::InvalidLightValue {
194                    control: "brightness_native".into(),
195                    value,
196                });
197            }
198            report[4..6].copy_from_slice(&value.to_be_bytes());
199        }
200    }
201    Ok(report)
202}
203
204fn percent_to_native(percent: u8, range: LightValueRange) -> Result<u16, WriteError> {
205    if percent > 100 {
206        return Err(WriteError::InvalidLightValue {
207            control: "brightness_percent".into(),
208            value: u16::from(percent),
209        });
210    }
211    range
212        .native_for_percent(percent)
213        .ok_or_else(|| WriteError::InvalidLightValue {
214            control: "brightness_percent".into(),
215            value: percent.into(),
216        })
217}
218
219fn unsupported(control: &str) -> WriteError {
220    WriteError::LightUnsupported {
221        control: control.into(),
222    }
223}
224
225static DEVICE_LOCKS: LazyLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> =
226    LazyLock::new(|| Mutex::new(HashMap::new()));
227
228async fn device_lock(route: &DeviceRoute) -> OwnedMutexGuard<()> {
229    let key = route.to_string();
230    let lock = {
231        let mut locks = DEVICE_LOCKS.lock().await;
232        Arc::clone(locks.entry(key).or_insert_with(|| Arc::new(Mutex::new(()))))
233    };
234    lock.lock_owned().await
235}
236
237/// Apply a semantic Litra command through a raw HID route.
238pub async fn apply(
239    backend: &dyn HidBackend,
240    route: &DeviceRoute,
241    model: LitraModel,
242    command: LightCommand,
243) -> Result<(), WriteError> {
244    let Some(route_model) = LitraModel::from_route(route) else {
245        return Err(unsupported("raw_hid_route"));
246    };
247    if route_model != model {
248        return Err(unsupported("litra_model"));
249    }
250    let report = encode_command(model, command)?;
251    let _guard = device_lock(route).await;
252    let Some(mut writer) = open_route_writer(backend, route).await? else {
253        return Err(WriteError::DeviceNotFound);
254    };
255    tokio::time::timeout(RAW_WRITE_TIMEOUT, writer.write_output_report(&report))
256        .await
257        .map_err(|_| WriteError::RequestTimedOut {
258            operation: super::HidppOperation::Light,
259        })??;
260    debug!(route = %route, "applied raw Litra command");
261    Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266    use std::assert_matches;
267
268    use super::{
269        COMMAND_BRIGHTNESS, COMMAND_POWER, COMMAND_TEMPERATURE, LITRA_BEAM_PRODUCT_ID,
270        LightCommand, LitraModel, REPORT_ID, encode_command, matches_litra,
271    };
272    use crate::{DeviceRoute, WriteError};
273
274    #[test]
275    fn glow_power_reports_are_fixed_width() {
276        let on = encode_command(LitraModel::Glow, LightCommand::Power(true)).expect("valid");
277        let off = encode_command(LitraModel::Glow, LightCommand::Power(false)).expect("valid");
278        assert_eq!(&on[..5], &[REPORT_ID, 0xff, 0x04, COMMAND_POWER, 1]);
279        assert_eq!(&off[..5], &[REPORT_ID, 0xff, 0x04, COMMAND_POWER, 0]);
280        assert_eq!(on.len(), 20);
281        assert!(on[5..].iter().all(|byte| *byte == 0));
282        assert!(off[5..].iter().all(|byte| *byte == 0));
283    }
284
285    #[test]
286    fn glow_brightness_uses_big_endian_native_lumens() {
287        let report =
288            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(50)).expect("valid");
289        assert_eq!(&report[3..6], &[COMMAND_BRIGHTNESS, 0, 0x87]);
290    }
291
292    #[test]
293    fn glow_brightness_maps_normalized_boundaries_to_native_range() {
294        let minimum =
295            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(0)).expect("valid");
296        let maximum =
297            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(100)).expect("valid");
298        assert_eq!(&minimum[3..6], &[COMMAND_BRIGHTNESS, 0, 20]);
299        assert_eq!(&maximum[3..6], &[COMMAND_BRIGHTNESS, 0, 250]);
300    }
301
302    #[test]
303    fn glow_native_brightness_preserves_the_exact_requested_lumens() {
304        let report =
305            encode_command(LitraModel::Glow, LightCommand::BrightnessNative(136)).expect("valid");
306        assert_eq!(&report[3..6], &[COMMAND_BRIGHTNESS, 0, 136]);
307        assert_matches!(
308            encode_command(LitraModel::Glow, LightCommand::BrightnessNative(251)),
309            Err(WriteError::InvalidLightValue { .. })
310        );
311    }
312
313    #[test]
314    fn glow_temperature_uses_big_endian_kelvin() {
315        let report =
316            encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(4600)).expect("valid");
317        assert_eq!(&report[3..6], &[COMMAND_TEMPERATURE, 0x11, 0xf8]);
318    }
319
320    #[test]
321    fn glow_temperature_accepts_only_aligned_inclusive_boundaries() {
322        let minimum = encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(2700))
323            .expect("2700 K is the inclusive lower bound");
324        let maximum = encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(6500))
325            .expect("6500 K is the inclusive upper bound");
326        assert_eq!(&minimum[3..6], &[COMMAND_TEMPERATURE, 0x0a, 0x8c]);
327        assert_eq!(&maximum[3..6], &[COMMAND_TEMPERATURE, 0x19, 0x64]);
328        for invalid in [2600, 2750, 6600] {
329            assert_matches!(
330                encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(invalid)),
331                Err(WriteError::InvalidLightValue { .. })
332            );
333        }
334    }
335
336    #[test]
337    fn invalid_values_are_rejected() {
338        assert_matches!(
339            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(101)),
340            Err(WriteError::InvalidLightValue { .. })
341        );
342        assert_matches!(
343            encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(2750)),
344            Err(WriteError::InvalidLightValue { .. })
345        );
346    }
347
348    #[test]
349    fn matcher_requires_the_full_identity_tuple() {
350        assert!(matches_litra(0x046d, 0xc900, 0xff43, 0x0202));
351        assert!(!matches_litra(0x046d, 0xc900, 0xff43, 0x0203));
352        assert!(!matches_litra(0x046d, 0xc902, 0xff43, 0x0202));
353        assert!(!matches_litra(0x1234, 0xc900, 0xff43, 0x0202));
354    }
355
356    #[test]
357    fn glow_descriptor_exposes_driver_identity() {
358        assert_eq!(LitraModel::Glow.driver_id(), "litra");
359    }
360
361    #[test]
362    fn registry_model_ids_match_the_asset_registry() {
363        assert_eq!(LitraModel::Glow.registry_model_id(), Some("8c900"));
364        assert_eq!(LitraModel::Beam.registry_model_id(), Some("8c901"));
365    }
366
367    #[test]
368    fn beam_is_matched_by_its_complete_route_tuple() {
369        assert!(matches_litra(0x046d, 0xc901, 0xff43, 0x0202));
370        assert_eq!(
371            LitraModel::from_product_id(LITRA_BEAM_PRODUCT_ID),
372            Some(LitraModel::Beam)
373        );
374    }
375
376    #[test]
377    fn model_resolution_requires_the_complete_raw_route_tuple() {
378        let valid = DeviceRoute::RawHid {
379            vendor_id: 0x046d,
380            product_id: 0xc900,
381            usage_page: 0xff43,
382            usage_id: 0x0202,
383            identity: "serial:test".into(),
384        };
385        let wrong_usage = DeviceRoute::RawHid {
386            vendor_id: 0x046d,
387            product_id: 0xc900,
388            usage_page: 0xff43,
389            usage_id: 0x0203,
390            identity: "serial:test".into(),
391        };
392
393        assert_eq!(LitraModel::from_route(&valid), Some(LitraModel::Glow));
394        assert_eq!(LitraModel::from_route(&wrong_usage), None);
395        assert_eq!(
396            LitraModel::from_route(&DeviceRoute::Direct {
397                vendor_id: 0x046d,
398                product_id: 0xc900,
399            }),
400            None
401        );
402    }
403}