Skip to content

feat(battery): support legacy 0x1000 BatteryStatus and its charging quirk#312

Open
laofun wants to merge 1 commit into
AprilNEA:masterfrom
laofun:pr/legacy-battery
Open

feat(battery): support legacy 0x1000 BatteryStatus and its charging quirk#312
laofun wants to merge 1 commit into
AprilNEA:masterfrom
laofun:pr/legacy-battery

Conversation

@laofun

@laofun laofun commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Older mice like a Bluetooth-direct MX Master 2S only expose the legacy BatteryStatus feature (0x1000), never the unified 0x1004, so they showed no battery at all. This adds 0x1000 support and handles its quirk: the firmware reports 0% while charging.

Changes

  • Implement 0x1000, mirroring the SmartShift 0x2110/0x2111 fallback. The inventory probe prefers 0x1004 and falls back to 0x1000. Coarse BatteryLevel from fixed buckets (0x1000 has no level bitmask).
  • Hold the last-known % through a charge session so the reading does not flip to "Charging · 0%" once one discharge read exists.
  • Cold start (charger plugged before the app opens, no cached %): show "Charging" instead of a bogus 0% across the card, summary, and tray menu.
  • Add openlogi diag battery to print the raw 0x1004/0x1000 report.

Testing

  • cargo fmt --all -- --check, cargo clippy --workspace, cargo test (bucket mapping, hold-% across all four branches, GUI label).
  • On hardware: MX2S shows 50% discharging over BT-direct, and 0% status=Recharging while charging.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds support for the legacy 0x1000 BatteryStatus feature (used by older mice like the MX Master 2S that never expose the unified 0x1004), and handles the firmware quirk where discharge_level is reported as 0 during charging.

  • Feature probe: battery_feature_index now returns a BatteryProbe enum (Unified | Legacy), preferring 0x1004 and falling back to 0x1000 — the same enhanced-then-legacy split used for SmartShift.
  • Hold-on-zero: hold_percentage_while_charging carries the last non-zero discharge reading forward through a charge session, scoped exclusively to BatteryProbe::Legacy so a genuine 0% from a 0x1004 device is never masked.
  • Cold-start GUI protection: battery_charging_no_reading suppresses the bogus "0%" across the card, battery summary, and tray menu when the app starts while a legacy device is already on charge with no cached prior reading. Adds openlogi diag battery to dump raw feature output for field diagnostics.

Confidence Score: 5/5

Safe to merge — all four issues from the previous review round have been addressed, and the new code is well-scoped with good test coverage.

The hold-on-zero logic is correctly gated to Legacy probes only. The loop-overflow path now uses break instead of ?-returning None. Value 7 is an explicit enum variant so TryFromPrimitive no longer silently drops it. The fallthrough error in read_battery_raw reports 0x1004 rather than the misleading 0x1000. Tests cover the four hold branches, the Unified passthrough, bucket boundaries, and the tray/card GUI suppression. The two-layer design (server-side hold + GUI cold-start guard) is consistent and the interaction between layers is correct.

No files require special attention.

Important Files Changed

