Skip to main content

openlogi_device/write/
backlight.rs

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