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
32 changes: 32 additions & 0 deletions crates/openlogi-cli/src/cmd/diag/battery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! `openlogi diag battery` — dump the device's raw battery report.
//!
//! Prints exactly what the firmware returns (unified `0x1004` fields, or legacy
//! `0x1000` `discharge_level`/`next_level`/`status`). Run it once on battery and
//! once with the charger plugged in to see how the device reports while charging
//! — e.g. an MX2S returns `discharge_level=0` mid-charge, which is the device's
//! own limitation, not a bug in the read path.

use anyhow::{Context, Result};
use clap::Args;

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

#[derive(Debug, Args)]
pub struct BatteryArgs {
/// Run against the device whose name contains this string
/// (case-insensitive) instead of auto-selecting.
#[arg(long, value_name = "NAME")]
pub device: Option<String>,
}

pub async fn run(args: BatteryArgs) -> Result<()> {
// 0x1004 UnifiedBattery / 0x1000 BatteryStatus — pick a device with either.
let (route, name) = select_device(args.device.as_deref(), &[0x1000, 0x1004]).await?;
println!("device: {name} ({route})");

let line = openlogi_hid::read_battery_raw(&route)
.await
.context("read battery")?;
println!(" {line}");
Ok(())
}
4 changes: 4 additions & 0 deletions crates/openlogi-cli/src/cmd/diag/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use anyhow::{Result, anyhow};
use clap::Subcommand;
use openlogi_hid::{DeviceRoute, dump_features};

pub mod battery;
pub mod dpi;
pub mod features;
pub mod lighting;
Expand All @@ -19,6 +20,8 @@ pub mod smartshift;
pub enum DiagCmd {
/// Dump every HID++ feature the active device reports.
Features(features::FeaturesArgs),
/// Read the raw battery report (0x1004 or 0x1000 fields).
Battery(battery::BatteryArgs),
/// Read DPI → write a small delta → read back → restore → report.
Dpi(dpi::DpiArgs),
/// Read SmartShift mode → toggle → read back → toggle back → report.
Expand All @@ -31,6 +34,7 @@ impl DiagCmd {
pub async fn run(self) -> Result<()> {
match self {
Self::Features(args) => features::run(args).await,
Self::Battery(args) => battery::run(args).await,
Self::Dpi(args) => dpi::run(args).await,
Self::Smartshift(args) => smartshift::run(args).await,
Self::Lighting(args) => lighting::run(args).await,
Expand Down
75 changes: 63 additions & 12 deletions crates/openlogi-gui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,17 +830,32 @@ fn status_dot(online: bool) -> AnyElement {
.into_any_element()
}

/// True when the device is charging but still reports 0% — the MX2S `0x1000`
/// firmware can't gauge charge under load, and on a cold start there's no
/// pre-charge % cached to carry forward. Show "Charging" without the bogus 0%.
/// ponytail: cold-start only; once any discharge read is cached, the
/// carry-forward in `inventory.rs` holds it and `percentage` is non-zero.
pub(crate) fn battery_charging_no_reading(b: &BatteryInfo) -> bool {
matches!(
b.status,
BatteryStatus::Charging | BatteryStatus::ChargingSlow
) && b.percentage == 0
}

/// Battery readout for a gallery card: a charge/level glyph plus the
/// percentage, in the muted metadata style.
fn battery_view(b: &BatteryInfo, pal: Palette) -> AnyElement {
h_flex()
let row = h_flex()
.gap_1()
.items_center()
.text_xs()
.text_color(pal.text_muted)
.child(Icon::new(battery_icon(b)).size_3())
.child(format!("{}%", b.percentage))
.into_any_element()
.child(Icon::new(battery_icon(b)).size_3());
if battery_charging_no_reading(b) {
row.child(tr!("Charging")).into_any_element()
} else {
row.child(format!("{}%", b.percentage)).into_any_element()
}
}

/// Pick the battery glyph from charge state first (charging / full / error),
Expand Down Expand Up @@ -1351,22 +1366,32 @@ fn battery_summary(battery: &BatteryInfo, pal: Palette) -> impl IntoElement {
.text_xs()
.text_color(pal.text_muted)
.child(status)
.child(format!("{}%", battery.percentage)),
.child(if battery_charging_no_reading(battery) {
String::new()
} else {
format!("{}%", battery.percentage)
}),
)
.child(
div()
.child({
let track = div()
.h(px(6.))
.w_full()
.rounded_full()
.bg(pal.surface_hover)
.child(
.bg(pal.surface_hover);
// Charging with no reliable %: leave the track empty rather than
// drawing the 1%-wide red critical sliver that percentage==0 yields.
if battery_charging_no_reading(battery) {
track
} else {
track.child(
div()
.h_full()
.w(relative_percent(battery.percentage))
.rounded_full()
.bg(rgb(battery_color(battery.percentage))),
),
)
)
}
})
}

fn sidebar_action(
Expand Down Expand Up @@ -1726,7 +1751,8 @@ fn accessibility_status(pal: Palette, granted: bool) -> AnyElement {
#[cfg(test)]
mod tests {
use super::{
Capabilities, DetailTab, DeviceKind, DeviceRecord, DeviceRoute, connection_icon_path,
BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DetailTab, DeviceKind,
DeviceRecord, DeviceRoute, battery_charging_no_reading, connection_icon_path,
};

#[test]
Expand Down Expand Up @@ -1756,6 +1782,31 @@ mod tests {
assert_eq!(connection_icon_path(None), "action-icons/bluetooth.svg");
}

/// "Charging" replaces the bogus percentage only when charging *and* the
/// reading is still 0% (cold start, no cached pre-charge value). A non-zero
/// charge or a real 0% while discharging keeps the number.
#[test]
fn charging_without_reading_suppresses_percentage() {
let b = |percentage, status| BatteryInfo {
percentage,
level: BatteryLevel::Good,
status,
};
assert!(battery_charging_no_reading(&b(0, BatteryStatus::Charging)));
assert!(battery_charging_no_reading(&b(
0,
BatteryStatus::ChargingSlow
)));
assert!(!battery_charging_no_reading(&b(
40,
BatteryStatus::Charging
)));
assert!(!battery_charging_no_reading(&b(
0,
BatteryStatus::Discharging
)));
}

fn record(kind: DeviceKind, capabilities: Option<Capabilities>) -> DeviceRecord {
DeviceRecord {
config_key: "test".to_string(),
Expand Down
3 changes: 3 additions & 0 deletions crates/openlogi-gui/src/app_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ fn device_menu_items(cx: &App) -> Vec<MenuItem> {
Some(state) if !state.device_list.is_empty() => {
for record in &state.device_list {
let title = match &record.battery {
Some(battery) if crate::app::battery_charging_no_reading(battery) => {
format!("{} · {}", record.display_name, tr!("Charging"))
}
Some(battery) => format!("{} · {}%", record.display_name, battery.percentage),
None => record.display_name.clone(),
};
Expand Down
Loading
Loading