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 shared device registry owns product matching; this driver owns
5//! semantic-range conversion and fixed report encoding, while the generic
6//! transport only owns enumeration and 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::backend::HidBackend;
17use crate::channel::route::{DeviceRoute, open_route_writer};
18
19use super::WriteError;
20
21// LightCommand is pure IPC wire data with no HID++ I/O, so it lives in
22// `openlogi_core::hid::light`; re-exported here unchanged so this module's
23// own API surface doesn't churn.
24pub use openlogi_core::hid::light::LightCommand;
25pub use openlogi_device_registry::litra::{
26    LITRA_BEAM_PRODUCT_ID, LITRA_GLOW_PRODUCT_ID, LitraDescriptor, LitraModel, find_litra,
27    matches_litra,
28};
29
30const REPORT_LEN: usize = 20;
31const REPORT_ID: u8 = 0x11;
32const REPORT_PREFIX: [u8; 2] = [0xff, 0x04];
33const COMMAND_POWER: u8 = 0x1c;
34const COMMAND_BRIGHTNESS: u8 = 0x4c;
35const COMMAND_TEMPERATURE: u8 = 0x9c;
36const MIN_BRIGHTNESS_LUMENS: u16 = 20;
37const MAX_BRIGHTNESS_LUMENS: u16 = 250;
38const MIN_TEMPERATURE_KELVIN: u16 = 2700;
39const MAX_TEMPERATURE_KELVIN: u16 = 6500;
40const TEMPERATURE_STEP_KELVIN: u16 = 100;
41const RAW_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
42
43const fn validated_range(min: u16, max: u16, step: u16, unit: LightValueUnit) -> LightValueRange {
44    match LightValueRange::new(min, max, step, unit) {
45        Ok(range) => range,
46        Err(_) => panic!("invalid static Litra capability range"),
47    }
48}
49
50const GLOW_BRIGHTNESS_RANGE: LightValueRange = validated_range(
51    MIN_BRIGHTNESS_LUMENS,
52    MAX_BRIGHTNESS_LUMENS,
53    1,
54    LightValueUnit::Lumens,
55);
56const GLOW_TEMPERATURE_RANGE: LightValueRange = validated_range(
57    MIN_TEMPERATURE_KELVIN,
58    MAX_TEMPERATURE_KELVIN,
59    TEMPERATURE_STEP_KELVIN,
60    LightValueUnit::Kelvin,
61);
62
63/// Resolve a model only when the complete raw-HID route matches a registered
64/// Litra interface.
65#[must_use]
66pub fn litra_model_for_route(route: &DeviceRoute) -> Option<LitraModel> {
67    let DeviceRoute::RawHid {
68        vendor_id,
69        product_id,
70        usage_page,
71        usage_id,
72        ..
73    } = route
74    else {
75        return None;
76    };
77    find_litra(*vendor_id, *product_id, *usage_page, *usage_id).map(|device| device.model)
78}
79
80pub(crate) const fn litra_capabilities(model: LitraModel) -> LightCapabilities {
81    match model {
82        LitraModel::Glow | LitraModel::Beam => LightCapabilities {
83            power: true,
84            brightness: Some(GLOW_BRIGHTNESS_RANGE),
85            temperature: Some(GLOW_TEMPERATURE_RANGE),
86            color: false,
87            zones: false,
88        },
89    }
90}
91
92/// Encode a semantic command into the exact fixed-width Litra report.
93pub fn encode_command(
94    model: LitraModel,
95    command: LightCommand,
96) -> Result<[u8; REPORT_LEN], WriteError> {
97    let mut report = [0; REPORT_LEN];
98    report[0] = REPORT_ID;
99    report[1..3].copy_from_slice(&REPORT_PREFIX);
100    match command {
101        LightCommand::Power(enabled) => {
102            report[3] = COMMAND_POWER;
103            report[4] = u8::from(enabled);
104        }
105        LightCommand::BrightnessPercent(percent) => {
106            report[3] = COMMAND_BRIGHTNESS;
107            let range = litra_capabilities(model)
108                .brightness
109                .ok_or_else(|| unsupported("brightness"))?;
110            let lumens = percent_to_native(percent, range)?;
111            report[4..6].copy_from_slice(&lumens.to_be_bytes());
112        }
113        LightCommand::TemperatureKelvin(kelvin) => {
114            report[3] = COMMAND_TEMPERATURE;
115            let range = litra_capabilities(model)
116                .temperature
117                .ok_or_else(|| unsupported("temperature"))?;
118            if !range.contains(kelvin) {
119                return Err(WriteError::InvalidLightValue {
120                    control: "temperature_kelvin".into(),
121                    value: kelvin,
122                });
123            }
124            report[4..6].copy_from_slice(&kelvin.to_be_bytes());
125        }
126        LightCommand::BrightnessNative(value) => {
127            report[3] = COMMAND_BRIGHTNESS;
128            let range = litra_capabilities(model)
129                .brightness
130                .ok_or_else(|| unsupported("brightness"))?;
131            if !range.contains(value) {
132                return Err(WriteError::InvalidLightValue {
133                    control: "brightness_native".into(),
134                    value,
135                });
136            }
137            report[4..6].copy_from_slice(&value.to_be_bytes());
138        }
139    }
140    Ok(report)
141}
142
143fn percent_to_native(percent: u8, range: LightValueRange) -> Result<u16, WriteError> {
144    if percent > 100 {
145        return Err(WriteError::InvalidLightValue {
146            control: "brightness_percent".into(),
147            value: u16::from(percent),
148        });
149    }
150    range
151        .native_for_percent(percent)
152        .ok_or_else(|| WriteError::InvalidLightValue {
153            control: "brightness_percent".into(),
154            value: percent.into(),
155        })
156}
157
158fn unsupported(control: &str) -> WriteError {
159    WriteError::LightUnsupported {
160        control: control.into(),
161    }
162}
163
164static DEVICE_LOCKS: LazyLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> =
165    LazyLock::new(|| Mutex::new(HashMap::new()));
166
167async fn device_lock(route: &DeviceRoute) -> OwnedMutexGuard<()> {
168    let key = route.to_string();
169    let lock = {
170        let mut locks = DEVICE_LOCKS.lock().await;
171        Arc::clone(locks.entry(key).or_insert_with(|| Arc::new(Mutex::new(()))))
172    };
173    lock.lock_owned().await
174}
175
176/// Apply a semantic Litra command through a raw HID route.
177pub async fn apply(
178    backend: &dyn HidBackend,
179    route: &DeviceRoute,
180    model: LitraModel,
181    command: LightCommand,
182) -> Result<(), WriteError> {
183    let Some(route_model) = litra_model_for_route(route) else {
184        return Err(unsupported("raw_hid_route"));
185    };
186    if route_model != model {
187        return Err(unsupported("litra_model"));
188    }
189    let report = encode_command(model, command)?;
190    let _guard = device_lock(route).await;
191    let Some(mut writer) = open_route_writer(backend, route).await? else {
192        return Err(WriteError::DeviceNotFound);
193    };
194    tokio::time::timeout(RAW_WRITE_TIMEOUT, writer.write_output_report(&report))
195        .await
196        .map_err(|_| WriteError::RequestTimedOut {
197            operation: super::HidppOperation::Light,
198        })??;
199    debug!(route = %route, "applied raw Litra command");
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use std::assert_matches;
206
207    use super::{
208        COMMAND_BRIGHTNESS, COMMAND_POWER, COMMAND_TEMPERATURE, LightCommand, LitraModel,
209        REPORT_ID, encode_command, litra_model_for_route,
210    };
211    use crate::{DeviceRoute, WriteError};
212
213    #[test]
214    fn glow_power_reports_are_fixed_width() {
215        let on = encode_command(LitraModel::Glow, LightCommand::Power(true)).expect("valid");
216        let off = encode_command(LitraModel::Glow, LightCommand::Power(false)).expect("valid");
217        assert_eq!(&on[..5], &[REPORT_ID, 0xff, 0x04, COMMAND_POWER, 1]);
218        assert_eq!(&off[..5], &[REPORT_ID, 0xff, 0x04, COMMAND_POWER, 0]);
219        assert_eq!(on.len(), 20);
220        assert!(on[5..].iter().all(|byte| *byte == 0));
221        assert!(off[5..].iter().all(|byte| *byte == 0));
222    }
223
224    #[test]
225    fn glow_brightness_uses_big_endian_native_lumens() {
226        let report =
227            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(50)).expect("valid");
228        assert_eq!(&report[3..6], &[COMMAND_BRIGHTNESS, 0, 0x87]);
229    }
230
231    #[test]
232    fn glow_brightness_maps_normalized_boundaries_to_native_range() {
233        let minimum =
234            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(0)).expect("valid");
235        let maximum =
236            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(100)).expect("valid");
237        assert_eq!(&minimum[3..6], &[COMMAND_BRIGHTNESS, 0, 20]);
238        assert_eq!(&maximum[3..6], &[COMMAND_BRIGHTNESS, 0, 250]);
239    }
240
241    #[test]
242    fn glow_native_brightness_preserves_the_exact_requested_lumens() {
243        let report =
244            encode_command(LitraModel::Glow, LightCommand::BrightnessNative(136)).expect("valid");
245        assert_eq!(&report[3..6], &[COMMAND_BRIGHTNESS, 0, 136]);
246        assert_matches!(
247            encode_command(LitraModel::Glow, LightCommand::BrightnessNative(251)),
248            Err(WriteError::InvalidLightValue { .. })
249        );
250    }
251
252    #[test]
253    fn glow_temperature_uses_big_endian_kelvin() {
254        let report =
255            encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(4600)).expect("valid");
256        assert_eq!(&report[3..6], &[COMMAND_TEMPERATURE, 0x11, 0xf8]);
257    }
258
259    #[test]
260    fn glow_temperature_accepts_only_aligned_inclusive_boundaries() {
261        let minimum = encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(2700))
262            .expect("2700 K is the inclusive lower bound");
263        let maximum = encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(6500))
264            .expect("6500 K is the inclusive upper bound");
265        assert_eq!(&minimum[3..6], &[COMMAND_TEMPERATURE, 0x0a, 0x8c]);
266        assert_eq!(&maximum[3..6], &[COMMAND_TEMPERATURE, 0x19, 0x64]);
267        for invalid in [2600, 2750, 6600] {
268            assert_matches!(
269                encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(invalid)),
270                Err(WriteError::InvalidLightValue { .. })
271            );
272        }
273    }
274
275    #[test]
276    fn invalid_values_are_rejected() {
277        assert_matches!(
278            encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(101)),
279            Err(WriteError::InvalidLightValue { .. })
280        );
281        assert_matches!(
282            encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(2750)),
283            Err(WriteError::InvalidLightValue { .. })
284        );
285    }
286
287    #[test]
288    fn model_resolution_requires_the_complete_raw_route_tuple() {
289        let valid = DeviceRoute::RawHid {
290            vendor_id: 0x046d,
291            product_id: 0xc900,
292            usage_page: 0xff43,
293            usage_id: 0x0202,
294            identity: "serial:test".into(),
295        };
296        let wrong_usage = DeviceRoute::RawHid {
297            vendor_id: 0x046d,
298            product_id: 0xc900,
299            usage_page: 0xff43,
300            usage_id: 0x0203,
301            identity: "serial:test".into(),
302        };
303
304        assert_eq!(litra_model_for_route(&valid), Some(LitraModel::Glow));
305        assert_eq!(litra_model_for_route(&wrong_usage), None);
306        assert_eq!(
307            litra_model_for_route(&DeviceRoute::Direct {
308                vendor_id: 0x046d,
309                product_id: 0xc900,
310            }),
311            None
312        );
313    }
314}