Skip to main content

hidpp/feature/battery_voltage/
mod.rs

1//! Implements the `BatteryVoltage` feature (ID `0x1001`) that reports a
2//! device's battery charge as a measured voltage plus a charging-flags byte.
3//!
4//! G-series wireless gaming devices (G915, G903 LS, G502 LIGHTSPEED) expose
5//! `0x1001` and neither the legacy `0x1000` nor the unified `0x1004`, so
6//! without this feature the inventory probe finds no battery source for them
7//! at all. Unlike its siblings the feature reports no percentage — callers
8//! estimate one from the voltage (see `openlogi-hid`'s mappings).
9//!
10//! Only `getBatteryInfo` (function `0`) is implemented; the broadcast event
11//! isn't needed to display a charge reading — the same scope `BatteryStatus`
12//! (`0x1000`) keeps.
13//!
14//! The wire layout is not in a public Logitech spec: the voltage as a
15//! big-endian millivolt `u16` followed by one flags byte was
16//! reverse-engineered, and the decoding here follows Solaar
17//! (`decipher_battery_voltage`) and libratbag's consensus on the flag bits.
18
19use std::sync::Arc;
20
21use crate::{
22    channel::HidppChannel,
23    feature::{CreatableFeature, Feature, FeatureEndpoint},
24    protocol::v20::Hidpp20Error,
25};
26
27/// Implements the `BatteryVoltage` / `0x1001` feature.
28pub struct BatteryVoltageFeature {
29    /// The endpoint this feature talks to.
30    endpoint: FeatureEndpoint,
31}
32
33impl CreatableFeature for BatteryVoltageFeature {
34    const ID: u16 = 0x1001;
35    const STARTING_VERSION: u8 = 0;
36
37    fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
38        Self {
39            endpoint: FeatureEndpoint::new(chan, device_index, feature_index),
40        }
41    }
42}
43
44impl Feature for BatteryVoltageFeature {}
45
46impl BatteryVoltageFeature {
47    /// Reads the measured battery voltage and charging state (function `0`,
48    /// `getBatteryInfo`).
49    pub async fn get_battery_info(&self) -> Result<VoltageBatteryInfo, Hidpp20Error> {
50        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
51        Ok(VoltageBatteryInfo::from_wire(&payload))
52    }
53}
54
55/// A reading from the `0x1001` `getBatteryInfo` function.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize))]
58#[non_exhaustive]
59pub struct VoltageBatteryInfo {
60    /// Measured battery voltage in millivolt — roughly `3500` (empty) to
61    /// `4200` (full) for the single-cell Li-Po batteries these devices carry.
62    pub voltage_mv: u16,
63
64    /// The charging state decoded from the flags byte.
65    pub status: VoltageChargingStatus,
66
67    /// The firmware's "charge level critical" marker (flags bit `5`).
68    pub critical: bool,
69}
70
71impl VoltageBatteryInfo {
72    /// Decodes a `getBatteryInfo` response payload: voltage as a big-endian
73    /// millivolt `u16` in bytes `0`–`1`, the charging flags in byte `2`.
74    #[must_use]
75    pub fn from_wire(payload: &[u8; 16]) -> Self {
76        let flags = payload[2];
77        Self {
78            voltage_mv: u16::from_be_bytes([payload[0], payload[1]]),
79            status: VoltageChargingStatus::from_flags(flags),
80            critical: flags & (1 << 5) != 0,
81        }
82    }
83}
84
85/// Charging state decoded from the `0x1001` flags byte.
86///
87/// Bit `7` set means external power is present; bits `0`–`1` then carry the
88/// charge status (`0b01` charge complete, `0b10` charge fault) and bits `3` /
89/// `4` mark fast / slow charging. Bit assignments follow Solaar and libratbag.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92#[non_exhaustive]
93pub enum VoltageChargingStatus {
94    /// Running on battery (bit `7` clear).
95    Discharging,
96    /// Charging at the standard rate.
97    Charging,
98    /// Charging at a raised current (bit `3`).
99    ChargingFast,
100    /// Charging at reduced current (bit `4`).
101    ChargingSlow,
102    /// On external power with charge complete (status bits `0b01`).
103    Full,
104    /// On external power but not charging — a charge fault (status bits
105    /// `0b10`).
106    NotCharging,
107}
108
109impl VoltageChargingStatus {
110    /// Decodes the flags byte. Total on purpose: a contradictory or future
111    /// flag combination falls into the nearest charging bucket rather than
112    /// failing, so a battery reading never vanishes over an unknown bit.
113    fn from_flags(flags: u8) -> Self {
114        if flags & (1 << 7) == 0 {
115            return Self::Discharging;
116        }
117        match flags & 0x03 {
118            0x01 | 0x03 => Self::Full,
119            0x02 => Self::NotCharging,
120            _ => {
121                if flags & (1 << 3) != 0 {
122                    Self::ChargingFast
123                } else if flags & (1 << 4) != 0 {
124                    Self::ChargingSlow
125                } else {
126                    Self::Charging
127                }
128            }
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{VoltageBatteryInfo, VoltageChargingStatus};
136
137    /// Builds a 16-byte payload from the 3 meaningful bytes.
138    fn payload(voltage_mv: u16, flags: u8) -> [u8; 16] {
139        let mut payload = [0; 16];
140        payload[..2].copy_from_slice(&voltage_mv.to_be_bytes());
141        payload[2] = flags;
142        payload
143    }
144
145    #[test]
146    fn discharging_reading_decodes_voltage_and_status() {
147        let info = VoltageBatteryInfo::from_wire(&payload(3781, 0x00));
148        assert_eq!(info.voltage_mv, 3781);
149        assert_eq!(info.status, VoltageChargingStatus::Discharging);
150        assert!(!info.critical);
151    }
152
153    #[test]
154    fn external_power_flag_alone_means_standard_charging() {
155        let info = VoltageBatteryInfo::from_wire(&payload(4100, 0x80));
156        assert_eq!(info.status, VoltageChargingStatus::Charging);
157    }
158
159    #[test]
160    fn charge_status_bits_take_precedence_over_rate_bits() {
161        // Charge complete wins over a stale fast-charge bit.
162        let info = VoltageBatteryInfo::from_wire(&payload(4186, 0x80 | 0x08 | 0x01));
163        assert_eq!(info.status, VoltageChargingStatus::Full);
164        let info = VoltageBatteryInfo::from_wire(&payload(4000, 0x80 | 0x02));
165        assert_eq!(info.status, VoltageChargingStatus::NotCharging);
166    }
167
168    #[test]
169    fn rate_bits_split_fast_and_slow_charging() {
170        let fast = VoltageBatteryInfo::from_wire(&payload(3900, 0x80 | 0x08));
171        assert_eq!(fast.status, VoltageChargingStatus::ChargingFast);
172        let slow = VoltageBatteryInfo::from_wire(&payload(3900, 0x80 | 0x10));
173        assert_eq!(slow.status, VoltageChargingStatus::ChargingSlow);
174    }
175
176    #[test]
177    fn critical_bit_is_surfaced_independently_of_status() {
178        let info = VoltageBatteryInfo::from_wire(&payload(3520, 0x20));
179        assert_eq!(info.status, VoltageChargingStatus::Discharging);
180        assert!(info.critical);
181    }
182
183    #[test]
184    fn without_external_power_the_rate_bits_are_meaningless() {
185        // Bit 7 clear: whatever the low bits claim, the device runs on battery.
186        let info = VoltageBatteryInfo::from_wire(&payload(3700, 0x1b));
187        assert_eq!(info.status, VoltageChargingStatus::Discharging);
188    }
189}