Skip to main content

hidpp/feature/
adc_measurement.rs

1//! Implements the `AdcMeasurement` feature (ID `0x1F20`) that reports a
2//! device's battery as a measured voltage plus a link/charging flags byte.
3//!
4//! Wireless G-series headsets expose `0x1F20` and none of `0x1000` / `0x1001`
5//! / `0x1004` (verified on a G733; the Linux kernel reads a G935 the same
6//! way), so without this feature the inventory probe finds no battery source
7//! for them. Like `BatteryVoltage` (`0x1001`) the feature reports no
8//! percentage — callers estimate one from the voltage.
9//!
10//! `getAdcMeasurement` (function `0`) reads the battery. The firmware also
11//! broadcasts the same payload unsolicited as event `0` when the reading or
12//! the link changes, which
13//! [`AdcMeasurementFeature::is_status_broadcast`] recognizes.
14//!
15//! The wire layout is not in a public Logitech spec: the voltage as a
16//! big-endian millivolt `u16` followed by one flags byte was
17//! reverse-engineered. The flag values follow the Linux kernel
18//! (`hid-logitech-hidpp.c`, `hidpp20_map_adc_measurement_1f20`): bit `0` means
19//! the device is linked, bit `1` charging, bit `2` charge complete.
20
21use num_enum::TryFromPrimitive;
22use openlogi_hidpp_derive::Feature;
23
24use crate::{
25    feature::FeatureEndpoint,
26    protocol::v20::{ErrorType, Hidpp20Error},
27};
28
29/// Implements the `AdcMeasurement` / `0x1F20` feature.
30#[derive(Feature)]
31#[creatable(id = 0x1f20, version = 0)]
32pub struct AdcMeasurementFeature {
33    /// The endpoint this feature talks to.
34    endpoint: FeatureEndpoint,
35}
36
37impl AdcMeasurementFeature {
38    /// Whether an unsolicited message with `function_id` is this feature's
39    /// status broadcast (event `0`), sent when the reading or the link
40    /// changes.
41    ///
42    /// Deliberately independent of the payload: the broadcast announces a
43    /// change even when its flags value is one [`AdcMeasurement::from_wire`]
44    /// rejects (the kernel names `0x0F`), so a listener that only needs to know
45    /// *that* something changed must not drop it.
46    #[must_use]
47    pub const fn is_status_broadcast(function_id: u8) -> bool {
48        function_id == 0
49    }
50
51    /// Reads the measured battery voltage and link/charging state (function
52    /// `0`, `getAdcMeasurement`).
53    ///
54    /// A `LogitechInternal` (`0x05`) error answer is [`AdcMeasurement::Unlinked`]:
55    /// a G733 dongle whose headset is switched off answers that instead of a
56    /// clear link bit (captured from hardware), and the kernel reads a protocol
57    /// error from this call as the device being offline. Other errors stay
58    /// errors, so a `Busy` answer does not pass for a switched-off device.
59    pub async fn get_adc_measurement(&self) -> Result<AdcMeasurement, Hidpp20Error> {
60        match self.endpoint.call(0, [0; 3]).await {
61            Ok(response) => AdcMeasurement::from_wire(&response.extend_payload()),
62            Err(Hidpp20Error::Feature(ErrorType::LogitechInternal)) => Ok(AdcMeasurement::Unlinked),
63            Err(e) => Err(e),
64        }
65    }
66}
67
68/// A reading from the `0x1F20` `getAdcMeasurement` function, or its event `0`
69/// broadcast.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize))]
72#[non_exhaustive]
73pub enum AdcMeasurement {
74    /// The battery-powered end is not linked — flags bit `0` clear, or the
75    /// `LogitechInternal` error a G733 dongle answers while its headset is
76    /// off — so there is no reading.
77    Unlinked,
78    /// A live battery reading.
79    Linked {
80        /// Measured battery voltage in millivolt.
81        voltage_mv: u16,
82        /// The charging state decoded from the flags byte.
83        status: AdcChargingStatus,
84    },
85}
86
87impl AdcMeasurement {
88    /// Decodes a `getAdcMeasurement` response payload: voltage as a big-endian
89    /// millivolt `u16` in bytes `0`–`1`, the flags in byte `2`.
90    ///
91    /// # Errors
92    ///
93    /// [`Hidpp20Error::UnsupportedResponse`] for a linked flags value outside
94    /// the known set (the kernel's explicit `0x0F` included).
95    pub fn from_wire(payload: &[u8; 16]) -> Result<Self, Hidpp20Error> {
96        let flags = payload[2];
97        if flags & 0x01 == 0 {
98            return Ok(Self::Unlinked);
99        }
100        Ok(Self::Linked {
101            voltage_mv: u16::from_be_bytes([payload[0], payload[1]]),
102            status: AdcChargingStatus::try_from(flags)
103                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
104        })
105    }
106}
107
108/// Charging state carried by a linked `0x1F20` flags byte.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFromPrimitive)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111#[non_exhaustive]
112#[repr(u8)]
113pub enum AdcChargingStatus {
114    /// Running on battery.
115    Discharging = 0x01,
116    /// On external power and charging.
117    Charging = 0x03,
118    /// On external power with charge complete.
119    Full = 0x07,
120}
121
122#[cfg(test)]
123mod tests {
124    use super::{AdcChargingStatus, AdcMeasurement};
125    use crate::protocol::v20::Hidpp20Error;
126
127    /// Builds a 16-byte payload from the 3 meaningful bytes.
128    fn payload(voltage_mv: u16, flags: u8) -> [u8; 16] {
129        let mut payload = [0; 16];
130        payload[..2].copy_from_slice(&voltage_mv.to_be_bytes());
131        payload[2] = flags;
132        payload
133    }
134
135    #[test]
136    fn g733_reading_decodes_voltage_and_status() {
137        // Captured from a G733 dongle (046d:0ab5): `0f 41 01`.
138        let mut wire = [0; 16];
139        wire[..3].copy_from_slice(&[0x0f, 0x41, 0x01]);
140        assert!(matches!(
141            AdcMeasurement::from_wire(&wire),
142            Ok(AdcMeasurement::Linked {
143                voltage_mv: 3905,
144                status: AdcChargingStatus::Discharging,
145            })
146        ));
147    }
148
149    #[test]
150    fn known_flag_values_map_to_charging_states() {
151        for (flags, status) in [
152            (0x01, AdcChargingStatus::Discharging),
153            (0x03, AdcChargingStatus::Charging),
154            (0x07, AdcChargingStatus::Full),
155        ] {
156            let decoded = AdcMeasurement::from_wire(&payload(4000, flags));
157            assert!(
158                matches!(
159                    decoded,
160                    Ok(AdcMeasurement::Linked { voltage_mv: 4000, status: s }) if s == status
161                ),
162                "flags {flags:#04x} decoded as {decoded:?}"
163            );
164        }
165    }
166
167    #[test]
168    fn clear_link_bit_is_unlinked_whatever_else_is_set() {
169        assert!(matches!(
170            AdcMeasurement::from_wire(&payload(0, 0x00)),
171            Ok(AdcMeasurement::Unlinked)
172        ));
173        assert!(matches!(
174            AdcMeasurement::from_wire(&payload(3900, 0x06)),
175            Ok(AdcMeasurement::Unlinked)
176        ));
177    }
178
179    #[test]
180    fn unknown_linked_flags_are_an_error() {
181        for flags in [0x05, 0x0f, 0x81] {
182            let decoded = AdcMeasurement::from_wire(&payload(3900, flags));
183            assert!(
184                matches!(decoded, Err(Hidpp20Error::UnsupportedResponse)),
185                "flags {flags:#04x} decoded as {decoded:?}"
186            );
187        }
188    }
189}