Skip to main content

hidpp/feature/
battery_voltage.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 openlogi_hidpp_derive::Feature;
20
21use crate::{feature::FeatureEndpoint, protocol::v20::Hidpp20Error};
22
23/// Implements the `BatteryVoltage` / `0x1001` feature.
24#[derive(Feature)]
25#[creatable(id = 0x1001, version = 0)]
26pub struct BatteryVoltageFeature {
27    /// The endpoint this feature talks to.
28    endpoint: FeatureEndpoint,
29}
30
31impl BatteryVoltageFeature {
32    /// Reads the measured battery voltage and charging state (function `0`,
33    /// `getBatteryInfo`).
34    pub async fn get_battery_info(&self) -> Result<VoltageBatteryInfo, Hidpp20Error> {
35        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
36        Ok(VoltageBatteryInfo::from_wire(&payload))
37    }
38}
39
40/// A reading from the `0x1001` `getBatteryInfo` function.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize))]
43#[non_exhaustive]
44pub struct VoltageBatteryInfo {
45    /// Measured battery voltage in millivolt — roughly `3500` (empty) to
46    /// `4200` (full) for the single-cell Li-Po batteries these devices carry.
47    pub voltage_mv: u16,
48
49    /// The charging state decoded from the flags byte.
50    pub status: VoltageChargingStatus,
51
52    /// The firmware's "charge level critical" marker (flags bit `5`).
53    pub critical: bool,
54}
55
56impl VoltageBatteryInfo {
57    /// Decodes a `getBatteryInfo` response payload: voltage as a big-endian
58    /// millivolt `u16` in bytes `0`–`1`, the charging flags in byte `2`.
59    #[must_use]
60    pub fn from_wire(payload: &[u8; 16]) -> Self {
61        let flags = payload[2];
62        Self {
63            voltage_mv: u16::from_be_bytes([payload[0], payload[1]]),
64            status: VoltageChargingStatus::from_flags(flags),
65            critical: flags & (1 << 5) != 0,
66        }
67    }
68}
69
70/// Charging state decoded from the `0x1001` flags byte.
71///
72/// Bit `7` set means external power is present; bits `0`–`1` then carry the
73/// charge status (`0b01` charge complete, `0b10` charge fault) and bits `3` /
74/// `4` mark fast / slow charging. Bit assignments follow Solaar and libratbag.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize))]
77#[non_exhaustive]
78pub enum VoltageChargingStatus {
79    /// Running on battery (bit `7` clear).
80    Discharging,
81    /// Charging at the standard rate.
82    Charging,
83    /// Charging at a raised current (bit `3`).
84    ChargingFast,
85    /// Charging at reduced current (bit `4`).
86    ChargingSlow,
87    /// On external power with charge complete (status bits `0b01`).
88    Full,
89    /// On external power but not charging — a charge fault (status bits
90    /// `0b10`).
91    NotCharging,
92}
93
94impl VoltageChargingStatus {
95    /// Decodes the flags byte. Total on purpose: a contradictory or future
96    /// flag combination falls into the nearest charging bucket rather than
97    /// failing, so a battery reading never vanishes over an unknown bit.
98    fn from_flags(flags: u8) -> Self {
99        if flags & (1 << 7) == 0 {
100            return Self::Discharging;
101        }
102        match flags & 0x03 {
103            0x01 | 0x03 => Self::Full,
104            0x02 => Self::NotCharging,
105            _ => {
106                if flags & (1 << 3) != 0 {
107                    Self::ChargingFast
108                } else if flags & (1 << 4) != 0 {
109                    Self::ChargingSlow
110                } else {
111                    Self::Charging
112                }
113            }
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::{VoltageBatteryInfo, VoltageChargingStatus};
121
122    /// Builds a 16-byte payload from the 3 meaningful bytes.
123    fn payload(voltage_mv: u16, flags: u8) -> [u8; 16] {
124        let mut payload = [0; 16];
125        payload[..2].copy_from_slice(&voltage_mv.to_be_bytes());
126        payload[2] = flags;
127        payload
128    }
129
130    #[test]
131    fn discharging_reading_decodes_voltage_and_status() {
132        let info = VoltageBatteryInfo::from_wire(&payload(3781, 0x00));
133        assert_eq!(info.voltage_mv, 3781);
134        assert_eq!(info.status, VoltageChargingStatus::Discharging);
135        assert!(!info.critical);
136    }
137
138    #[test]
139    fn external_power_flag_alone_means_standard_charging() {
140        let info = VoltageBatteryInfo::from_wire(&payload(4100, 0x80));
141        assert_eq!(info.status, VoltageChargingStatus::Charging);
142    }
143
144    #[test]
145    fn charge_status_bits_take_precedence_over_rate_bits() {
146        // Charge complete wins over a stale fast-charge bit.
147        let info = VoltageBatteryInfo::from_wire(&payload(4186, 0x80 | 0x08 | 0x01));
148        assert_eq!(info.status, VoltageChargingStatus::Full);
149        let info = VoltageBatteryInfo::from_wire(&payload(4000, 0x80 | 0x02));
150        assert_eq!(info.status, VoltageChargingStatus::NotCharging);
151    }
152
153    #[test]
154    fn rate_bits_split_fast_and_slow_charging() {
155        let fast = VoltageBatteryInfo::from_wire(&payload(3900, 0x80 | 0x08));
156        assert_eq!(fast.status, VoltageChargingStatus::ChargingFast);
157        let slow = VoltageBatteryInfo::from_wire(&payload(3900, 0x80 | 0x10));
158        assert_eq!(slow.status, VoltageChargingStatus::ChargingSlow);
159    }
160
161    #[test]
162    fn critical_bit_is_surfaced_independently_of_status() {
163        let info = VoltageBatteryInfo::from_wire(&payload(3520, 0x20));
164        assert_eq!(info.status, VoltageChargingStatus::Discharging);
165        assert!(info.critical);
166    }
167
168    #[test]
169    fn without_external_power_the_rate_bits_are_meaningless() {
170        // Bit 7 clear: whatever the low bits claim, the device runs on battery.
171        let info = VoltageBatteryInfo::from_wire(&payload(3700, 0x1b));
172        assert_eq!(info.status, VoltageChargingStatus::Discharging);
173    }
174}