Skip to main content

openlogi_core/hid/
backlight.rs

1//! HID++ keyboard-backlight mode, status, read-back state, and changes.
2//!
3//! These types describe the semantic state of HID++ `Backlight` feature
4//! `0x1982`. The protocol conversion and transport operations that produce
5//! them remain in `openlogi-device`.
6
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11/// How the firmware decides the backlight brightness level.
12///
13/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so
14/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump
15/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum BacklightMode {
18    /// No mode selected.
19    None,
20    /// Level follows the ambient-light sensor.
21    Automatic,
22    /// Level adjusted with the keyboard's own backlight keys. The firmware
23    /// enters this mode on its own; software cannot write it.
24    TemporaryManual,
25    /// Level set by software and held until changed.
26    PermanentManual,
27}
28
29/// Why the backlight is in its current state, as reported by the device.
30///
31/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so
32/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump
33/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum BacklightStatus {
36    /// Turned off by software — the LEDs stay dark regardless of ambient
37    /// light or hand proximity.
38    DisabledBySoftware,
39    /// Turned off because the battery is critically low.
40    DisabledByCriticalBattery,
41    /// Following the ambient-light sensor.
42    AlsAutomatic,
43    /// Following the ambient-light sensor, which reads bright enough that the
44    /// LEDs are off.
45    AlsSaturated,
46    /// Holding a level the user picked with the backlight keys.
47    TemporaryManual,
48    /// Holding a level written by software.
49    PermanentManual,
50}
51
52/// Snapshot of a keyboard's backlight, merged from the `0x1982`
53/// `getBacklightConfig` and `getBacklightInfo` responses.
54///
55/// Crosses the agent↔GUI IPC, so field order is wire format — changes require
56/// a `PROTOCOL_VERSION` bump (guarded by
57/// `openlogi-ipc/tests/wire_format.rs`).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59pub struct BacklightState {
60    /// Whether the backlight system is enabled at all. When `false` the
61    /// firmware keeps the LEDs dark no matter what the sensors report, and
62    /// [`Self::status`] reads [`BacklightStatus::DisabledBySoftware`].
63    pub enabled: bool,
64    /// How the level is chosen while the backlight is enabled.
65    pub mode: BacklightMode,
66    /// Why the backlight is in its current state.
67    pub status: BacklightStatus,
68    /// Current brightness level, `0` (off) up to [`Self::nb_levels`] minus one.
69    pub current_level: u8,
70    /// Number of user-selectable brightness levels the device reports.
71    pub nb_levels: u8,
72    /// Whether battery saving is on: the firmware turns the backlight off
73    /// when the battery runs low.
74    ///
75    /// `serde(default)` only keeps recorded JSON fixtures that predate the
76    /// field loading; the IPC encoding always carries it.
77    #[serde(default)]
78    pub power_save: bool,
79    /// Which [`BacklightChange`]s the keyboard accepts.
80    #[serde(default)]
81    pub controls: BacklightControls,
82}
83
84/// What software may change on a keyboard backlight beyond turning it on or
85/// off, from the `0x1982` capability bits and feature version.
86///
87/// Crosses the agent↔GUI IPC inside [`BacklightState`], so field order is
88/// wire format — changes require a `PROTOCOL_VERSION` bump (guarded by
89/// `openlogi-ipc/tests/wire_format.rs`).
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
91pub struct BacklightControls {
92    /// Software can switch between the ambient-light sensor choosing the
93    /// level and holding one (permanent-manual mode): the keyboard supports
94    /// both.
95    pub automatic: bool,
96    /// Software can pick the level: permanent-manual mode, `0x1982` version 3
97    /// or later, and more than one level.
98    pub level: bool,
99    /// The firmware can turn the backlight off on a low battery.
100    pub power_save: bool,
101}
102
103impl BacklightControls {
104    /// Whether `change` is one this keyboard accepts, ignoring the level
105    /// range: [`BacklightChange::Level`] also needs a level below
106    /// [`BacklightState::nb_levels`].
107    #[must_use]
108    pub fn allow(self, change: BacklightChange) -> bool {
109        match change {
110            BacklightChange::Enabled(_) => true,
111            BacklightChange::Automatic(_) => self.automatic,
112            BacklightChange::Level(_) => self.level,
113            BacklightChange::PowerSave(_) => self.power_save,
114        }
115    }
116}
117
118/// One change to a keyboard backlight, written to the keyboard's own memory.
119///
120/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so
121/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump
122/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub enum BacklightChange {
125    /// Turn the backlight system on or off.
126    Enabled(bool),
127    /// Let the ambient-light sensor choose the level (`true`), or hold the
128    /// level the keyboard shows now (`false`), so nothing visibly changes.
129    Automatic(bool),
130    /// Hold this level, `0` (off) up to [`BacklightState::nb_levels`] minus
131    /// one. Leaves automatic mode.
132    Level(u8),
133    /// Turn battery saving on or off.
134    PowerSave(bool),
135}
136
137impl fmt::Display for BacklightChange {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        let on_off = |on: bool| if on { "on" } else { "off" };
140        match *self {
141            Self::Enabled(on) => write!(f, "backlight {}", on_off(on)),
142            Self::Automatic(on) => write!(f, "automatic brightness {}", on_off(on)),
143            Self::Level(level) => write!(f, "backlight level {level}"),
144            Self::PowerSave(on) => write!(f, "battery saving {}", on_off(on)),
145        }
146    }
147}
148
149impl BacklightState {
150    /// Whether the keyboard accepts `change` right now: the control exists
151    /// and, for a level, the level is in range.
152    #[must_use]
153    pub fn accepts(self, change: BacklightChange) -> bool {
154        self.controls.allow(change)
155            && match change {
156                BacklightChange::Level(level) => level < self.nb_levels,
157                _ => true,
158            }
159    }
160
161    /// Whether the ambient-light sensor chooses the level — the firmware's
162    /// automatic mode, or the temporary level the backlight keys set on top
163    /// of it.
164    #[must_use]
165    pub fn is_automatic(self) -> bool {
166        matches!(
167            self.mode,
168            BacklightMode::Automatic | BacklightMode::TemporaryManual
169        )
170    }
171
172    /// Whether the LEDs are dark right now, for whatever reason — software
173    /// disable, critical battery, a saturated ambient-light sensor, or a zero
174    /// manual level.
175    #[must_use]
176    pub fn is_dark(self) -> bool {
177        !self.enabled
178            || self.current_level == 0
179            || matches!(
180                self.status,
181                BacklightStatus::DisabledBySoftware
182                    | BacklightStatus::DisabledByCriticalBattery
183                    | BacklightStatus::AlsSaturated
184            )
185    }
186
187    /// The backlight this one becomes once the keyboard keeps `change`, as
188    /// far as the change alone decides — what software can show before the
189    /// keyboard reads back, and what a keyboard double answers.
190    ///
191    /// The firmware owns the rest and it is kept: a critical-battery shutoff
192    /// stays in force, and a status the mode did not change — a saturated
193    /// sensor, say — stands. Turning the backlight off always reads
194    /// [`BacklightStatus::DisabledBySoftware`], the status the keyboard
195    /// reports for it. `change` is not checked against [`Self::accepts`].
196    #[must_use]
197    pub fn changed(self, change: BacklightChange) -> Self {
198        let mut next = self;
199        match change {
200            BacklightChange::Enabled(on) => next.enabled = on,
201            BacklightChange::Automatic(true) => next.mode = BacklightMode::Automatic,
202            // The level shown now is the one held, as the device write does.
203            BacklightChange::Automatic(false) => next.mode = BacklightMode::PermanentManual,
204            BacklightChange::Level(level) => {
205                next.mode = BacklightMode::PermanentManual;
206                next.current_level = level;
207            }
208            BacklightChange::PowerSave(on) => next.power_save = on,
209        }
210        next.status = if !next.enabled {
211            BacklightStatus::DisabledBySoftware
212        } else if self.status == BacklightStatus::DisabledByCriticalBattery
213            || (self.enabled && next.mode == self.mode)
214        {
215            self.status
216        } else {
217            match next.mode {
218                BacklightMode::Automatic => BacklightStatus::AlsAutomatic,
219                BacklightMode::TemporaryManual => BacklightStatus::TemporaryManual,
220                BacklightMode::PermanentManual => BacklightStatus::PermanentManual,
221                BacklightMode::None => self.status,
222            }
223        };
224        next
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn lit() -> BacklightState {
233        BacklightState {
234            enabled: true,
235            mode: BacklightMode::Automatic,
236            status: BacklightStatus::AlsAutomatic,
237            current_level: 4,
238            nb_levels: 8,
239            power_save: true,
240            controls: BacklightControls {
241                automatic: true,
242                level: true,
243                power_save: true,
244            },
245        }
246    }
247
248    #[test]
249    fn every_change_needs_its_control() {
250        let all = lit();
251        let none = BacklightState {
252            controls: BacklightControls::default(),
253            ..lit()
254        };
255        for change in [
256            BacklightChange::Automatic(false),
257            BacklightChange::Level(3),
258            BacklightChange::PowerSave(false),
259        ] {
260            assert!(all.accepts(change), "{change}");
261            assert!(!none.accepts(change), "{change}");
262        }
263        // On/off is part of every 0x1982 keyboard.
264        assert!(none.accepts(BacklightChange::Enabled(false)));
265    }
266
267    #[test]
268    fn a_level_must_be_below_the_level_count() {
269        assert!(lit().accepts(BacklightChange::Level(0)));
270        assert!(lit().accepts(BacklightChange::Level(7)));
271        assert!(!lit().accepts(BacklightChange::Level(8)));
272    }
273
274    #[test]
275    fn the_backlight_keys_leave_the_sensor_in_charge() {
276        let keys = BacklightState {
277            mode: BacklightMode::TemporaryManual,
278            ..lit()
279        };
280        let held = BacklightState {
281            mode: BacklightMode::PermanentManual,
282            ..lit()
283        };
284        assert!(lit().is_automatic());
285        assert!(keys.is_automatic());
286        assert!(!held.is_automatic());
287    }
288
289    #[test]
290    fn changes_name_themselves_for_error_messages() {
291        assert_eq!(BacklightChange::Enabled(true).to_string(), "backlight on");
292        assert_eq!(
293            BacklightChange::Automatic(false).to_string(),
294            "automatic brightness off"
295        );
296        assert_eq!(BacklightChange::Level(3).to_string(), "backlight level 3");
297        assert_eq!(
298            BacklightChange::PowerSave(true).to_string(),
299            "battery saving on"
300        );
301    }
302
303    #[test]
304    fn a_lit_backlight_is_not_dark() {
305        assert!(!lit().is_dark());
306    }
307
308    #[test]
309    fn software_disable_reads_as_dark() {
310        let state = BacklightState {
311            enabled: false,
312            status: BacklightStatus::DisabledBySoftware,
313            ..lit()
314        };
315        assert!(state.is_dark());
316    }
317
318    #[test]
319    fn a_zero_level_reads_as_dark_even_while_enabled() {
320        let state = BacklightState {
321            current_level: 0,
322            ..lit()
323        };
324        assert!(state.is_dark());
325    }
326
327    #[test]
328    fn a_saturated_ambient_sensor_reads_as_dark() {
329        let state = BacklightState {
330            status: BacklightStatus::AlsSaturated,
331            ..lit()
332        };
333        assert!(state.is_dark());
334    }
335
336    #[test]
337    fn turning_the_backlight_off_reads_as_a_software_disable() {
338        let off = lit().changed(BacklightChange::Enabled(false));
339        assert!(!off.enabled);
340        assert_eq!(off.status, BacklightStatus::DisabledBySoftware);
341        assert_eq!(
342            off.mode,
343            BacklightMode::Automatic,
344            "the mode is kept for later"
345        );
346    }
347
348    #[test]
349    fn turning_it_back_on_reports_the_kept_mode() {
350        let off = lit().changed(BacklightChange::Enabled(false));
351        assert_eq!(off.changed(BacklightChange::Enabled(true)), lit());
352        let manual = BacklightState {
353            mode: BacklightMode::PermanentManual,
354            ..off
355        };
356        assert_eq!(
357            manual.changed(BacklightChange::Enabled(true)).status,
358            BacklightStatus::PermanentManual
359        );
360    }
361
362    #[test]
363    fn leaving_automatic_holds_the_level_shown() {
364        let held = lit().changed(BacklightChange::Automatic(false));
365        assert_eq!(held.mode, BacklightMode::PermanentManual);
366        assert_eq!(held.status, BacklightStatus::PermanentManual);
367        assert_eq!(held.current_level, lit().current_level);
368        let back = held.changed(BacklightChange::Automatic(true));
369        assert_eq!(back.mode, BacklightMode::Automatic);
370        assert_eq!(back.status, BacklightStatus::AlsAutomatic);
371    }
372
373    #[test]
374    fn a_level_leaves_automatic_mode() {
375        let set = lit().changed(BacklightChange::Level(2));
376        assert_eq!(set.current_level, 2);
377        assert_eq!(set.mode, BacklightMode::PermanentManual);
378        assert_eq!(set.status, BacklightStatus::PermanentManual);
379    }
380
381    #[test]
382    fn battery_saving_changes_nothing_else() {
383        let saving_off = lit().changed(BacklightChange::PowerSave(false));
384        assert_eq!(
385            saving_off,
386            BacklightState {
387                power_save: false,
388                ..lit()
389            }
390        );
391    }
392
393    #[test]
394    fn firmware_owned_status_is_kept() {
395        let saturated = BacklightState {
396            status: BacklightStatus::AlsSaturated,
397            ..lit()
398        };
399        assert_eq!(
400            saturated.changed(BacklightChange::PowerSave(false)).status,
401            BacklightStatus::AlsSaturated,
402            "the sensor still reads bright"
403        );
404        let critical = BacklightState {
405            status: BacklightStatus::DisabledByCriticalBattery,
406            ..lit()
407        };
408        assert_eq!(
409            critical.changed(BacklightChange::Level(3)).status,
410            BacklightStatus::DisabledByCriticalBattery,
411            "a level does not override the battery shutoff"
412        );
413        assert_eq!(
414            critical.changed(BacklightChange::Enabled(false)).status,
415            BacklightStatus::DisabledBySoftware
416        );
417    }
418}