logiops_core/features/
battery.rs

1//! Battery features (0x1000 Unified Battery, 0x1001 Battery Status).
2//!
3//! These features report battery level and charging status. Most modern
4//! Logitech devices use Unified Battery (0x1000), while older devices
5//! use Battery Status (0x1001).
6
7use hidpp_transport::HidapiChannel;
8use tracing::{debug, trace};
9
10use crate::error::{HidppErrorCode, ProtocolError, Result};
11use crate::protocol::{build_long_request, get_error_code, is_error_response};
12
13/// Unified Battery feature (0x1000) implementation.
14pub struct UnifiedBatteryFeature {
15    device_index: u8,
16    feature_index: u8,
17}
18
19/// Battery Status feature (0x1001) implementation.
20pub struct BatteryStatusFeature {
21    device_index: u8,
22    feature_index: u8,
23}
24
25/// Battery level and status information.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct BatteryInfo {
28    /// Battery level as percentage (0-100).
29    pub level: u8,
30    /// Charging status.
31    pub status: ChargingStatus,
32    /// Battery voltage in millivolts (if available).
33    pub voltage_mv: Option<u16>,
34}
35
36/// Battery level buckets (for devices that don't report exact percentage).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BatteryLevel {
39    /// Full (> 80%).
40    Full,
41    /// Good (50-80%).
42    Good,
43    /// Low (20-50%).
44    Low,
45    /// Critical (< 20%).
46    Critical,
47    /// Empty/dead.
48    Empty,
49    /// Unknown level.
50    Unknown,
51}
52
53impl BatteryLevel {
54    /// Converts from byte value.
55    #[must_use]
56    pub fn from_byte(value: u8) -> Self {
57        match value {
58            0 => Self::Empty,
59            1 => Self::Critical,
60            2 => Self::Low,
61            3 => Self::Good,
62            4..=8 => Self::Full,
63            _ => Self::Unknown,
64        }
65    }
66
67    /// Returns an approximate percentage for this level.
68    #[must_use]
69    pub fn approximate_percent(&self) -> u8 {
70        match self {
71            Self::Full => 90,
72            Self::Good => 65,
73            Self::Low => 35,
74            Self::Critical => 10,
75            Self::Empty => 0,
76            Self::Unknown => 50,
77        }
78    }
79}
80
81impl std::fmt::Display for BatteryLevel {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::Full => write!(f, "Full"),
85            Self::Good => write!(f, "Good"),
86            Self::Low => write!(f, "Low"),
87            Self::Critical => write!(f, "Critical"),
88            Self::Empty => write!(f, "Empty"),
89            Self::Unknown => write!(f, "Unknown"),
90        }
91    }
92}
93
94/// Battery charging status.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum ChargingStatus {
97    /// Battery is discharging (normal use).
98    #[default]
99    Discharging,
100    /// Battery is charging.
101    Charging,
102    /// Battery is fully charged (and charging complete).
103    Full,
104    /// Charging error or slow charging.
105    ChargingError,
106    /// Wireless charging in progress.
107    WirelessCharging,
108}
109
110impl ChargingStatus {
111    /// Converts from byte value (Unified Battery format).
112    #[must_use]
113    pub fn from_byte(value: u8) -> Self {
114        match value {
115            1 => Self::Charging,
116            2 => Self::Full,
117            3 => Self::ChargingError,
118            4 => Self::WirelessCharging,
119            _ => Self::Discharging,
120        }
121    }
122
123    /// Returns true if the device is currently charging.
124    #[must_use]
125    pub fn is_charging(&self) -> bool {
126        matches!(self, Self::Charging | Self::WirelessCharging)
127    }
128}
129
130impl std::fmt::Display for ChargingStatus {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        match self {
133            Self::Discharging => write!(f, "Discharging"),
134            Self::Charging => write!(f, "Charging"),
135            Self::Full => write!(f, "Fully Charged"),
136            Self::ChargingError => write!(f, "Charging Error"),
137            Self::WirelessCharging => write!(f, "Wireless Charging"),
138        }
139    }
140}
141
142impl UnifiedBatteryFeature {
143    /// Creates a new Unified Battery feature accessor.
144    ///
145    /// # Arguments
146    /// * `device_index` - Device index (0xFF for direct)
147    /// * `feature_index` - Feature index from root feature discovery
148    #[must_use]
149    pub fn new(device_index: u8, feature_index: u8) -> Self {
150        Self {
151            device_index,
152            feature_index,
153        }
154    }
155
156    /// Gets the battery capabilities.
157    ///
158    /// # Errors
159    /// Returns an error if HID++ communication fails.
160    pub async fn get_capabilities(&self, channel: &HidapiChannel) -> Result<u8> {
161        // getCapabilities: function_id=0
162        let request = build_long_request(self.device_index, self.feature_index, 0x00, &[]);
163
164        trace!("getting battery capabilities");
165        let response = channel.request(&request, 5).await?;
166
167        if is_error_response(&response) {
168            let code = get_error_code(&response).unwrap_or(0);
169            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
170        }
171
172        if response.len() < 5 {
173            return Err(ProtocolError::InvalidResponse(
174                "battery capabilities response too short".to_string(),
175            ));
176        }
177
178        let capabilities = response[4];
179        debug!(capabilities, "got battery capabilities");
180        Ok(capabilities)
181    }
182
183    /// Gets the current battery status.
184    ///
185    /// # Errors
186    /// Returns an error if HID++ communication fails.
187    pub async fn get_status(&self, channel: &HidapiChannel) -> Result<BatteryInfo> {
188        // getStatus: function_id=1
189        let request = build_long_request(self.device_index, self.feature_index, 0x01, &[]);
190
191        trace!("getting battery status");
192        let response = channel.request(&request, 5).await?;
193
194        if is_error_response(&response) {
195            let code = get_error_code(&response).unwrap_or(0);
196            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
197        }
198
199        if response.len() < 7 {
200            return Err(ProtocolError::InvalidResponse(
201                "battery status response too short".to_string(),
202            ));
203        }
204
205        // Response: [report_id, device_idx, feature_idx, func_sw_id, state_of_charge, battery_level, charging_status, ...]
206        let level = response[4];
207        let battery_level = BatteryLevel::from_byte(response[5]);
208        let status = ChargingStatus::from_byte(response[6]);
209
210        // Use the percentage if provided, otherwise estimate from level
211        let percent = if level > 0 && level <= 100 {
212            level
213        } else {
214            battery_level.approximate_percent()
215        };
216
217        let info = BatteryInfo {
218            level: percent,
219            status,
220            voltage_mv: None,
221        };
222
223        debug!(
224            level = info.level,
225            status = %info.status,
226            "got battery status"
227        );
228
229        Ok(info)
230    }
231}
232
233impl BatteryStatusFeature {
234    /// Creates a new Battery Status feature accessor.
235    ///
236    /// # Arguments
237    /// * `device_index` - Device index (0xFF for direct)
238    /// * `feature_index` - Feature index from root feature discovery
239    #[must_use]
240    pub fn new(device_index: u8, feature_index: u8) -> Self {
241        Self {
242            device_index,
243            feature_index,
244        }
245    }
246
247    /// Gets the current battery level.
248    ///
249    /// # Errors
250    /// Returns an error if HID++ communication fails.
251    pub async fn get_battery_level(&self, channel: &HidapiChannel) -> Result<BatteryInfo> {
252        // getBatteryLevelStatus: function_id=0
253        let request = build_long_request(self.device_index, self.feature_index, 0x00, &[]);
254
255        trace!("getting battery level");
256        let response = channel.request(&request, 5).await?;
257
258        if is_error_response(&response) {
259            let code = get_error_code(&response).unwrap_or(0);
260            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
261        }
262
263        if response.len() < 7 {
264            return Err(ProtocolError::InvalidResponse(
265                "battery level response too short".to_string(),
266            ));
267        }
268
269        // Response: [report_id, device_idx, feature_idx, func_sw_id, battery_discharge_level, battery_discharge_next_level, battery_status]
270        let discharge_level = response[4];
271        let next_level = response[5];
272        let battery_status = response[6];
273
274        // Interpret battery status for charging info
275        let status = match battery_status {
276            0x20..=0x3F => ChargingStatus::Charging,
277            0x80..=0x8F => ChargingStatus::Full,
278            _ => ChargingStatus::Discharging,
279        };
280
281        // Convert discharge level to percentage
282        // discharge_level is 0-7, where 0 = empty, 7 = full
283        let percent = if discharge_level <= 7 {
284            (u16::from(discharge_level) * 100 / 7).min(100) as u8
285        } else {
286            // Some devices report percentage directly
287            discharge_level.min(100)
288        };
289
290        let info = BatteryInfo {
291            level: percent,
292            status,
293            voltage_mv: None,
294        };
295
296        debug!(
297            level = info.level,
298            status = %info.status,
299            next_level,
300            "got battery level"
301        );
302
303        Ok(info)
304    }
305
306    /// Gets the battery voltage.
307    ///
308    /// # Errors
309    /// Returns an error if HID++ communication fails or the feature doesn't support voltage reporting.
310    pub async fn get_battery_voltage(&self, channel: &HidapiChannel) -> Result<u16> {
311        // getBatteryVoltage: function_id=1 (may not be supported on all devices)
312        let request = build_long_request(self.device_index, self.feature_index, 0x01, &[]);
313
314        trace!("getting battery voltage");
315        let response = channel.request(&request, 5).await?;
316
317        if is_error_response(&response) {
318            let code = get_error_code(&response).unwrap_or(0);
319            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
320        }
321
322        if response.len() < 6 {
323            return Err(ProtocolError::InvalidResponse(
324                "battery voltage response too short".to_string(),
325            ));
326        }
327
328        let voltage = u16::from_be_bytes([response[4], response[5]]);
329        debug!(voltage_mv = voltage, "got battery voltage");
330
331        Ok(voltage)
332    }
333}
334
335/// Unified interface for battery access regardless of feature version.
336pub enum BatteryFeature {
337    /// Unified Battery (0x1000) - modern devices.
338    Unified(UnifiedBatteryFeature),
339    /// Battery Status (0x1001) - older devices.
340    Legacy(BatteryStatusFeature),
341}
342
343impl BatteryFeature {
344    /// Gets the current battery status using the appropriate feature.
345    ///
346    /// # Errors
347    /// Returns an error if HID++ communication fails.
348    pub async fn get_battery(&self, channel: &HidapiChannel) -> Result<BatteryInfo> {
349        match self {
350            Self::Unified(f) => f.get_status(channel).await,
351            Self::Legacy(f) => f.get_battery_level(channel).await,
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_battery_level() {
362        assert_eq!(BatteryLevel::from_byte(0), BatteryLevel::Empty);
363        assert_eq!(BatteryLevel::from_byte(1), BatteryLevel::Critical);
364        assert_eq!(BatteryLevel::from_byte(4), BatteryLevel::Full);
365        assert_eq!(BatteryLevel::from_byte(255), BatteryLevel::Unknown);
366    }
367
368    #[test]
369    fn test_charging_status() {
370        assert!(!ChargingStatus::Discharging.is_charging());
371        assert!(ChargingStatus::Charging.is_charging());
372        assert!(ChargingStatus::WirelessCharging.is_charging());
373        assert!(!ChargingStatus::Full.is_charging());
374    }
375}