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_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#[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
91pub 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
175pub 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}