Skip to main content

hidpp/feature/backlight/
mod.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 std::sync::Arc;
12
13use num_enum::{IntoPrimitive, TryFromPrimitive};
14
15use crate::{
16    channel::{HidppChannel, MessageListenerGuard},
17    event::EventEmitter,
18    feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload},
19    protocol::v20::Hidpp20Error,
20};
21
22/// The "do not change" sentinel for the backlight effect in `setBacklightConfig`.
23const EFFECT_UNCHANGED: u8 = 0xff;
24
25/// Bit offset of the 2-bit backlight mode inside the options field.
26const MODE_SHIFT: u16 = 3;
27/// Mask of the backlight-mode bits inside the options field.
28const MODE_MASK: u16 = 0b11 << MODE_SHIFT;
29
30bitflags::bitflags! {
31    /// Backlight options and capability bits from `getBacklightConfig`.
32    ///
33    /// The low bits are the currently enabled options; the high bits report
34    /// which options and modes the device supports. The 2-bit backlight mode
35    /// occupies bits 3..=4 and is exposed separately as [`BacklightMode`].
36    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
37    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
38    pub struct BacklightOptions: u16 {
39        /// The "wow" power-on effect is enabled.
40        const WOW = 1 << 0;
41        /// The "crown" touch effect is enabled.
42        const CROWN = 1 << 1;
43        /// Power-save (disable backlight at critical battery) is enabled.
44        const PWR_SAVE = 1 << 2;
45        /// The device supports the "wow" effect.
46        const WOW_SUPPORTED = 1 << 8;
47        /// The device supports the "crown" effect.
48        const CROWN_SUPPORTED = 1 << 9;
49        /// The device supports power-save.
50        const PWR_SAVE_SUPPORTED = 1 << 10;
51        /// The device supports automatic (ALS) mode.
52        const AUTO_MODE_SUPPORTED = 1 << 11;
53        /// The device supports temporary-manual mode.
54        const TEMP_MANUAL_SUPPORTED = 1 << 12;
55        /// The device supports permanent-manual mode.
56        const PERM_MANUAL_SUPPORTED = 1 << 13;
57    }
58}
59
60bitflags::bitflags! {
61    /// The set of predefined effects a device supports, from
62    /// `getBacklightConfig`.
63    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
65    pub struct BacklightEffectList: u16 {
66        /// The "static" effect.
67        const STATIC = 1 << 0;
68        /// The "none" effect.
69        const NONE = 1 << 1;
70        /// The "breathing light" effect.
71        const BREATHING = 1 << 2;
72        /// The "contrast" effect.
73        const CONTRAST = 1 << 3;
74        /// The "reaction" effect.
75        const REACTION = 1 << 4;
76        /// The "random" effect.
77        const RANDOM = 1 << 5;
78        /// The "waves" effect.
79        const WAVES = 1 << 6;
80    }
81}
82
83/// The backlight level-adjustment mode.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize))]
86#[non_exhaustive]
87#[repr(u8)]
88pub enum BacklightMode {
89    /// No mode selected.
90    None = 0,
91    /// Automatic mode: level follows the ambient-light sensor.
92    Automatic = 1,
93    /// Temporary manual mode: level adjusted via the backlight keys. This mode
94    /// cannot be set by software.
95    TemporaryManual = 2,
96    /// Permanent manual mode: level adjusted by software.
97    PermanentManual = 3,
98}
99
100/// A predefined backlight effect.
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize))]
103#[non_exhaustive]
104#[repr(u8)]
105pub enum BacklightEffect {
106    /// The "static" effect (default).
107    Static = 0,
108    /// The "none" effect.
109    None = 1,
110    /// The "breathing light" effect.
111    Breathing = 2,
112    /// The "contrast" effect.
113    Contrast = 3,
114    /// The "reaction" effect.
115    Reaction = 4,
116    /// The "random" effect.
117    Random = 5,
118    /// The "waves" effect.
119    Waves = 6,
120}
121
122/// The current backlight status from `getBacklightInfo`.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize))]
125#[non_exhaustive]
126#[repr(u8)]
127pub enum BacklightStatus {
128    /// Disabled by software.
129    DisabledBySoftware = 0,
130    /// Disabled because the battery is critically low.
131    DisabledByCriticalBattery = 1,
132    /// Automatic (ALS) mode.
133    AlsAutomatic = 2,
134    /// Automatic mode, saturated — the backlight is off.
135    AlsSaturated = 3,
136    /// Temporary manual mode (set by hardware).
137    TemporaryManual = 4,
138    /// Permanent manual mode (set by software).
139    PermanentManual = 5,
140}
141
142/// The backlight configuration from [`BacklightFeature::get_backlight_config`].
143#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize))]
145#[non_exhaustive]
146pub struct BacklightConfig {
147    /// Whether the backlight system is enabled.
148    pub enabled: bool,
149    /// Enabled options and supported capabilities.
150    pub options: BacklightOptions,
151    /// Currently selected backlight mode.
152    pub mode: BacklightMode,
153    /// Effects the device supports.
154    pub effect_list: BacklightEffectList,
155    /// Current manual brightness level (`0` = off, up to `7`).
156    pub current_level: u8,
157    /// Fade-out duration after the last keystroke with no proximity, in 5-second
158    /// units (`1..=0x05a0`).
159    pub duration_hands_out: u16,
160    /// Fade-out duration while hands remain in the detection zone, in 5-second
161    /// units.
162    pub duration_hands_in: u16,
163    /// Fade-out duration while externally powered, in 5-second units.
164    pub duration_powered: u16,
165}
166
167/// Backlight configuration to write with
168/// [`BacklightFeature::set_backlight_config`].
169#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize))]
171pub struct SetBacklightConfig {
172    /// Whether to enable the backlight system.
173    pub enabled: bool,
174    /// Options to enable. Only [`BacklightOptions::WOW`],
175    /// [`BacklightOptions::CROWN`] and [`BacklightOptions::PWR_SAVE`] are
176    /// writable; the device discards unsupported options.
177    pub options: BacklightOptions,
178    /// Mode to select. [`BacklightMode::TemporaryManual`] cannot be set by
179    /// software.
180    pub mode: BacklightMode,
181    /// Effect to apply, or `None` to leave the current effect unchanged.
182    pub effect: Option<BacklightEffect>,
183    /// Manual brightness level (`0` = off, up to `7`).
184    pub current_level: u8,
185    /// Fade-out duration after the last keystroke with no proximity, in 5-second
186    /// units.
187    pub duration_hands_out: u16,
188    /// Fade-out duration while hands remain in the detection zone, in 5-second
189    /// units.
190    pub duration_hands_in: u16,
191    /// Fade-out duration while externally powered, in 5-second units.
192    pub duration_powered: u16,
193}
194
195/// Backlight information from [`BacklightFeature::get_backlight_info`].
196#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
197#[cfg_attr(feature = "serde", derive(serde::Serialize))]
198#[non_exhaustive]
199pub struct BacklightInfo {
200    /// Number of user-selectable intensity levels (`0..nb_levels`).
201    pub nb_levels: u8,
202    /// Current intensity level.
203    pub current_level: u8,
204    /// Current backlight status.
205    pub status: BacklightStatus,
206    /// Currently applied effect.
207    pub effect: BacklightEffect,
208    /// Out-of-box fade-out duration with hands out, in 5-second units.
209    pub oob_duration_hands_out: u16,
210    /// Out-of-box fade-out duration with hands in, in 5-second units.
211    pub oob_duration_hands_in: u16,
212    /// Out-of-box fade-out duration while externally powered, in 5-second units.
213    pub oob_duration_powered: u16,
214}
215
216/// An event emitted by [`BacklightFeature`].
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
218#[cfg_attr(feature = "serde", derive(serde::Serialize))]
219#[non_exhaustive]
220pub enum BacklightEvent {
221    /// The user changed the backlight; carries the latest backlight info.
222    InfoChanged(BacklightInfoUpdate),
223}
224
225/// Payload of [`BacklightEvent::InfoChanged`].
226#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize))]
228#[non_exhaustive]
229pub struct BacklightInfoUpdate {
230    /// Number of user-selectable intensity levels.
231    pub nb_levels: u8,
232    /// Current intensity level.
233    pub current_level: u8,
234    /// Current backlight status.
235    pub status: BacklightStatus,
236    /// Currently applied effect.
237    pub effect: BacklightEffect,
238}
239
240/// Implements the `Backlight` / `0x1982` feature (version 3).
241pub struct BacklightFeature {
242    /// The endpoint this feature talks to.
243    endpoint: FeatureEndpoint,
244
245    /// The emitter used to publish decoded events.
246    emitter: Arc<EventEmitter<BacklightEvent>>,
247
248    /// Removes the message listener when the feature is dropped.
249    _msg_listener: MessageListenerGuard,
250}
251
252impl CreatableFeature for BacklightFeature {
253    const ID: u16 = 0x1982;
254    const STARTING_VERSION: u8 = 3;
255
256    fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
257        let emitter = Arc::new(EventEmitter::new());
258
259        let listener = chan.add_msg_listener_guarded({
260            let emitter = Arc::clone(&emitter);
261
262            move |raw, matched| {
263                let Some((func, payload)) =
264                    event_payload(raw, matched, device_index, feature_index)
265                else {
266                    return;
267                };
268                // backlightInfoEvent is the only event and carries sub-id 0.
269                if func.to_lo() != 0 {
270                    return;
271                }
272                if let Ok(update) = BacklightInfoUpdate::from_payload(&payload) {
273                    emitter.emit(BacklightEvent::InfoChanged(update));
274                }
275            }
276        });
277
278        Self {
279            endpoint: FeatureEndpoint::new(chan, device_index, feature_index),
280            emitter,
281            _msg_listener: listener,
282        }
283    }
284}
285
286impl Feature for BacklightFeature {}
287
288impl EmittingFeature<BacklightEvent> for BacklightFeature {
289    fn listen(&self) -> async_channel::Receiver<BacklightEvent> {
290        self.emitter.create_receiver()
291    }
292}
293
294impl BacklightFeature {
295    /// Retrieves the current backlight configuration.
296    pub async fn get_backlight_config(&self) -> Result<BacklightConfig, Hidpp20Error> {
297        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
298        let raw_options = u16::from_le_bytes([payload[1], payload[2]]);
299        Ok(BacklightConfig {
300            enabled: payload[0] & 1 != 0,
301            options: BacklightOptions::from_bits_retain(raw_options & !MODE_MASK),
302            mode: BacklightMode::try_from(((raw_options & MODE_MASK) >> MODE_SHIFT) as u8)
303                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
304            effect_list: BacklightEffectList::from_bits_retain(u16::from_le_bytes([
305                payload[3], payload[4],
306            ])),
307            current_level: payload[5],
308            duration_hands_out: u16::from_le_bytes([payload[6], payload[7]]),
309            duration_hands_in: u16::from_le_bytes([payload[8], payload[9]]),
310            duration_powered: u16::from_le_bytes([payload[10], payload[11]]),
311        })
312    }
313
314    /// Writes the backlight configuration persistently (to non-volatile memory).
315    pub async fn set_backlight_config(
316        &self,
317        config: SetBacklightConfig,
318    ) -> Result<(), Hidpp20Error> {
319        // The request options byte packs the writable option flags (low 3 bits)
320        // and the 2-bit mode (bits 3..=4).
321        let options_byte = (config.options.bits()
322            & (BacklightOptions::WOW | BacklightOptions::CROWN | BacklightOptions::PWR_SAVE).bits())
323            as u8
324            | (u8::from(config.mode) << MODE_SHIFT);
325        let [out_lo, out_hi] = config.duration_hands_out.to_le_bytes();
326        let [in_lo, in_hi] = config.duration_hands_in.to_le_bytes();
327        let [pwr_lo, pwr_hi] = config.duration_powered.to_le_bytes();
328        let mut args = [0; 16];
329        args[..10].copy_from_slice(&[
330            u8::from(config.enabled),
331            options_byte,
332            config.effect.map_or(EFFECT_UNCHANGED, u8::from),
333            config.current_level,
334            out_lo,
335            out_hi,
336            in_lo,
337            in_hi,
338            pwr_lo,
339            pwr_hi,
340        ]);
341        self.endpoint.call_long(1, args).await?;
342        Ok(())
343    }
344
345    /// Retrieves general backlight information and out-of-box durations.
346    pub async fn get_backlight_info(&self) -> Result<BacklightInfo, Hidpp20Error> {
347        let payload = self.endpoint.call(2, [0; 3]).await?.extend_payload();
348        Ok(BacklightInfo {
349            nb_levels: payload[0],
350            current_level: payload[1],
351            status: BacklightStatus::try_from(payload[2])
352                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
353            effect: BacklightEffect::try_from(payload[3])
354                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
355            oob_duration_hands_out: u16::from_le_bytes([payload[4], payload[5]]),
356            oob_duration_hands_in: u16::from_le_bytes([payload[6], payload[7]]),
357            oob_duration_powered: u16::from_le_bytes([payload[8], payload[9]]),
358        })
359    }
360
361    /// Applies a backlight effect temporarily (stored in RAM, not persisted).
362    pub async fn set_backlight_effect(&self, effect: BacklightEffect) -> Result<(), Hidpp20Error> {
363        self.endpoint.call(3, [effect.into(), 0, 0]).await?;
364        Ok(())
365    }
366}
367
368impl BacklightInfoUpdate {
369    fn from_payload(payload: &[u8; 16]) -> Result<Self, Hidpp20Error> {
370        Ok(Self {
371            nb_levels: payload[0],
372            current_level: payload[1],
373            status: BacklightStatus::try_from(payload[2])
374                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
375            effect: BacklightEffect::try_from(payload[3])
376                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
377        })
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::{
384        BacklightEffect, BacklightInfoUpdate, BacklightMode, BacklightOptions, BacklightStatus,
385    };
386
387    #[test]
388    fn decodes_options_and_mode_split() {
389        // WOW enabled + permanent-manual mode (0b11 << 3) + auto-mode supported.
390        let raw = BacklightOptions::WOW.bits()
391            | (u16::from(u8::from(BacklightMode::PermanentManual)) << 3)
392            | BacklightOptions::AUTO_MODE_SUPPORTED.bits();
393        let mode = BacklightMode::try_from(((raw & (0b11 << 3)) >> 3) as u8).unwrap();
394        let options = BacklightOptions::from_bits_retain(raw & !(0b11 << 3));
395
396        assert_eq!(mode, BacklightMode::PermanentManual);
397        assert!(options.contains(BacklightOptions::WOW));
398        assert!(options.contains(BacklightOptions::AUTO_MODE_SUPPORTED));
399        // The mode bits must not leak into the options flags.
400        assert!(!options.contains(BacklightOptions::PWR_SAVE));
401        assert!(!options.contains(BacklightOptions::CROWN));
402    }
403
404    #[test]
405    fn decodes_backlight_info_event() {
406        let mut payload = [0; 16];
407        payload[0] = 8;
408        payload[1] = 5;
409        payload[2] = 5;
410        payload[3] = 2;
411
412        let update = BacklightInfoUpdate::from_payload(&payload).unwrap();
413        assert_eq!(update.nb_levels, 8);
414        assert_eq!(update.current_level, 5);
415        assert_eq!(update.status, BacklightStatus::PermanentManual);
416        assert_eq!(update.effect, BacklightEffect::Breathing);
417    }
418
419    #[test]
420    fn maps_do_not_change_effect_sentinel() {
421        assert_eq!(None::<BacklightEffect>.map_or(0xff, u8::from), 0xff);
422        assert_eq!(Some(BacklightEffect::Waves).map_or(0xff, u8::from), 6);
423    }
424}