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}