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