Skip to main content

hidpp/feature/
backlight.rs

1//! Implements the `Backlight` feature (ID `0x1982`, version 3) for keyboards
2//! with an adjustable backlight.
3//!
4//! The feature enables/disables the backlight, selects a backlight mode
5//! (automatic via the ambient-light sensor, temporary manual, or permanent
6//! manual), chooses a predefined effect, and configures the manual level and
7//! fade-out durations.
8//!
9//! All multi-byte fields in this feature are little-endian.
10
11use num_enum::{IntoPrimitive, TryFromPrimitive};
12use openlogi_hidpp_derive::Feature;
13
14use crate::{
15    feature::{DecodeEvent, EventSource, FeatureEndpoint},
16    protocol::v20::Hidpp20Error,
17};
18
19/// The "do not change" sentinel for the backlight effect in `setBacklightConfig`.
20const EFFECT_UNCHANGED: u8 = 0xff;
21
22/// Bit offset of the 2-bit backlight mode inside the options field.
23const MODE_SHIFT: u16 = 3;
24/// Mask of the backlight-mode bits inside the options field.
25const MODE_MASK: u16 = 0b11 << MODE_SHIFT;
26
27bitflags::bitflags! {
28    /// Backlight options and capability bits from `getBacklightConfig`.
29    ///
30    /// The low bits are the currently enabled options; the high bits report
31    /// which options and modes the device supports. The 2-bit backlight mode
32    /// occupies bits 3..=4 and is exposed separately as [`BacklightMode`].
33    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
35    pub struct BacklightOptions: u16 {
36        /// The "wow" power-on effect is enabled.
37        const WOW = 1 << 0;
38        /// The "crown" touch effect is enabled.
39        const CROWN = 1 << 1;
40        /// Power-save (disable backlight at critical battery) is enabled.
41        const PWR_SAVE = 1 << 2;
42        /// The device supports the "wow" effect.
43        const WOW_SUPPORTED = 1 << 8;
44        /// The device supports the "crown" effect.
45        const CROWN_SUPPORTED = 1 << 9;
46        /// The device supports power-save.
47        const PWR_SAVE_SUPPORTED = 1 << 10;
48        /// The device supports automatic (ALS) mode.
49        const AUTO_MODE_SUPPORTED = 1 << 11;
50        /// The device supports temporary-manual mode.
51        const TEMP_MANUAL_SUPPORTED = 1 << 12;
52        /// The device supports permanent-manual mode.
53        const PERM_MANUAL_SUPPORTED = 1 << 13;
54    }
55}
56
57bitflags::bitflags! {
58    /// The set of predefined effects a device supports, from
59    /// `getBacklightConfig`.
60    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
61    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
62    pub struct BacklightEffectList: u16 {
63        /// The "static" effect.
64        const STATIC = 1 << 0;
65        /// The "none" effect.
66        const NONE = 1 << 1;
67        /// The "breathing light" effect.
68        const BREATHING = 1 << 2;
69        /// The "contrast" effect.
70        const CONTRAST = 1 << 3;
71        /// The "reaction" effect.
72        const REACTION = 1 << 4;
73        /// The "random" effect.
74        const RANDOM = 1 << 5;
75        /// The "waves" effect.
76        const WAVES = 1 << 6;
77    }
78}
79
80/// The backlight level-adjustment mode.
81#[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    /// No mode selected.
87    None = 0,
88    /// Automatic mode: level follows the ambient-light sensor.
89    Automatic = 1,
90    /// Temporary manual mode: level adjusted via the backlight keys. This mode
91    /// cannot be set by software.
92    TemporaryManual = 2,
93    /// Permanent manual mode: level adjusted by software.
94    PermanentManual = 3,
95}
96
97/// A predefined backlight effect.
98#[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    /// The "static" effect (default).
104    Static = 0,
105    /// The "none" effect.
106    None = 1,
107    /// The "breathing light" effect.
108    Breathing = 2,
109    /// The "contrast" effect.
110    Contrast = 3,
111    /// The "reaction" effect.
112    Reaction = 4,
113    /// The "random" effect.
114    Random = 5,
115    /// The "waves" effect.
116    Waves = 6,
117}
118
119/// The current backlight status from `getBacklightInfo`.
120#[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    /// Disabled by software.
126    DisabledBySoftware = 0,
127    /// Disabled because the battery is critically low.
128    DisabledByCriticalBattery = 1,
129    /// Automatic (ALS) mode.
130    AlsAutomatic = 2,
131    /// Automatic mode, saturated — the backlight is off.
132    AlsSaturated = 3,
133    /// Temporary manual mode (set by hardware).
134    TemporaryManual = 4,
135    /// Permanent manual mode (set by software).
136    PermanentManual = 5,
137}
138
139/// The backlight configuration from [`BacklightFeature::get_backlight_config`].
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize))]
142#[non_exhaustive]
143pub struct BacklightConfig {
144    /// Whether the backlight system is enabled.
145    pub enabled: bool,
146    /// Enabled options and supported capabilities.
147    pub options: BacklightOptions,
148    /// Currently selected backlight mode.
149    pub mode: BacklightMode,
150    /// Effects the device supports.
151    pub effect_list: BacklightEffectList,
152    /// Current manual brightness level (`0` = off, up to `7`).
153    pub current_level: u8,
154    /// Fade-out duration after the last keystroke with no proximity, in 5-second
155    /// units (`1..=0x05a0`).
156    pub duration_hands_out: u16,
157    /// Fade-out duration while hands remain in the detection zone, in 5-second
158    /// units.
159    pub duration_hands_in: u16,
160    /// Fade-out duration while externally powered, in 5-second units.
161    pub duration_powered: u16,
162}
163
164/// Backlight configuration to write with
165/// [`BacklightFeature::set_backlight_config`].
166#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
167#[cfg_attr(feature = "serde", derive(serde::Serialize))]
168pub struct SetBacklightConfig {
169    /// Whether to enable the backlight system.
170    pub enabled: bool,
171    /// Options to enable. Only [`BacklightOptions::WOW`],
172    /// [`BacklightOptions::CROWN`] and [`BacklightOptions::PWR_SAVE`] are
173    /// writable; the device discards unsupported options.
174    pub options: BacklightOptions,
175    /// Mode to select. [`BacklightMode::TemporaryManual`] cannot be set by
176    /// software.
177    pub mode: BacklightMode,
178    /// Effect to apply, or `None` to leave the current effect unchanged.
179    pub effect: Option<BacklightEffect>,
180    /// Manual brightness level (`0` = off, up to `7`).
181    pub current_level: u8,
182    /// Fade-out duration after the last keystroke with no proximity, in 5-second
183    /// units.
184    pub duration_hands_out: u16,
185    /// Fade-out duration while hands remain in the detection zone, in 5-second
186    /// units.
187    pub duration_hands_in: u16,
188    /// Fade-out duration while externally powered, in 5-second units.
189    pub duration_powered: u16,
190}
191
192/// Backlight information from [`BacklightFeature::get_backlight_info`].
193#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
194#[cfg_attr(feature = "serde", derive(serde::Serialize))]
195#[non_exhaustive]
196pub struct BacklightInfo {
197    /// Number of user-selectable intensity levels (`0..nb_levels`).
198    pub nb_levels: u8,
199    /// Current intensity level.
200    pub current_level: u8,
201    /// Current backlight status.
202    pub status: BacklightStatus,
203    /// Currently applied effect.
204    pub effect: BacklightEffect,
205    /// Out-of-box fade-out duration with hands out, in 5-second units.
206    pub oob_duration_hands_out: u16,
207    /// Out-of-box fade-out duration with hands in, in 5-second units.
208    pub oob_duration_hands_in: u16,
209    /// Out-of-box fade-out duration while externally powered, in 5-second units.
210    pub oob_duration_powered: u16,
211}
212
213/// An event emitted by [`BacklightFeature`].
214#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
215#[cfg_attr(feature = "serde", derive(serde::Serialize))]
216#[non_exhaustive]
217pub enum BacklightEvent {
218    /// The user changed the backlight; carries the latest backlight info.
219    InfoChanged(BacklightInfoUpdate),
220}
221
222/// Payload of [`BacklightEvent::InfoChanged`].
223#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
224#[cfg_attr(feature = "serde", derive(serde::Serialize))]
225#[non_exhaustive]
226pub struct BacklightInfoUpdate {
227    /// Number of user-selectable intensity levels.
228    pub nb_levels: u8,
229    /// Current intensity level.
230    pub current_level: u8,
231    /// Current backlight status.
232    pub status: BacklightStatus,
233    /// Currently applied effect.
234    pub effect: BacklightEffect,
235}
236
237/// Implements the `Backlight` / `0x1982` feature (version 3).
238#[derive(Feature)]
239#[creatable(id = 0x1982, version = 3)]
240pub struct BacklightFeature {
241    /// The endpoint this feature talks to.
242    endpoint: FeatureEndpoint,
243
244    /// Publishes decoded events to listeners.
245    events: EventSource<BacklightEvent>,
246}
247
248impl DecodeEvent for BacklightEvent {
249    fn decode(sub_id: u8, payload: &[u8; 16]) -> Option<Self> {
250        // backlightInfoEvent is the only event and carries sub-id 0.
251        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    /// Retrieves the current backlight configuration.
262    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    /// Writes the backlight configuration persistently (to non-volatile memory).
281    pub async fn set_backlight_config(
282        &self,
283        config: SetBacklightConfig,
284    ) -> Result<(), Hidpp20Error> {
285        // The request options byte packs the writable option flags (low 3 bits)
286        // and the 2-bit mode (bits 3..=4).
287        #[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    /// Retrieves general backlight information and out-of-box durations.
316    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    /// Applies a backlight effect temporarily (stored in RAM, not persisted).
332    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        // WOW enabled + permanent-manual mode (0b11 << 3) + auto-mode supported.
361        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        // The mode bits must not leak into the options flags.
371        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}