1use num_enum::{IntoPrimitive, TryFromPrimitive};
12use openlogi_hidpp_derive::Feature;
13
14use crate::{
15 feature::{DecodeEvent, EventSource, FeatureEndpoint},
16 protocol::v20::Hidpp20Error,
17};
18
19const EFFECT_UNCHANGED: u8 = 0xff;
21
22const MODE_SHIFT: u16 = 3;
24const MODE_MASK: u16 = 0b11 << MODE_SHIFT;
26
27bitflags::bitflags! {
28 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
35 pub struct BacklightOptions: u16 {
36 const WOW = 1 << 0;
38 const CROWN = 1 << 1;
40 const PWR_SAVE = 1 << 2;
42 const WOW_SUPPORTED = 1 << 8;
44 const CROWN_SUPPORTED = 1 << 9;
46 const PWR_SAVE_SUPPORTED = 1 << 10;
48 const AUTO_MODE_SUPPORTED = 1 << 11;
50 const TEMP_MANUAL_SUPPORTED = 1 << 12;
52 const PERM_MANUAL_SUPPORTED = 1 << 13;
54 }
55}
56
57bitflags::bitflags! {
58 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
61 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
62 pub struct BacklightEffectList: u16 {
63 const STATIC = 1 << 0;
65 const NONE = 1 << 1;
67 const BREATHING = 1 << 2;
69 const CONTRAST = 1 << 3;
71 const REACTION = 1 << 4;
73 const RANDOM = 1 << 5;
75 const WAVES = 1 << 6;
77 }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize))]
83#[non_exhaustive]
84#[repr(u8)]
85pub enum BacklightMode {
86 None = 0,
88 Automatic = 1,
90 TemporaryManual = 2,
93 PermanentManual = 3,
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize))]
100#[non_exhaustive]
101#[repr(u8)]
102pub enum BacklightEffect {
103 Static = 0,
105 None = 1,
107 Breathing = 2,
109 Contrast = 3,
111 Reaction = 4,
113 Random = 5,
115 Waves = 6,
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
121#[cfg_attr(feature = "serde", derive(serde::Serialize))]
122#[non_exhaustive]
123#[repr(u8)]
124pub enum BacklightStatus {
125 DisabledBySoftware = 0,
127 DisabledByCriticalBattery = 1,
129 AlsAutomatic = 2,
131 AlsSaturated = 3,
133 TemporaryManual = 4,
135 PermanentManual = 5,
137}
138
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize))]
142#[non_exhaustive]
143pub struct BacklightConfig {
144 pub enabled: bool,
146 pub options: BacklightOptions,
148 pub mode: BacklightMode,
150 pub effect_list: BacklightEffectList,
152 pub current_level: u8,
154 pub duration_hands_out: u16,
157 pub duration_hands_in: u16,
160 pub duration_powered: u16,
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
167#[cfg_attr(feature = "serde", derive(serde::Serialize))]
168pub struct SetBacklightConfig {
169 pub enabled: bool,
171 pub options: BacklightOptions,
175 pub mode: BacklightMode,
178 pub effect: Option<BacklightEffect>,
180 pub current_level: u8,
182 pub duration_hands_out: u16,
185 pub duration_hands_in: u16,
188 pub duration_powered: u16,
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
194#[cfg_attr(feature = "serde", derive(serde::Serialize))]
195#[non_exhaustive]
196pub struct BacklightInfo {
197 pub nb_levels: u8,
199 pub current_level: u8,
201 pub status: BacklightStatus,
203 pub effect: BacklightEffect,
205 pub oob_duration_hands_out: u16,
207 pub oob_duration_hands_in: u16,
209 pub oob_duration_powered: u16,
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
215#[cfg_attr(feature = "serde", derive(serde::Serialize))]
216#[non_exhaustive]
217pub enum BacklightEvent {
218 InfoChanged(BacklightInfoUpdate),
220}
221
222#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
224#[cfg_attr(feature = "serde", derive(serde::Serialize))]
225#[non_exhaustive]
226pub struct BacklightInfoUpdate {
227 pub nb_levels: u8,
229 pub current_level: u8,
231 pub status: BacklightStatus,
233 pub effect: BacklightEffect,
235}
236
237#[derive(Feature)]
239#[creatable(id = 0x1982, version = 3)]
240pub struct BacklightFeature {
241 endpoint: FeatureEndpoint,
243
244 events: EventSource<BacklightEvent>,
246}
247
248impl DecodeEvent for BacklightEvent {
249 fn decode(sub_id: u8, payload: &[u8; 16]) -> Option<Self> {
250 if sub_id != 0 {
252 return None;
253 }
254 BacklightInfoUpdate::from_payload(payload)
255 .ok()
256 .map(BacklightEvent::InfoChanged)
257 }
258}
259
260impl BacklightFeature {
261 pub async fn get_backlight_config(&self) -> Result<BacklightConfig, Hidpp20Error> {
263 let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
264 let raw_options = u16::from_le_bytes([payload[1], payload[2]]);
265 Ok(BacklightConfig {
266 enabled: payload[0] & 1 != 0,
267 options: BacklightOptions::from_bits_retain(raw_options & !MODE_MASK),
268 mode: BacklightMode::try_from(((raw_options & MODE_MASK) >> MODE_SHIFT) as u8)
269 .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
270 effect_list: BacklightEffectList::from_bits_retain(u16::from_le_bytes([
271 payload[3], payload[4],
272 ])),
273 current_level: payload[5],
274 duration_hands_out: u16::from_le_bytes([payload[6], payload[7]]),
275 duration_hands_in: u16::from_le_bytes([payload[8], payload[9]]),
276 duration_powered: u16::from_le_bytes([payload[10], payload[11]]),
277 })
278 }
279
280 pub async fn set_backlight_config(
282 &self,
283 config: SetBacklightConfig,
284 ) -> Result<(), Hidpp20Error> {
285 #[expect(
288 clippy::cast_possible_truncation,
289 reason = "masked to WOW|CROWN|PWR_SAVE (bits 0-2), so the value always fits in a u8"
290 )]
291 let options_byte = (config.options.bits()
292 & (BacklightOptions::WOW | BacklightOptions::CROWN | BacklightOptions::PWR_SAVE).bits())
293 as u8
294 | (u8::from(config.mode) << MODE_SHIFT);
295 let [out_lo, out_hi] = config.duration_hands_out.to_le_bytes();
296 let [in_lo, in_hi] = config.duration_hands_in.to_le_bytes();
297 let [pwr_lo, pwr_hi] = config.duration_powered.to_le_bytes();
298 let mut args = [0; 16];
299 args[..10].copy_from_slice(&[
300 u8::from(config.enabled),
301 options_byte,
302 config.effect.map_or(EFFECT_UNCHANGED, u8::from),
303 config.current_level,
304 out_lo,
305 out_hi,
306 in_lo,
307 in_hi,
308 pwr_lo,
309 pwr_hi,
310 ]);
311 self.endpoint.call_long(1, args).await?;
312 Ok(())
313 }
314
315 pub async fn get_backlight_info(&self) -> Result<BacklightInfo, Hidpp20Error> {
317 let payload = self.endpoint.call(2, [0; 3]).await?.extend_payload();
318 Ok(BacklightInfo {
319 nb_levels: payload[0],
320 current_level: payload[1],
321 status: BacklightStatus::try_from(payload[2])
322 .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
323 effect: BacklightEffect::try_from(payload[3])
324 .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
325 oob_duration_hands_out: u16::from_le_bytes([payload[4], payload[5]]),
326 oob_duration_hands_in: u16::from_le_bytes([payload[6], payload[7]]),
327 oob_duration_powered: u16::from_le_bytes([payload[8], payload[9]]),
328 })
329 }
330
331 pub async fn set_backlight_effect(&self, effect: BacklightEffect) -> Result<(), Hidpp20Error> {
333 self.endpoint.call(3, [effect.into(), 0, 0]).await?;
334 Ok(())
335 }
336}
337
338impl BacklightInfoUpdate {
339 fn from_payload(payload: &[u8; 16]) -> Result<Self, Hidpp20Error> {
340 Ok(Self {
341 nb_levels: payload[0],
342 current_level: payload[1],
343 status: BacklightStatus::try_from(payload[2])
344 .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
345 effect: BacklightEffect::try_from(payload[3])
346 .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
347 })
348 }
349}
350
351#[cfg(test)]
352#[allow(clippy::unwrap_used, reason = "expect/unwrap are idiomatic in tests")]
353mod tests {
354 use super::{
355 BacklightEffect, BacklightInfoUpdate, BacklightMode, BacklightOptions, BacklightStatus,
356 };
357
358 #[test]
359 fn decodes_options_and_mode_split() {
360 let raw = BacklightOptions::WOW.bits()
362 | (u16::from(u8::from(BacklightMode::PermanentManual)) << 3)
363 | BacklightOptions::AUTO_MODE_SUPPORTED.bits();
364 let mode = BacklightMode::try_from(((raw & (0b11 << 3)) >> 3) as u8).unwrap();
365 let options = BacklightOptions::from_bits_retain(raw & !(0b11 << 3));
366
367 assert_eq!(mode, BacklightMode::PermanentManual);
368 assert!(options.contains(BacklightOptions::WOW));
369 assert!(options.contains(BacklightOptions::AUTO_MODE_SUPPORTED));
370 assert!(!options.contains(BacklightOptions::PWR_SAVE));
372 assert!(!options.contains(BacklightOptions::CROWN));
373 }
374
375 #[test]
376 fn decodes_backlight_info_event() {
377 let mut payload = [0; 16];
378 payload[0] = 8;
379 payload[1] = 5;
380 payload[2] = 5;
381 payload[3] = 2;
382
383 let update = BacklightInfoUpdate::from_payload(&payload).unwrap();
384 assert_eq!(update.nb_levels, 8);
385 assert_eq!(update.current_level, 5);
386 assert_eq!(update.status, BacklightStatus::PermanentManual);
387 assert_eq!(update.effect, BacklightEffect::Breathing);
388 }
389
390 #[test]
391 fn maps_do_not_change_effect_sentinel() {
392 assert_eq!(None::<BacklightEffect>.map_or(0xff, u8::from), 0xff);
393 assert_eq!(Some(BacklightEffect::Waves).map_or(0xff, u8::from), 6);
394 }
395}