Filename Overview
crates/openlogi-hidpp/src/feature/battery_status/mod.rs New BatteryStatusFeature (0x1000) with LegacyBatteryStatus enum including Other=7; clean implementation mirroring UnifiedBattery pattern with #[non_exhaustive] for forward compatibility.
crates/openlogi-hid/src/inventory.rs Introduces BatteryProbe enum and hold_percentage_while_charging; probe prefers 0x1004 over 0x1000, hold is correctly scoped to Legacy probe only, overflow ?-returning is fixed with break, and tests cover all four hold branches.
crates/openlogi-hid/src/mappings.rs Adds map_legacy_battery_status and legacy_battery_level_from_percentage with correct wildcard arm (required because LegacyBatteryStatus is #[non_exhaustive] from a foreign crate); well-tested bucket boundaries.
crates/openlogi-gui/src/app.rs Adds battery_charging_no_reading for cold-start GUI protection; battery_summary uses justify_between with String::new() as a safe no-op placeholder; battery_view and test coverage look correct.
crates/openlogi-hid/src/write.rs New read_battery_raw diagnostic function; fallthrough error correctly uses feature_hex: 0x1004 (not 0x1000) since neither feature was found.
crates/openlogi-gui/src/app_menu.rs Tray menu now shows 'Device · Charging' for cold-start 0% case, matching the GUI card behavior; pattern arm ordering is correct.
crates/openlogi-cli/src/cmd/diag/battery.rs New openlogi diag battery command; probes both 0x1000 and 0x1004 via select_device, delegates to read_battery_raw — clean and minimal.
crates/openlogi-cli/src/cmd/diag/mod.rs Registers Battery subcommand in DiagCmd enum and dispatch match; straightforward wiring.
crates/openlogi-hid/src/lib.rs Re-exports read_battery_raw from write module; one-line change.
crates/openlogi-hidpp/src/feature/mod.rs Adds pub mod battery_status declaration; trivial one-line addition.
crates/openlogi-hidpp/src/feature/registry.rs Promotes 0x1000 BatteryStatus from a named-only entry to a creatable feature backed by BatteryStatusFeature; mirrors 0x1004 UnifiedBattery pattern.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[probe_or_reuse called] --> B{Cache stale or empty?}
    B -- Yes --> C[probe_features: enumerate feature table]
    C --> D{0x1004 in table?}
    D -- Yes --> E[BatteryProbe::Unified index]
    D -- No --> F{0x1000 in table?}
    F -- Yes --> G[BatteryProbe::Legacy index]
    F -- No --> H[battery = None]
    E --> I[read_battery via 0x1004]
    G --> J[read_battery via 0x1000]
    I --> K[hold_percentage_while_charging]
    J --> K
    K --> L{probe == Legacy AND\npercentage==0 AND charging\nAND prev > 0?}
    L -- Yes --> M[Substitute prev percentage+level\nkeep fresh status]
    L -- No --> N[Return fresh BatteryInfo]
    M --> O[Store in Cached]
    N --> O
    O --> P[GUI render]
    B -- No, cache hit --> Q[read_battery only\ncheap 1 round-trip]
    Q --> K
    P --> R{battery_charging_no_reading?\ncharging AND percentage==0}
    R -- Yes --> S[Show Charging without %\nleave progress bar empty]
    R -- No --> T[Show percentage% and fill bar]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[probe_or_reuse called] --> B{Cache stale or empty?}
    B -- Yes --> C[probe_features: enumerate feature table]
    C --> D{0x1004 in table?}
    D -- Yes --> E[BatteryProbe::Unified index]
    D -- No --> F{0x1000 in table?}
    F -- Yes --> G[BatteryProbe::Legacy index]
    F -- No --> H[battery = None]
    E --> I[read_battery via 0x1004]
    G --> J[read_battery via 0x1000]
    I --> K[hold_percentage_while_charging]
    J --> K
    K --> L{probe == Legacy AND\npercentage==0 AND charging\nAND prev > 0?}
    L -- Yes --> M[Substitute prev percentage+level\nkeep fresh status]
    L -- No --> N[Return fresh BatteryInfo]
    M --> O[Store in Cached]
    N --> O
    O --> P[GUI render]
    B -- No, cache hit --> Q[read_battery only\ncheap 1 round-trip]
    Q --> K
    P --> R{battery_charging_no_reading?\ncharging AND percentage==0}
    R -- Yes --> S[Show Charging without %\nleave progress bar empty]
    R -- No --> T[Show percentage% and fill bar]
Loading

Reviews (4): Last reviewed commit: "feat(battery): support legacy 0x1000 Bat..." | Re-trigger Greptile

Comment thread crates/openlogi-hid/src/write.rs
Comment thread crates/openlogi-hid/src/inventory.rs Outdated
Comment thread crates/openlogi-hidpp/src/feature/battery_status/mod.rs
@laofun laofun force-pushed the pr/legacy-battery branch from 4cffc1a to 86e4154 Compare June 22, 2026 16:13
@laofun

laofun commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps — applied all three:

  1. write.rs fallthrough now reports feature_hex: 0x1004 (the preferred feature) instead of implying 0x1000 was specifically absent.
  2. battery_feature_index now breaks past u8::MAX instead of ?-returning None and discarding an already-found legacy probe.
  3. Added LegacyBatteryStatus::Other = 7 so a device reporting Solaar's value 7 surfaces as BatteryStatus::Unknown rather than failing the parse and making the battery vanish — covered by a new unit test.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

All three changes look correct and well-executed. A few observations:

1. write.rs fallthrough (feature_hex: 0x1004) — correct. The comment makes the intent clear, and 0x1004 is the right "preferred" feature to report when neither is present.

2. battery_feature_index break past u8::MAX — the loop structure is clean. The comment even acknowledges the unreachability honestly. The legacy accumulation across the loop means an early 0x1000 entry won't be lost if the table somehow exceeds 255 entries before a 0x1004 hit.

3. LegacyBatteryStatus::Other = 7 — the test in mappings.rs is a good regression anchor:

let other = HidppLegacyBatteryStatus::try_from(7u8).expect("7 is a known variant");
assert_eq!(map_legacy_battery_status(other), BatteryStatus::Unknown);

This validates both that the TryFromPrimitive derive accepts 7 and that the wildcard arm in map_legacy_battery_status routes it to Unknown rather than failing.

One minor nit on get_battery_level_status in battery_status/mod.rs: the LegacyBatteryStatus::try_from failure path still maps to Hidpp20Error::UnsupportedResponse. With Other = 7 added, the only way to hit that arm now is a truly undocumented value (8+). The mapping is reasonable, but worth confirming that TryFromPrimitive on a #[non_exhaustive] enum still derives TryFrom<u8> for only the declared variants — it does, since #[non_exhaustive] is a stability guard for downstream crates, not a change to the derive semantics. No action needed.

The PR is in good shape. LGTM.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment thread crates/openlogi-hid/src/inventory.rs
@laofun laofun force-pushed the pr/legacy-battery branch from 86e4154 to ffa4f23 Compare June 22, 2026 16:27

@AprilNEA AprilNEA left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the careful follow-up here — the 0x1000 fallback, diagnostic command, and regression tests all look well-scoped, and CI is green.

I found two edge cases worth tightening before merge:

  1. hold_percentage_while_charging should probably be scoped to BatteryProbe::Legacy. The helper and comments describe the MX2S / 0x1000 charging quirk, but it is currently applied after probe_features and on cache refresh regardless of whether the selected probe is Unified or Legacy (crates/openlogi-hid/src/inventory.rs, around the calls after fresh reads). On a 0x1004 device that legitimately reports charging_percentage = 0 while charging, or reports no percentage support, a previous non-zero cached value could be carried forward even though the legacy quirk is not involved. Matching on BatteryProbe::Legacy at the call site would keep the workaround tied to the feature that needs it.

  2. The cold-start GUI suppression hides the 0% text, but the detail summary progress bar still renders from battery.percentage (relative_percent(0) and battery_color(0)). That means the summary can still show an empty/critical-looking bar for the same “Charging, no reliable reading” case. It may be worth hiding the bar, using a neutral/charging style, or otherwise routing battery_charging_no_reading(battery) through the progress-bar rendering too.

Otherwise this is a nice, focused addition — especially the raw openlogi diag battery path for verifying the firmware behavior on real devices.

@laofun laofun force-pushed the pr/legacy-battery branch from ffa4f23 to 7e11836 Compare June 25, 2026 03:03
@laofun

laofun commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed:

  1. hold_percentage_while_charging now takes the BatteryProbe and early-returns unless it's Legacy, so the hold is tied to the 0x1000 quirk — a 0x1004 device reporting a genuine 0% while charging surfaces it. Added a unified_charging_zero_is_not_held test.
  2. The summary progress bar now routes through battery_charging_no_reading() too: in the "charging, no reliable reading" case it leaves the track empty instead of drawing the 1%-wide critical sliver.

…uirk

Older devices such as a Bluetooth-direct MX Master 2S expose only the legacy
BatteryStatus feature (0x1000), never the unified 0x1004, so they showed no
battery at all. Implement 0x1000 mirroring the SmartShift 0x2110/0x2111 design
and have the inventory probe prefer 0x1004, falling back to 0x1000 — the same
enhanced-then-legacy order SmartShift uses. 0x1000 reports a percentage but no
level bitmask, so the coarse BatteryLevel is derived from fixed display buckets.

The 0x1000 firmware can't gauge charge under load: it reports discharge_level=0
status=Recharging while charging, which surfaced as a misleading "Charging · 0%".
Two layers handle it:

- hold_percentage_while_charging() carries the last-known percentage forward
  through a charge session (frozen pre-charge value, cache only) so the reading
  stays trackable once one discharge read exists.
- On a cold start (charger plugged before the app opens) there's no prior to
  hold, so the GUI shows "Charging" instead of the bogus 0% across the card,
  summary, and tray menu.

Adds `openlogi diag battery`, which prints the raw 0x1004/0x1000 report so a
claim like "MX2S shows 0% while charging" can be confirmed against the wire.

Verified on hardware: MX2S reports 50% (discharging) over BT-direct, and 0%
status=Recharging while charging.
@laofun laofun force-pushed the pr/legacy-battery branch from 7e11836 to d54b363 Compare June 25, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants