From 4515d8bbf9e8a670ea7dc7be2949d7401beea6b1 Mon Sep 17 00:00:00 2001 From: Yevhen Kyriukha Date: Mon, 27 Jul 2026 10:39:24 +0300 Subject: [PATCH 1/2] feat(backlight): support HID++ 0x1982 The MX Keys family's white backlight sits behind 0x1982, which is separate from the RGB features (0x8070/0x8080) that set_keyboard_color drives, so Capabilities::lighting doesn't cover it and diag lighting can't reach it. Adds get_backlight/set_backlight_enabled in openlogi-hid on top of the existing 0x1982 wrapper, plus an `openlogi backlight [status|off|on]` command. The write is a read-modify-write so mode, effect, level and the fade durations survive a toggle. TemporaryManual maps to Automatic since setBacklightConfig can't write it. Kept the command top-level instead of under diag because setBacklightConfig writes to NVM, and diag is documented as not touching persistent state. The new HidppOperation variants are appended so the bincode indices and wire goldens don't shift. Tested on an MX Keys S over a Bolt receiver. --- crates/openlogi-cli/src/cmd/backlight.rs | 119 +++++++++++++ crates/openlogi-cli/src/cmd/mod.rs | 4 + crates/openlogi-cli/src/lib.rs | 36 ++++ crates/openlogi-hid/src/backlight.rs | 146 ++++++++++++++++ crates/openlogi-hid/src/lib.rs | 10 +- crates/openlogi-hid/src/write.rs | 5 +- crates/openlogi-hid/src/write/backlight.rs | 193 +++++++++++++++++++++ crates/openlogi-hid/src/write/error.rs | 4 + 8 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 crates/openlogi-cli/src/cmd/backlight.rs create mode 100644 crates/openlogi-hid/src/backlight.rs create mode 100644 crates/openlogi-hid/src/write/backlight.rs diff --git a/crates/openlogi-cli/src/cmd/backlight.rs b/crates/openlogi-cli/src/cmd/backlight.rs new file mode 100644 index 00000000..b29159b7 --- /dev/null +++ b/crates/openlogi-cli/src/cmd/backlight.rs @@ -0,0 +1,119 @@ +//! `openlogi backlight` — read and set the HID++ `0x1982` keyboard backlight. +//! +//! Unlike `diag`, this is persistent configuration: `setBacklightConfig` writes +//! to the keyboard's non-volatile memory, so `backlight off` survives +//! reconnects, host switches, and power cycles with nothing re-applying it. + +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use openlogi_hid::{BacklightMode, BacklightState, BacklightStatus, DeviceRoute}; + +use crate::cmd::diag::select_device; + +/// HID++ `Backlight` — the white, level-adjustable backlight on the MX Keys +/// line. RGB keyboards use `0x8070` / `0x8080` instead and are driven by +/// `diag lighting`. +const BACKLIGHT_FEATURE: u16 = 0x1982; + +#[derive(Debug, Args)] +pub struct BacklightArgs { + /// Run against the device whose name contains this string + /// (case-insensitive) instead of auto-selecting. Useful when several + /// keyboards are paired. + #[arg(long, value_name = "NAME", global = true)] + pub device: Option, + + #[command(subcommand)] + pub action: Option, +} + +#[derive(Debug, Subcommand)] +pub enum BacklightAction { + /// Show the current backlight state (the default with no subcommand). + Status, + /// Turn the backlight off completely and persistently. The LEDs stay dark + /// regardless of ambient light or hand proximity. + Off, + /// Turn the backlight back on, restoring the mode and brightness level the + /// keyboard still has stored. + On, +} + +pub async fn run(args: BacklightArgs) -> Result<()> { + let (route, name) = select_device(args.device.as_deref(), &[BACKLIGHT_FEATURE]).await?; + println!("device: {name} ({route})"); + + let enable = match args.action.unwrap_or(BacklightAction::Status) { + BacklightAction::Status => { + let state = read_state(&route).await?; + print_state("current", state); + return Ok(()); + } + BacklightAction::Off => false, + BacklightAction::On => true, + }; + + let before = read_state(&route).await?; + print_state("current", before); + + let after = openlogi_hid::set_backlight_enabled(&route, enable) + .await + .with_context(|| { + format!( + "set backlight {}", + if enable { "enabled" } else { "disabled" } + ) + })?; + print_state("read-back", after); + + if after.enabled != enable { + anyhow::bail!( + "backlight write not applied: requested enabled={enable}, device reports enabled={}", + after.enabled + ); + } + + if enable { + println!("✓ backlight enabled (level {}/{})", after.current_level, after.nb_levels); + } else { + println!("✓ backlight off — persisted to the keyboard, survives reconnect and power cycle"); + } + Ok(()) +} + +async fn read_state(route: &DeviceRoute) -> Result { + openlogi_hid::get_backlight(route) + .await + .context("read backlight state") +} + +fn print_state(label: &str, state: BacklightState) { + println!( + " {label}: enabled={} mode={} status={} level={}/{}", + state.enabled, + mode_label(state.mode), + status_label(state.status), + state.current_level, + state.nb_levels, + ); +} + +fn mode_label(mode: BacklightMode) -> &'static str { + match mode { + BacklightMode::None => "none", + BacklightMode::Automatic => "automatic (ambient-light sensor)", + BacklightMode::TemporaryManual => "temporary manual (backlight keys)", + BacklightMode::PermanentManual => "permanent manual (software)", + } +} + +fn status_label(status: BacklightStatus) -> &'static str { + match status { + BacklightStatus::DisabledBySoftware => "off (disabled by software)", + BacklightStatus::DisabledByCriticalBattery => "off (critical battery)", + BacklightStatus::AlsAutomatic => "on (following ambient light)", + BacklightStatus::AlsSaturated => "off (ambient light saturated)", + BacklightStatus::TemporaryManual => "on (level from backlight keys)", + BacklightStatus::PermanentManual => "on (level from software)", + } +} diff --git a/crates/openlogi-cli/src/cmd/mod.rs b/crates/openlogi-cli/src/cmd/mod.rs index 91136c28..6204e012 100644 --- a/crates/openlogi-cli/src/cmd/mod.rs +++ b/crates/openlogi-cli/src/cmd/mod.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::Subcommand; pub mod assets; +pub mod backlight; pub mod diag; pub mod list; @@ -9,6 +10,8 @@ pub mod list; pub enum Command { /// List connected Logitech HID++ devices. List(list::ListArgs), + /// Read or persistently set the keyboard backlight (HID++ 0x1982). + Backlight(backlight::BacklightArgs), /// Manage assets fetched from OpenLogi's asset mirrors. #[command(subcommand)] Assets(assets::AssetsCmd), @@ -21,6 +24,7 @@ impl Command { pub async fn run(self) -> Result<()> { match self { Self::List(args) => list::run(args).await, + Self::Backlight(args) => backlight::run(args).await, // `assets sync` is blocking HTTP — no need for the async runtime. Self::Assets(cmd) => cmd.run(), Self::Diag(cmd) => cmd.run().await, diff --git a/crates/openlogi-cli/src/lib.rs b/crates/openlogi-cli/src/lib.rs index 97af9981..0d3b0e86 100644 --- a/crates/openlogi-cli/src/lib.rs +++ b/crates/openlogi-cli/src/lib.rs @@ -43,6 +43,7 @@ mod tests { use super::*; use cmd::Command; + use cmd::backlight::BacklightAction; use cmd::diag::DiagCmd; use cmd::diag::lighting::Method; use cmd::diag::wheel::ResolutionArg; @@ -63,6 +64,41 @@ mod tests { assert!(cli.cmd.is_none()); } + /// A bare `openlogi backlight` must stay valid — `run` treats a missing + /// action as `status`, so it can never write to the device by accident. + #[test] + fn backlight_defaults_to_status_and_accepts_a_device_filter() { + let cli = Cli::try_parse_from(["openlogi", "backlight", "--device", "MX KEYS S"]) + .expect("bare backlight invocation parses"); + + match cli.cmd.expect("subcommand present") { + Command::Backlight(args) => { + assert_eq!(args.device.as_deref(), Some("MX KEYS S")); + assert!(args.action.is_none()); + } + other => panic!("expected Backlight, got {other:?}"), + } + } + + #[test] + fn backlight_off_is_parsed_as_its_own_action() { + let cli = + Cli::try_parse_from(["openlogi", "backlight", "off"]).expect("backlight off parses"); + + match cli.cmd.expect("subcommand present") { + Command::Backlight(args) => { + assert!(matches!(args.action, Some(BacklightAction::Off))); + } + other => panic!("expected Backlight, got {other:?}"), + } + } + + #[test] + fn backlight_rejects_an_unknown_action() { + let result = Cli::try_parse_from(["openlogi", "backlight", "dim"]); + assert!(result.is_err()); + } + #[test] fn smartshift_leave_flipped_conflicts_with_sensitivity() { let result = Cli::try_parse_from([ diff --git a/crates/openlogi-hid/src/backlight.rs b/crates/openlogi-hid/src/backlight.rs new file mode 100644 index 00000000..dc4e4774 --- /dev/null +++ b/crates/openlogi-hid/src/backlight.rs @@ -0,0 +1,146 @@ +//! HID++ `Backlight` (feature `0x1982`) — keyboard backlight control. +//! +//! The protocol-level `0x1982` wrapper lives in `openlogi-hidpp`; this module +//! keeps OpenLogi's IPC/config-facing mode, status, and snapshot types. +//! +//! This is the backlight family used by the MX Keys line: a white, +//! level-adjustable backlight driven by an ambient-light sensor and a hand +//! proximity sensor. It is distinct from the RGB families (`0x8070` +//! ColorLedEffects, `0x8080` PerKeyLighting) that [`crate::set_keyboard_color`] +//! drives — a device exposes one or the other, never both. +//! +//! `setBacklightConfig` writes to the device's non-volatile memory, so a +//! disabled backlight stays disabled across reconnects, host switches, and +//! power cycles without a daemon re-applying it. + +use serde::{Deserialize, Serialize}; + +/// How the firmware decides the backlight brightness level. +/// +/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so +/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump +/// (guarded by `openlogi-agent-core/tests/wire_format.rs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BacklightMode { + /// No mode selected. + None, + /// Level follows the ambient-light sensor. + Automatic, + /// Level adjusted with the keyboard's own backlight keys. The firmware + /// enters this mode on its own; software cannot write it. + TemporaryManual, + /// Level set by software and held until changed. + PermanentManual, +} + +/// Why the backlight is in its current state, as reported by +/// `getBacklightInfo`. +/// +/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so +/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump +/// (guarded by `openlogi-agent-core/tests/wire_format.rs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BacklightStatus { + /// Turned off by software — the LEDs stay dark regardless of ambient + /// light or hand proximity. This is what [`crate::set_backlight_enabled`] + /// with `false` produces. + DisabledBySoftware, + /// Turned off because the battery is critically low. + DisabledByCriticalBattery, + /// Following the ambient-light sensor. + AlsAutomatic, + /// Following the ambient-light sensor, which reads bright enough that the + /// LEDs are off. + AlsSaturated, + /// Holding a level the user picked with the backlight keys. + TemporaryManual, + /// Holding a level written by software. + PermanentManual, +} + +/// Snapshot of a keyboard's backlight, merged from the `0x1982` +/// `getBacklightConfig` and `getBacklightInfo` responses. +/// +/// Crosses the agent↔GUI IPC, so field order is wire format — changes require +/// a `PROTOCOL_VERSION` bump (guarded by +/// `openlogi-agent-core/tests/wire_format.rs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BacklightState { + /// Whether the backlight system is enabled at all. When `false` the + /// firmware keeps the LEDs dark no matter what the sensors report, and + /// [`Self::status`] reads [`BacklightStatus::DisabledBySoftware`]. + pub enabled: bool, + /// How the level is chosen while the backlight is enabled. + pub mode: BacklightMode, + /// Why the backlight is in its current state. + pub status: BacklightStatus, + /// Current brightness level, `0` (off) up to [`Self::nb_levels`] minus one. + pub current_level: u8, + /// Number of user-selectable brightness levels the device reports. + pub nb_levels: u8, +} + +impl BacklightState { + /// Whether the LEDs are dark right now, for whatever reason — software + /// disable, critical battery, a saturated ambient-light sensor, or a zero + /// manual level. + #[must_use] + pub fn is_dark(self) -> bool { + !self.enabled + || self.current_level == 0 + || matches!( + self.status, + BacklightStatus::DisabledBySoftware + | BacklightStatus::DisabledByCriticalBattery + | BacklightStatus::AlsSaturated + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lit() -> BacklightState { + BacklightState { + enabled: true, + mode: BacklightMode::Automatic, + status: BacklightStatus::AlsAutomatic, + current_level: 4, + nb_levels: 8, + } + } + + #[test] + fn a_lit_backlight_is_not_dark() { + assert!(!lit().is_dark()); + } + + #[test] + fn software_disable_reads_as_dark() { + let state = BacklightState { + enabled: false, + status: BacklightStatus::DisabledBySoftware, + ..lit() + }; + assert!(state.is_dark()); + } + + #[test] + fn a_zero_level_reads_as_dark_even_while_enabled() { + let state = BacklightState { + current_level: 0, + ..lit() + }; + assert!(state.is_dark()); + } + + #[test] + fn a_saturated_ambient_sensor_reads_as_dark() { + let state = BacklightState { + status: BacklightStatus::AlsSaturated, + ..lit() + }; + assert!(state.is_dark()); + } +} diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index b6cf1547..7797ce8b 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -19,6 +19,7 @@ mod transport; #[cfg(target_os = "windows")] mod windows_hid; +pub mod backlight; pub mod gesture; mod hires_wheel; pub mod hotplug; @@ -29,6 +30,7 @@ pub mod smartshift; pub mod thumbwheel; pub mod write; +pub use backlight::{BacklightMode, BacklightState, BacklightStatus}; pub use gesture::{CaptureChannel, CapturedInput, GestureError, run_capture_session}; pub use hires_wheel::{ ScrollReportingTarget, ScrollResolution, ScrollWheelMode, get_scroll_wheel_mode, @@ -45,8 +47,8 @@ pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS}; pub use smartshift::{AUTO_DISENGAGE_PERMANENT, SmartShiftMode, SmartShiftStatus}; pub use write::{ DpiCapabilities, DpiInfo, FeatureEntry, HidppFeatureErrorKind, HidppOperation, LightingMethod, - ReprogControlEntry, SharedChannel, WriteError, dump_features, dump_reprog_controls, get_dpi, - get_dpi_info, get_smartshift_status, set_dpi, set_dpi_on, set_keyboard_color, - set_keyboard_color_with, set_smartshift, set_smartshift_on, set_smartshift_sensitivity, - toggle_smartshift, toggle_smartshift_on, + ReprogControlEntry, SharedChannel, WriteError, dump_features, dump_reprog_controls, + get_backlight, get_dpi, get_dpi_info, get_smartshift_status, set_backlight_enabled, set_dpi, + set_dpi_on, set_keyboard_color, set_keyboard_color_with, set_smartshift, set_smartshift_on, + set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on, }; diff --git a/crates/openlogi-hid/src/write.rs b/crates/openlogi-hid/src/write.rs index e01c79e2..b45d9185 100644 --- a/crates/openlogi-hid/src/write.rs +++ b/crates/openlogi-hid/src/write.rs @@ -1,4 +1,5 @@ -//! HID++ writes back to the device — DPI, SmartShift, lighting, and diagnostics. +//! HID++ writes back to the device — DPI, SmartShift, lighting, backlight, and +//! diagnostics. //! //! Each entry point takes a [`DeviceRoute`] and resolves it to an open channel //! through `open_route_channel`, so the same call works whether the device is @@ -13,6 +14,7 @@ use hidpp::{channel::HidppChannel, device::Device, feature::CreatableFeature}; use crate::route::{DeviceRoute, open_route_channel}; +mod backlight; mod diagnostics; mod dpi; mod error; @@ -20,6 +22,7 @@ mod lighting; mod shared; mod smartshift; +pub use backlight::{get_backlight, set_backlight_enabled}; pub use diagnostics::{FeatureEntry, ReprogControlEntry, dump_features, dump_reprog_controls}; pub use dpi::{DpiCapabilities, DpiInfo, get_dpi, get_dpi_info, set_dpi}; pub use error::{HidppFeatureErrorKind, HidppOperation, WriteError}; diff --git a/crates/openlogi-hid/src/write/backlight.rs b/crates/openlogi-hid/src/write/backlight.rs new file mode 100644 index 00000000..fd8bcd63 --- /dev/null +++ b/crates/openlogi-hid/src/write/backlight.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use hidpp::{ + device::Device, + feature::{ + CreatableFeature, + backlight::{ + BacklightFeature, BacklightMode as FirmwareMode, BacklightStatus as FirmwareStatus, + SetBacklightConfig, + }, + }, +}; +use tracing::debug; + +use crate::backlight::{BacklightMode, BacklightState, BacklightStatus}; +use crate::route::DeviceRoute; + +use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route}; + +/// Map the fork's `0x1982` mode onto OpenLogi's [`BacklightMode`]. The source +/// enum is `#[non_exhaustive]`; an unmodelled future variant maps to +/// [`BacklightMode::None`], which callers treat as "firmware picks". +fn mode_from_firmware(mode: FirmwareMode) -> BacklightMode { + match mode { + FirmwareMode::Automatic => BacklightMode::Automatic, + FirmwareMode::TemporaryManual => BacklightMode::TemporaryManual, + FirmwareMode::PermanentManual => BacklightMode::PermanentManual, + _ => BacklightMode::None, + } +} + +/// The inverse of [`mode_from_firmware`], used when writing a config back. +/// +/// [`BacklightMode::TemporaryManual`] is the mode the *keyboard* enters when +/// the user presses its backlight keys; `setBacklightConfig` cannot write it, +/// so it is sent as [`FirmwareMode::Automatic`] — the firmware's own fallback +/// once software takes over the level. +fn mode_to_firmware(mode: BacklightMode) -> FirmwareMode { + match mode { + BacklightMode::None => FirmwareMode::None, + BacklightMode::Automatic | BacklightMode::TemporaryManual => FirmwareMode::Automatic, + BacklightMode::PermanentManual => FirmwareMode::PermanentManual, + } +} + +/// Map the fork's `0x1982` status onto OpenLogi's [`BacklightStatus`]. The +/// source enum is `#[non_exhaustive]`; an unmodelled future variant maps to +/// [`BacklightStatus::AlsAutomatic`], the firmware's out-of-box behaviour. +fn status_from_firmware(status: FirmwareStatus) -> BacklightStatus { + match status { + FirmwareStatus::DisabledBySoftware => BacklightStatus::DisabledBySoftware, + FirmwareStatus::DisabledByCriticalBattery => BacklightStatus::DisabledByCriticalBattery, + FirmwareStatus::AlsSaturated => BacklightStatus::AlsSaturated, + FirmwareStatus::TemporaryManual => BacklightStatus::TemporaryManual, + FirmwareStatus::PermanentManual => BacklightStatus::PermanentManual, + _ => BacklightStatus::AlsAutomatic, + } +} + +/// Read `getBacklightConfig` + `getBacklightInfo` and merge them into a +/// [`BacklightState`]. +async fn read_state(feature: &BacklightFeature) -> Result { + let config = feature.get_backlight_config().await.map_err(|e| { + classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID) + })?; + let info = feature.get_backlight_info().await.map_err(|e| { + classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID) + })?; + Ok(BacklightState { + enabled: config.enabled, + mode: mode_from_firmware(config.mode), + status: status_from_firmware(info.status), + current_level: info.current_level, + nb_levels: info.nb_levels, + }) +} + +/// Read the current backlight state of the keyboard on `route`. +/// +/// `FeatureUnsupported` when the device does not expose HID++ `0x1982` — RGB +/// keyboards (`0x8070` / `0x8080`) and every mouse fall in that group. +pub async fn get_backlight(route: &DeviceRoute) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(Arc::clone(&channel), index) + .await + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; + read_state(&feature).await + }) + .await +} + +/// Enable or disable the backlight on `route`, and return the read-back state. +/// +/// Disabling sets the firmware's own master switch: the LEDs stay dark +/// regardless of the ambient-light and proximity sensors, and the device +/// reports [`BacklightStatus::DisabledBySoftware`]. The write goes to +/// non-volatile memory, so it survives reconnects, host switches, and power +/// cycles — nothing needs to re-apply it. +/// +/// Every other field is read first and written back unchanged, so the mode, +/// effect, brightness level, and fade-out durations the user configured are +/// preserved and come back with a later `enabled = true`. +/// +/// `FeatureUnsupported` when the device does not expose HID++ `0x1982`. +pub async fn set_backlight_enabled( + route: &DeviceRoute, + enabled: bool, +) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(Arc::clone(&channel), index) + .await + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; + + let current = feature.get_backlight_config().await.map_err(|e| { + classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID) + })?; + + feature + .set_backlight_config(SetBacklightConfig { + enabled, + options: current.options, + mode: mode_to_firmware(mode_from_firmware(current.mode)), + // `None` sends the 0xff "do not change" sentinel, keeping + // whichever effect the device already runs. + effect: None, + current_level: current.current_level, + duration_hands_out: current.duration_hands_out, + duration_hands_in: current.duration_hands_in, + duration_powered: current.duration_powered, + }) + .await + .map_err(|e| { + classify_hidpp_error(e, HidppOperation::WriteBacklight, BacklightFeature::ID) + })?; + + debug!(index, enabled, "wrote backlight enable"); + read_state(&feature).await + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firmware_modes_round_trip_through_openlogi_modes() { + assert_eq!( + mode_from_firmware(FirmwareMode::Automatic), + BacklightMode::Automatic + ); + assert_eq!( + mode_from_firmware(FirmwareMode::PermanentManual), + BacklightMode::PermanentManual + ); + assert_eq!(mode_from_firmware(FirmwareMode::None), BacklightMode::None); + } + + #[test] + fn temporary_manual_is_downgraded_because_software_cannot_write_it() { + assert_eq!( + mode_from_firmware(FirmwareMode::TemporaryManual), + BacklightMode::TemporaryManual + ); + assert_eq!( + mode_to_firmware(BacklightMode::TemporaryManual), + FirmwareMode::Automatic + ); + } + + #[test] + fn writable_modes_survive_the_read_write_round_trip() { + for mode in [FirmwareMode::None, FirmwareMode::PermanentManual] { + assert_eq!(mode_to_firmware(mode_from_firmware(mode)), mode); + } + } + + #[test] + fn software_disable_status_is_mapped() { + assert_eq!( + status_from_firmware(FirmwareStatus::DisabledBySoftware), + BacklightStatus::DisabledBySoftware + ); + assert_eq!( + status_from_firmware(FirmwareStatus::AlsSaturated), + BacklightStatus::AlsSaturated + ); + } +} diff --git a/crates/openlogi-hid/src/write/error.rs b/crates/openlogi-hid/src/write/error.rs index 5d4fee7b..f11ac1ae 100644 --- a/crates/openlogi-hid/src/write/error.rs +++ b/crates/openlogi-hid/src/write/error.rs @@ -100,6 +100,10 @@ pub enum HidppOperation { ReadWheelMode, /// Write and verify the native HiResWheel mode. WriteWheelMode, + /// Read the keyboard backlight config or info. + ReadBacklight, + /// Write the keyboard backlight config. + WriteBacklight, } /// HID++ feature error kind in a serializable wire-safe form. From b2c1f9c1346453ad2ed0450ccc6c919572bd86a1 Mon Sep 17 00:00:00 2001 From: Yevhen Kyriukha Date: Mon, 27 Jul 2026 10:52:36 +0300 Subject: [PATCH 2/2] fix(backlight): document and surface the temporary-manual mode change setBacklightConfig cannot write TemporaryManual, so a toggle from that mode necessarily lands somewhere else and the doc comment's claim that every other field survives was wrong. Say what actually happens instead of widening the claim. Automatic stays the target rather than PermanentManual: holding the level would turn an adjustment the user made from the backlight keys, which the firmware itself calls temporary, into a persistent NVM setting. The CLI now prints a note when the pre-write mode is TemporaryManual so the mode change does not read as a side effect of the enable bit. --- crates/openlogi-cli/src/cmd/backlight.rs | 7 +++++++ crates/openlogi-hid/src/write/backlight.rs | 14 +++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-cli/src/cmd/backlight.rs b/crates/openlogi-cli/src/cmd/backlight.rs index b29159b7..b3084a51 100644 --- a/crates/openlogi-cli/src/cmd/backlight.rs +++ b/crates/openlogi-cli/src/cmd/backlight.rs @@ -56,6 +56,13 @@ pub async fn run(args: BacklightArgs) -> Result<()> { let before = read_state(&route).await?; print_state("current", before); + // The keyboard sets this mode itself from its backlight keys and + // setBacklightConfig cannot write it back, so say so rather than let the + // mode change look like a side effect of the enable bit. + if before.mode == BacklightMode::TemporaryManual { + println!(" note: the level came from the keyboard's backlight keys, a mode software cannot write back — it returns to automatic (ambient-light sensor)"); + } + let after = openlogi_hid::set_backlight_enabled(&route, enable) .await .with_context(|| { diff --git a/crates/openlogi-hid/src/write/backlight.rs b/crates/openlogi-hid/src/write/backlight.rs index fd8bcd63..c873b7df 100644 --- a/crates/openlogi-hid/src/write/backlight.rs +++ b/crates/openlogi-hid/src/write/backlight.rs @@ -99,9 +99,17 @@ pub async fn get_backlight(route: &DeviceRoute) -> Result