1use 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
22pub use openlogi_core::hid::light::LightCommand;
26
27pub const LITRA_DRIVER_ID: &str = "litra";
29pub const LITRA_GLOW_PRODUCT_ID: u16 = 0xc900;
31pub const LITRA_BEAM_PRODUCT_ID: u16 = 0xc901;
33pub const LITRA_USAGE_PAGE: u16 = 0xff43;
35pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum LitraModel {
74 Glow,
76 Beam,
78}
79
80impl LitraModel {
81 #[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 #[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 #[must_use]
113 pub const fn driver_id(self) -> &'static str {
114 LITRA_DRIVER_ID
115 }
116
117 #[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 #[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#[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
150pub 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
237pub 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}