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