Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions crates/openlogi-cli/src/cmd/backlight.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

#[command(subcommand)]
pub action: Option<BacklightAction>,
}

#[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<BacklightState> {
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)",
}
}
4 changes: 4 additions & 0 deletions crates/openlogi-cli/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ use anyhow::Result;
use clap::Subcommand;

pub mod assets;
pub mod backlight;
pub mod diag;
pub mod list;

#[derive(Debug, Subcommand)]
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),
Expand All @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions crates/openlogi-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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([
Expand Down
146 changes: 146 additions & 0 deletions crates/openlogi-hid/src/backlight.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
10 changes: 6 additions & 4 deletions crates/openlogi-hid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
};
Loading