diff --git a/crates/openlogi-cli/src/cmd/backlight.rs b/crates/openlogi-cli/src/cmd/backlight.rs new file mode 100644 index 00000000..b3084a51 --- /dev/null +++ b/crates/openlogi-cli/src/cmd/backlight.rs @@ -0,0 +1,126 @@ +//! `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); + + // 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(|| { + 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..c873b7df --- /dev/null +++ b/crates/openlogi-hid/src/write/backlight.rs @@ -0,0 +1,201 @@ +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. +/// +/// The effect, brightness level, and fade-out durations are read first and +/// written back unchanged, so they return with a later `enabled = true`. +/// +/// The mode survives too, except from [`BacklightMode::TemporaryManual`] — the +/// state the keyboard enters on its own when the user presses its backlight +/// keys. `setBacklightConfig` cannot write that mode, so it lands in +/// [`BacklightMode::Automatic`] and the level goes back under ambient-light +/// control. Promoting it to [`BacklightMode::PermanentManual`] would hold the +/// level but make a deliberately temporary adjustment permanent, so the +/// firmware's own fallback wins instead. Read the mode first and tell the user +/// when this applies. +/// +/// `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.