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
2 changes: 1 addition & 1 deletion crates/openlogi-cli/src/cmd/diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub enum DiagCmd {
Dpi(dpi::DpiArgs),
/// Read SmartShift mode → toggle → read back → toggle back → report.
Smartshift(smartshift::SmartshiftArgs),
/// Set a wired RGB keyboard to a solid colour (e.g. `ff0000` for red).
/// Set a device's RGB LEDs to a solid colour (e.g. `ff0000` for red).
Lighting(lighting::LightingArgs),
/// Read or set the HID++ 0x2121 wheel reporting resolution.
Wheel(wheel::WheelArgs),
Expand Down
141 changes: 90 additions & 51 deletions crates/openlogi-cli/src/cmd/diag/lighting.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
//! `openlogi diag lighting <RRGGBB>` — set a wired RGB keyboard to a solid
//! colour via HID++ `PerKeyLighting` (0x8080).
//! `openlogi diag lighting <RRGGBB>` — set a device's RGB LEDs to a solid
//! colour via HID++ `RgbEffects` (0x8071), `ColorLedEffects` (0x8070) or
//! `PerKeyLighting` (0x8080).
//!
//! Targets the first online direct-attached (USB) Logitech device — i.e. a
//! wired G-series keyboard — by VID/PID, so it isn't tied to one model.
//! Picks the first online device exposing one of those, so it reaches a wireless
//! mouse behind a receiver as well as a wired keyboard.

use anyhow::{Result, anyhow};
use anyhow::Result;
use clap::{Args, ValueEnum};
use openlogi_core::color::Rgb;
use openlogi_hid::{DeviceRoute, LightingMethod};
use openlogi_hid::LightingMethod;

use crate::cmd::diag::select_device;

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Method {
/// Prefer 0x8070 ColorLedEffects, fall back to 0x8080 per-key (default).
/// Walk 0x8071 → 0x8070 → 0x8080, taking the first exposed (default).
Auto,
/// Force 0x8070 ColorLedEffects (the fixed-effect onboard override).
/// Force 0x8071 RgbEffects (the per-cluster fixed-effect override).
Rgb,
/// Force 0x8070 ColorLedEffects (the per-zone fixed-effect override).
Effects,
/// Force 0x8080 PerKeyLighting (the per-key stream).
Perkey,
Expand All @@ -23,6 +28,7 @@ impl From<Method> for LightingMethod {
fn from(m: Method) -> Self {
match m {
Method::Auto => Self::Auto,
Method::Rgb => Self::RgbEffects,
Method::Effects => Self::Effects,
Method::Perkey => Self::PerKey,
}
Expand All @@ -32,10 +38,21 @@ impl From<Method> for LightingMethod {
#[derive(Debug, Args)]
pub struct LightingArgs {
/// Colour as `RRGGBB` hex (e.g. `ff0000` for red).
pub color: String,
#[arg(required_unless_present_any = ["info", "release_control"])]
pub color: Option<String>,

/// Dump the device's 0x8071 clusters and their effects, then exit without
/// writing a colour.
#[arg(long)]
pub info: bool,

/// Hand the 0x8071 clusters back to the device's own effect engine and
/// exit, undoing the software control a colour write takes.
#[arg(long, conflicts_with = "info")]
pub release_control: bool,

/// Run against the wired device whose name contains this string
/// (case-insensitive). Useful when several keyboards are connected.
/// Run against the device whose name contains this string
/// (case-insensitive). Useful when several lit devices are connected.
#[arg(long, value_name = "NAME")]
pub device: Option<String>,

Expand All @@ -45,53 +62,73 @@ pub struct LightingArgs {
}

pub async fn run(args: LightingArgs) -> Result<()> {
let color: Rgb = args.color.trim_start_matches('#').parse()?;
let (r, g, b) = color.components();
// Parse before touching hardware so a typo fails fast rather than after a
// device round-trip.
let color = args
.color
.as_deref()
.map(|c| c.trim_start_matches('#').parse::<Rgb>())
.transpose()?;

let device_query = args.device;
let needle = device_query.as_deref().map(str::to_lowercase);

let inventories = openlogi_hid::enumerate().await?;
let (route, name) = inventories
.iter()
.find_map(|inv| {
// Direct (USB-wired) devices carry no receiver UID — that's the
// wired keyboard. Bolt/Unifying receivers (mice) are skipped.
if inv.receiver.unique_id.is_some() {
return None;
}
let paired = inv.paired.iter().find(|p| p.online)?;
let name = paired.codename.clone().unwrap_or_else(|| {
format!(
"{:04x}:{:04x}",
inv.receiver.vendor_id, inv.receiver.product_id
)
});
if let Some(ref n) = needle
&& !name.to_lowercase().contains(n.as_str())
{
return None;
}
let route = DeviceRoute::Direct {
vendor_id: inv.receiver.vendor_id,
product_id: inv.receiver.product_id,
};
Some((route, name))
})
.ok_or_else(|| match &device_query {
Some(q) => anyhow!("no wired device matches `--device {q}`"),
None => {
anyhow!("no wired (direct-USB) Logitech device found — is the keyboard plugged in?")
}
})?;
// The three features `set_keyboard_color` can drive — auto-skip devices with
// no LEDs to paint.
let (route, name) = select_device(args.device.as_deref(), &[0x8071, 0x8070, 0x8080]).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 RGB diagnostics select legacy devices

When a 0x8070- or 0x8080-only device precedes the intended 0x8071 device, the union feature filter selects that legacy device for --info, --release-control, or --method rgb, causing the 0x8071-only operation to fail with FeatureUnsupported despite a compatible device being online.

Knowledge Base Used: OpenLogi CLI

Fix in Codex Fix in Claude Code

println!("device: {name} ({route})");

if args.info {
return print_rgb_info(&route).await;
}

if args.release_control {
openlogi_hid::release_rgb_control(&route).await?;
println!("released software control — the device drives its own RGB again");
return Ok(());
}

let Some(color) = color else {
// Unreachable via clap (`required_unless_present`), but the type is an
// Option either way.
anyhow::bail!("a colour is required unless --info is given");
};
let (r, g, b) = color.components();
let method: LightingMethod = args.method.into();
println!("setting {name} ({route}) to #{r:02x}{g:02x}{b:02x} via {method:?}");
println!("setting {name} to #{r:02x}{g:02x}{b:02x} via {method:?}");
openlogi_hid::set_keyboard_color_with(&route, method, r, g, b).await?;
println!("done — {name} should now be solid #{r:02x}{g:02x}{b:02x}");
Ok(())
}

/// Dump the `0x8071` cluster/effect table so a device whose LEDs don't all
/// respond can be told apart from one OpenLogi simply isn't addressing.
async fn print_rgb_info(route: &openlogi_hid::DeviceRoute) -> Result<()> {
let (control, clusters) = openlogi_hid::dump_rgb_clusters(route).await?;
println!(
" software control: all_clusters={} power_modes={}",
control.all_clusters, control.power_modes
);
if clusters.is_empty() {
println!(" no 0x8071 clusters reported");
return Ok(());
}
for cluster in &clusters {
println!(
" cluster {} location={:#06x} effect_persistency={} multiled_pattern={} effects={}",
cluster.index,
cluster.location,
cluster.effect_persistency,
cluster.multiled_pattern,
cluster.effects.len()
);
for effect in &cluster.effects {
println!(
" effect {:>2} id={:#06x} caps={:#06x} period={}ms",
effect.index, effect.effect_id, effect.effect_capabilities, effect.effect_period
);
}
}
Ok(())
}

#[cfg(test)]
#[allow(
clippy::unwrap_used,
Expand All @@ -105,7 +142,9 @@ mod color_validation_tests {

fn args(color: &str) -> LightingArgs {
LightingArgs {
color: color.to_string(),
color: Some(color.to_string()),
info: false,
release_control: false,
device: None,
method: Method::Auto,
}
Expand Down
23 changes: 22 additions & 1 deletion crates/openlogi-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,34 @@ mod tests {

match cli.cmd.expect("subcommand present") {
Command::Diag(DiagCmd::Lighting(args)) => {
assert_eq!(args.color, "ff0000");
assert_eq!(args.color.as_deref(), Some("ff0000"));
assert!(matches!(args.method, Method::Effects));
}
other => panic!("expected Diag(Lighting), got {other:?}"),
}
}

#[test]
fn lighting_info_makes_the_colour_optional() {
let cli = Cli::try_parse_from(["openlogi", "diag", "lighting", "--info"])
.expect("--info parses without a colour");

match cli.cmd.expect("subcommand present") {
Command::Diag(DiagCmd::Lighting(args)) => {
assert!(args.info);
assert!(args.color.is_none());
}
other => panic!("expected Diag(Lighting), got {other:?}"),
}
}

#[test]
fn lighting_without_colour_or_info_is_rejected() {
// The colour stays mandatory for a write — `--info` and
// `--release-control` are the only flags that excuse it.
assert!(Cli::try_parse_from(["openlogi", "diag", "lighting"]).is_err());
}

#[test]
fn lighting_rejects_unknown_method() {
let result = Cli::try_parse_from([
Expand Down
21 changes: 14 additions & 7 deletions crates/openlogi-core/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ pub struct Capabilities {
/// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi).
pub pointer: bool,
/// Solid-colour RGB the lighting panel can actually drive — HID++
/// `ColorLedEffects` (`0x8070`) or `PerKeyLighting` (`0x8080`), the features
/// `set_keyboard_color` writes. Backlight-only families aren't driven by the
/// panel, so they don't flip this and don't earn an inert Lighting tab.
/// `RgbEffects` (`0x8071`), `ColorLedEffects` (`0x8070`) or `PerKeyLighting`
/// (`0x8080`), the features `set_keyboard_color` writes. Backlight-only
/// families aren't driven by the panel, so they don't flip this and don't
/// earn an inert Lighting tab.
pub lighting: bool,
/// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the
/// firmware-reported `has_invert` capability.
Expand All @@ -111,11 +112,13 @@ impl Capabilities {
pub fn from_feature_ids(ids: &[u16]) -> Self {
const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
const POINTER: [u16; 2] = [0x2201, 0x2202];
// PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven
// by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a
// running onboard profile, falling back to 0x8080 per-key). Other families
// RgbEffects (0x8071), ColorLedEffects (0x8070) and PerKeyLighting
// (0x8080) — all three driven by `set_keyboard_color`, which walks them
// in that order (0x8071 is the modern per-cluster engine G-series *mice*
// expose instead of 0x8070; both fixed-effect paths override a running
// onboard profile, and 0x8080 per-key is the last resort). Other families
// (backlight 0x198x) stay out so they don't earn a tab the panel can't drive.
const LIGHTING: [u16; 2] = [0x8080, 0x8070];
const LIGHTING: [u16; 3] = [0x8080, 0x8070, 0x8071];
let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
Self {
buttons: has(&BUTTONS),
Expand Down Expand Up @@ -436,6 +439,10 @@ mod tests {
hires_wheel: true,
}
);
// A G903 LIGHTSPEED: RgbEffects (0x8071) is the only lighting feature it
// reports — no 0x8070, no 0x8080 — so it must still earn a Lighting tab.
let g903 = Capabilities::from_feature_ids(&[0x1b04, 0x2201, 0x2121, 0x8071]);
assert!(g903.lighting, "0x8071-only device should offer lighting");
// A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
assert_eq!(
Expand Down
5 changes: 3 additions & 2 deletions crates/openlogi-hid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ 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,
ReprogControlEntry, RgbClusterEntry, RgbControlState, RgbEffectEntry, SharedChannel,
WriteError, dump_features, dump_reprog_controls, dump_rgb_clusters, get_dpi, get_dpi_info,
get_smartshift_status, release_rgb_control, 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,
};
5 changes: 4 additions & 1 deletion crates/openlogi-hid/src/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ mod smartshift;
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};
pub use lighting::{LightingMethod, set_keyboard_color, set_keyboard_color_with};
pub use lighting::{
LightingMethod, RgbClusterEntry, RgbControlState, RgbEffectEntry, dump_rgb_clusters,
release_rgb_control, set_keyboard_color, set_keyboard_color_with,
};
pub use shared::{SharedChannel, set_dpi_on, set_smartshift_on, toggle_smartshift_on};
pub use smartshift::{
get_smartshift_status, set_smartshift, set_smartshift_sensitivity, toggle_smartshift,
Expand Down
Loading