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;
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#[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
92pub 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
176pub 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}