umsh_ulcp/
battery.rs

1//! Battery status snapshot codec (`PROP_BATTERY`).
2//!
3//! The property value is either **empty** — the implementation reports no
4//! battery measurements at all — or one field-flags octet followed by the
5//! present fields in fixed order: voltage (`UINT16_LE`, millivolts), level
6//! (`UINT8`, percent), charge state (PUI). Reserved flag bits must be
7//! zero, and the value length must match the flags exactly.
8
9use crate::pui;
10
11/// Field-flags bit for the voltage field.
12pub const FLAG_VOLTAGE: u8 = 1 << 0;
13/// Field-flags bit for the level field.
14pub const FLAG_LEVEL: u8 = 1 << 1;
15/// Field-flags bit for the charge-state field.
16pub const FLAG_CHARGE_STATE: u8 = 1 << 2;
17
18const FLAGS_RESERVED: u8 = !(FLAG_VOLTAGE | FLAG_LEVEL | FLAG_CHARGE_STATE);
19
20/// Largest encoded size of a snapshot: flags + voltage + level + a
21/// one-byte PUI charge state.
22pub const MAX_ENCODED_LEN: usize = 5;
23
24/// The battery charge-state enumeration.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum BatteryChargeState {
27    /// `BATTERY_CHARGE_STATE_DISCHARGING`
28    Discharging = 0,
29    /// `BATTERY_CHARGE_STATE_CHARGING`
30    Charging = 1,
31    /// `BATTERY_CHARGE_STATE_CHARGED`
32    Charged = 2,
33}
34
35impl BatteryChargeState {
36    /// The wire code for this state.
37    pub const fn code(self) -> u32 {
38        self as u32
39    }
40
41    /// Strict conversion from a decoded wire code.
42    pub const fn from_code(code: u32) -> Option<Self> {
43        match code {
44            0 => Some(Self::Discharging),
45            1 => Some(Self::Charging),
46            2 => Some(Self::Charged),
47            _ => None,
48        }
49    }
50}
51
52/// Snapshot or decode error.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum BatteryError {
55    /// Reserved flag bits set, length inconsistent with the flags, an
56    /// out-of-range level, or an unknown charge-state code.
57    Malformed,
58    /// The output buffer cannot hold the encoded snapshot.
59    BufferTooSmall,
60}
61
62/// One battery status snapshot: the fields the platform reports.
63///
64/// `None` means the field is unsupported (its flag bit is clear). A
65/// snapshot with every field `None` encodes as the empty value.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub struct BatteryStatus {
68    /// Measured voltage at the battery terminals, in millivolts.
69    pub voltage_mv: Option<u16>,
70    /// Estimated state of charge, 0–100 percent.
71    pub level_percent: Option<u8>,
72    /// Charge state reported by the charging system.
73    pub charge_state: Option<BatteryChargeState>,
74}
75
76impl BatteryStatus {
77    /// Whether no field is reported (the empty wire form).
78    pub const fn is_empty(&self) -> bool {
79        self.voltage_mv.is_none() && self.level_percent.is_none() && self.charge_state.is_none()
80    }
81
82    /// The field-flags octet for this snapshot.
83    pub fn flags(&self) -> u8 {
84        let mut flags = 0;
85        if self.voltage_mv.is_some() {
86            flags |= FLAG_VOLTAGE;
87        }
88        if self.level_percent.is_some() {
89            flags |= FLAG_LEVEL;
90        }
91        if self.charge_state.is_some() {
92            flags |= FLAG_CHARGE_STATE;
93        }
94        flags
95    }
96
97    /// Encode the snapshot, returning the number of bytes written.
98    ///
99    /// An all-`None` snapshot encodes as zero bytes (the empty form). A
100    /// level above 100 is rejected as [`BatteryError::Malformed`].
101    pub fn encode(&self, out: &mut [u8]) -> Result<usize, BatteryError> {
102        if self.is_empty() {
103            return Ok(0);
104        }
105        let mut len = 0;
106        let mut push = |byte: u8| -> Result<(), BatteryError> {
107            *out.get_mut(len).ok_or(BatteryError::BufferTooSmall)? = byte;
108            len += 1;
109            Ok(())
110        };
111        push(self.flags())?;
112        if let Some(mv) = self.voltage_mv {
113            let [low, high] = mv.to_le_bytes();
114            push(low)?;
115            push(high)?;
116        }
117        if let Some(percent) = self.level_percent {
118            if percent > 100 {
119                return Err(BatteryError::Malformed);
120            }
121            push(percent)?;
122        }
123        if let Some(state) = self.charge_state {
124            let mut pui_buf = [0u8; pui::MAX_LEN];
125            let pui_len = pui::encode(state.code(), &mut pui_buf)
126                .map_err(|_| BatteryError::BufferTooSmall)?;
127            for &byte in &pui_buf[..pui_len] {
128                push(byte)?;
129            }
130        }
131        Ok(len)
132    }
133
134    /// Strictly decode a property value.
135    ///
136    /// The empty value decodes to an all-`None` snapshot. Any other value
137    /// must consist of exactly the flags octet and the fields it declares;
138    /// a zero flags octet, reserved bits, trailing bytes, a level above
139    /// 100, and unknown charge-state codes are all malformed.
140    pub fn decode(value: &[u8]) -> Result<Self, BatteryError> {
141        let Some((&flags, mut rest)) = value.split_first() else {
142            return Ok(Self::default());
143        };
144        if flags == 0 || flags & FLAGS_RESERVED != 0 {
145            return Err(BatteryError::Malformed);
146        }
147        let mut take = |count: usize| -> Result<&[u8], BatteryError> {
148            if rest.len() < count {
149                return Err(BatteryError::Malformed);
150            }
151            let (field, remaining) = rest.split_at(count);
152            rest = remaining;
153            Ok(field)
154        };
155        let voltage_mv = if flags & FLAG_VOLTAGE != 0 {
156            let field = take(2)?;
157            Some(u16::from_le_bytes([field[0], field[1]]))
158        } else {
159            None
160        };
161        let level_percent = if flags & FLAG_LEVEL != 0 {
162            let percent = take(1)?[0];
163            if percent > 100 {
164                return Err(BatteryError::Malformed);
165            }
166            Some(percent)
167        } else {
168            None
169        };
170        let charge_state = if flags & FLAG_CHARGE_STATE != 0 {
171            let (code, consumed) = pui::decode(rest).map_err(|_| BatteryError::Malformed)?;
172            rest = &rest[consumed..];
173            Some(BatteryChargeState::from_code(code).ok_or(BatteryError::Malformed)?)
174        } else {
175            None
176        };
177        if !rest.is_empty() {
178            return Err(BatteryError::Malformed);
179        }
180        Ok(Self {
181            voltage_mv,
182            level_percent,
183            charge_state,
184        })
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[track_caller]
193    fn round_trip(status: BatteryStatus, expected: &[u8]) {
194        let mut buf = [0u8; MAX_ENCODED_LEN];
195        let len = status.encode(&mut buf).unwrap();
196        assert_eq!(&buf[..len], expected, "encoding of {status:?}");
197        assert_eq!(BatteryStatus::decode(expected).unwrap(), status);
198    }
199
200    #[test]
201    fn every_field_combination_round_trips() {
202        round_trip(BatteryStatus::default(), &[]);
203        round_trip(
204            BatteryStatus {
205                voltage_mv: Some(3987),
206                ..Default::default()
207            },
208            &[0b001, 0x93, 0x0F],
209        );
210        round_trip(
211            BatteryStatus {
212                level_percent: Some(100),
213                ..Default::default()
214            },
215            &[0b010, 100],
216        );
217        round_trip(
218            BatteryStatus {
219                charge_state: Some(BatteryChargeState::Charged),
220                ..Default::default()
221            },
222            &[0b100, 2],
223        );
224        round_trip(
225            BatteryStatus {
226                voltage_mv: Some(4200),
227                level_percent: Some(87),
228                ..Default::default()
229            },
230            &[0b011, 0x68, 0x10, 87],
231        );
232        round_trip(
233            BatteryStatus {
234                voltage_mv: Some(3700),
235                charge_state: Some(BatteryChargeState::Discharging),
236                ..Default::default()
237            },
238            &[0b101, 0x74, 0x0E, 0],
239        );
240        round_trip(
241            BatteryStatus {
242                level_percent: Some(0),
243                charge_state: Some(BatteryChargeState::Charging),
244                ..Default::default()
245            },
246            &[0b110, 0, 1],
247        );
248        round_trip(
249            BatteryStatus {
250                voltage_mv: Some(4180),
251                level_percent: Some(99),
252                charge_state: Some(BatteryChargeState::Charging),
253            },
254            &[0b111, 0x54, 0x10, 99, 1],
255        );
256    }
257
258    #[test]
259    fn rejects_malformed_values() {
260        // A zero flags octet: the empty form is the only no-field encoding.
261        assert_eq!(BatteryStatus::decode(&[0]), Err(BatteryError::Malformed));
262        // Reserved flag bits.
263        assert_eq!(
264            BatteryStatus::decode(&[0b1000, 1]),
265            Err(BatteryError::Malformed)
266        );
267        // Length shorter than the flags declare.
268        assert_eq!(
269            BatteryStatus::decode(&[0b001, 0x93]),
270            Err(BatteryError::Malformed)
271        );
272        // Trailing bytes beyond the declared fields.
273        assert_eq!(
274            BatteryStatus::decode(&[0b010, 50, 0]),
275            Err(BatteryError::Malformed)
276        );
277        // Level above 100.
278        assert_eq!(
279            BatteryStatus::decode(&[0b010, 101]),
280            Err(BatteryError::Malformed)
281        );
282        // Unknown charge-state code.
283        assert_eq!(
284            BatteryStatus::decode(&[0b100, 3]),
285            Err(BatteryError::Malformed)
286        );
287        // Truncated charge-state PUI.
288        assert_eq!(
289            BatteryStatus::decode(&[0b100, 0x80]),
290            Err(BatteryError::Malformed)
291        );
292    }
293
294    #[test]
295    fn encode_rejects_out_of_range_level() {
296        let status = BatteryStatus {
297            level_percent: Some(101),
298            ..Default::default()
299        };
300        let mut buf = [0u8; MAX_ENCODED_LEN];
301        assert_eq!(status.encode(&mut buf), Err(BatteryError::Malformed));
302    }
303
304    #[test]
305    fn encode_reports_short_buffers() {
306        let status = BatteryStatus {
307            voltage_mv: Some(4000),
308            ..Default::default()
309        };
310        let mut buf = [0u8; 2];
311        assert_eq!(status.encode(&mut buf), Err(BatteryError::BufferTooSmall));
312    }
313
314    #[test]
315    fn charge_state_codes_are_strict() {
316        assert_eq!(
317            BatteryChargeState::from_code(0),
318            Some(BatteryChargeState::Discharging)
319        );
320        assert_eq!(
321            BatteryChargeState::from_code(1),
322            Some(BatteryChargeState::Charging)
323        );
324        assert_eq!(
325            BatteryChargeState::from_code(2),
326            Some(BatteryChargeState::Charged)
327        );
328        assert_eq!(BatteryChargeState::from_code(3), None);
329    }
330}