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::backend::HidBackend;
17use crate::channel::route::{DeviceRoute, open_route_writer};
18
19use super::WriteError;
20
21pub 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
47const 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#[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
90pub 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
174pub 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 #[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}