Skip to main content

openlogi_device/write/
backlight.rs

1use std::sync::Arc;
2
3use hidpp::{
4    device::Device,
5    feature::{
6        CreatableFeature,
7        backlight::{
8            BacklightFeature, BacklightMode as FirmwareMode, BacklightStatus as FirmwareStatus,
9            SetBacklightConfig,
10        },
11    },
12};
13use tracing::debug;
14
15use crate::backend::HidBackend;
16use crate::backlight::{BacklightMode, BacklightState, BacklightStatus};
17use crate::channel::route::DeviceRoute;
18
19use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
20
21/// Map the fork's `0x1982` mode onto OpenLogi's [`BacklightMode`]. The source
22/// enum is `#[non_exhaustive]`; an unmodelled future variant maps to
23/// [`BacklightMode::None`], which callers treat as "firmware picks".
24fn mode_from_firmware(mode: FirmwareMode) -> BacklightMode {
25    match mode {
26        FirmwareMode::Automatic => BacklightMode::Automatic,
27        FirmwareMode::TemporaryManual => BacklightMode::TemporaryManual,
28        FirmwareMode::PermanentManual => BacklightMode::PermanentManual,
29        _ => BacklightMode::None,
30    }
31}
32
33/// The inverse of [`mode_from_firmware`], used when writing a config back.
34///
35/// [`BacklightMode::TemporaryManual`] is the mode the *keyboard* enters when
36/// the user presses its backlight keys; `setBacklightConfig` cannot write it,
37/// so it is sent as [`FirmwareMode::Automatic`] — the firmware's own fallback
38/// once software takes over the level.
39fn mode_to_firmware(mode: BacklightMode) -> FirmwareMode {
40    match mode {
41        BacklightMode::None => FirmwareMode::None,
42        BacklightMode::Automatic | BacklightMode::TemporaryManual => FirmwareMode::Automatic,
43        BacklightMode::PermanentManual => FirmwareMode::PermanentManual,
44    }
45}
46
47/// Map the fork's `0x1982` status onto OpenLogi's [`BacklightStatus`]. The
48/// source enum is `#[non_exhaustive]`; an unmodelled future variant maps to
49/// [`BacklightStatus::AlsAutomatic`], the firmware's out-of-box behaviour.
50fn status_from_firmware(status: FirmwareStatus) -> BacklightStatus {
51    match status {
52        FirmwareStatus::DisabledBySoftware => BacklightStatus::DisabledBySoftware,
53        FirmwareStatus::DisabledByCriticalBattery => BacklightStatus::DisabledByCriticalBattery,
54        FirmwareStatus::AlsSaturated => BacklightStatus::AlsSaturated,
55        FirmwareStatus::TemporaryManual => BacklightStatus::TemporaryManual,
56        FirmwareStatus::PermanentManual => BacklightStatus::PermanentManual,
57        _ => BacklightStatus::AlsAutomatic,
58    }
59}
60
61/// Read `getBacklightConfig` + `getBacklightInfo` and merge them into a
62/// [`BacklightState`].
63async fn read_state(feature: &BacklightFeature) -> Result<BacklightState, WriteError> {
64    let config = feature.get_backlight_config().await.map_err(|e| {
65        classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID)
66    })?;
67    let info = feature.get_backlight_info().await.map_err(|e| {
68        classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID)
69    })?;
70    Ok(BacklightState {
71        enabled: config.enabled,
72        mode: mode_from_firmware(config.mode),
73        status: status_from_firmware(info.status),
74        current_level: info.current_level,
75        nb_levels: info.nb_levels,
76    })
77}
78
79/// Read the current backlight state of the keyboard on `route`.
80///
81/// `FeatureUnsupported` when the device does not expose HID++ `0x1982` — RGB
82/// keyboards (`0x8070` / `0x8080`) and every mouse fall in that group.
83pub async fn get_backlight(
84    backend: &dyn HidBackend,
85    route: &DeviceRoute,
86) -> Result<BacklightState, WriteError> {
87    let index = route.device_index();
88    with_route(backend, route, move |channel| async move {
89        let mut device = Device::new(Arc::clone(&channel), index)
90            .await
91            .map_err(|_| WriteError::DeviceUnreachable { index })?;
92        let feature = open_feature::<BacklightFeature>(&mut device).await?;
93        read_state(&feature).await
94    })
95    .await
96}
97
98/// Enable or disable the backlight on `route`, and return the read-back state.
99///
100/// Disabling sets the firmware's own master switch: the LEDs stay dark
101/// regardless of the ambient-light and proximity sensors, and the device
102/// reports [`BacklightStatus::DisabledBySoftware`]. The write goes to
103/// non-volatile memory, so it survives reconnects, host switches, and power
104/// cycles — nothing needs to re-apply it.
105///
106/// The effect, brightness level, and fade-out durations are read first and
107/// written back unchanged, so they return with a later `enabled = true`.
108///
109/// The mode survives too, except from [`BacklightMode::TemporaryManual`] — the
110/// state the keyboard enters on its own when the user presses its backlight
111/// keys. `setBacklightConfig` cannot write that mode, so it lands in
112/// [`BacklightMode::Automatic`] and the level goes back under ambient-light
113/// control. Promoting it to [`BacklightMode::PermanentManual`] would hold the
114/// level but make a deliberately temporary adjustment permanent, so the
115/// firmware's own fallback wins instead. Read the mode first and tell the user
116/// when this applies.
117///
118/// `FeatureUnsupported` when the device does not expose HID++ `0x1982`.
119pub async fn set_backlight_enabled(
120    backend: &dyn HidBackend,
121    route: &DeviceRoute,
122    enabled: bool,
123) -> Result<BacklightState, WriteError> {
124    let index = route.device_index();
125    with_route(backend, route, move |channel| async move {
126        let mut device = Device::new(Arc::clone(&channel), index)
127            .await
128            .map_err(|_| WriteError::DeviceUnreachable { index })?;
129        let feature = open_feature::<BacklightFeature>(&mut device).await?;
130
131        let current = feature.get_backlight_config().await.map_err(|e| {
132            classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID)
133        })?;
134
135        feature
136            .set_backlight_config(SetBacklightConfig {
137                enabled,
138                options: current.options,
139                mode: mode_to_firmware(mode_from_firmware(current.mode)),
140                // `None` sends the 0xff "do not change" sentinel, keeping
141                // whichever effect the device already runs.
142                effect: None,
143                current_level: current.current_level,
144                duration_hands_out: current.duration_hands_out,
145                duration_hands_in: current.duration_hands_in,
146                duration_powered: current.duration_powered,
147            })
148            .await
149            .map_err(|e| {
150                classify_hidpp_error(e, HidppOperation::WriteBacklight, BacklightFeature::ID)
151            })?;
152
153        debug!(index, enabled, "wrote backlight enable");
154        read_state(&feature).await
155    })
156    .await
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn firmware_modes_round_trip_through_openlogi_modes() {
165        assert_eq!(
166            mode_from_firmware(FirmwareMode::Automatic),
167            BacklightMode::Automatic
168        );
169        assert_eq!(
170            mode_from_firmware(FirmwareMode::PermanentManual),
171            BacklightMode::PermanentManual
172        );
173        assert_eq!(mode_from_firmware(FirmwareMode::None), BacklightMode::None);
174    }
175
176    #[test]
177    fn temporary_manual_is_downgraded_because_software_cannot_write_it() {
178        assert_eq!(
179            mode_from_firmware(FirmwareMode::TemporaryManual),
180            BacklightMode::TemporaryManual
181        );
182        assert_eq!(
183            mode_to_firmware(BacklightMode::TemporaryManual),
184            FirmwareMode::Automatic
185        );
186    }
187
188    #[test]
189    fn writable_modes_survive_the_read_write_round_trip() {
190        for mode in [FirmwareMode::None, FirmwareMode::PermanentManual] {
191            assert_eq!(mode_to_firmware(mode_from_firmware(mode)), mode);
192        }
193    }
194
195    #[test]
196    fn software_disable_status_is_mapped() {
197        assert_eq!(
198            status_from_firmware(FirmwareStatus::DisabledBySoftware),
199            BacklightStatus::DisabledBySoftware
200        );
201        assert_eq!(
202            status_from_firmware(FirmwareStatus::AlsSaturated),
203            BacklightStatus::AlsSaturated
204        );
205    }
206}