Skip to main content

hermes_ble/
parse.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Eugene Hauptmann, Frédéric Simard
3
4//! Binary decoders for Hermes V1 BLE notification payloads.
5//!
6//! All public functions in this module are pure (no I/O, no allocation beyond
7//! the returned collections) and are safe to call from any async or sync context.
8
9use crate::protocol::{
10    ads1299_to_microvolts, ADS1299_GAIN, ADS1299_VREF, ACC_SENSITIVITY, EEG_FRAME_BYTES,
11    EEG_NUM_CHANNELS, GYRO_SENSITIVITY, MAG_SENSITIVITY,
12};
13use crate::types::{EegSample, MotionData, XyzSample};
14
15// ── EEG ──────────────────────────────────────────────────────────────────────
16
17/// Decode a signed 24-bit big-endian integer from 3 bytes.
18///
19/// The ADS1299 encodes each channel sample as a 24-bit two's-complement
20/// big-endian integer.
21///
22/// # Example
23///
24/// ```
25/// # use hermes_ble::parse::decode_i24_be;
26/// assert_eq!(decode_i24_be(&[0x00, 0x03, 0xE8]), 1000);
27/// assert_eq!(decode_i24_be(&[0xFF, 0xFF, 0xFF]), -1);
28/// ```
29pub fn decode_i24_be(bytes: &[u8]) -> i32 {
30    debug_assert!(bytes.len() >= 3);
31    let unsigned = ((bytes[0] as u32) << 16) | ((bytes[1] as u32) << 8) | (bytes[2] as u32);
32    // Sign-extend from 24 bits to 32 bits.
33    if unsigned & 0x80_0000 != 0 {
34        (unsigned | 0xFF00_0000) as i32
35    } else {
36        unsigned as i32
37    }
38}
39
40/// Parse one EEG BLE notification into a vector of [`EegSample`]s.
41///
42/// # Wire format
43///
44/// ```text
45/// byte[0]        packet index (0–127)
46/// bytes[1..]     N sample frames, each 24 bytes (8 channels × 3 bytes)
47/// ```
48///
49/// Trailing bytes that do not form a complete 24-byte frame are silently
50/// ignored (a warning is logged).
51///
52/// `timestamp` is the wall-clock time in milliseconds since epoch for the
53/// notification arrival; individual sample timestamps are not available from
54/// the device, so all samples in one packet share the same timestamp.
55///
56/// Returns an empty `Vec` if the payload is too short to contain even one
57/// complete sample frame.
58pub fn parse_eeg_packet(data: &[u8], timestamp: f64) -> Vec<EegSample> {
59    if data.len() < 1 + EEG_FRAME_BYTES {
60        return vec![];
61    }
62
63    let packet_index = data[0];
64    let payload = &data[1..];
65    let n_complete = payload.len() / EEG_FRAME_BYTES;
66
67    if payload.len() % EEG_FRAME_BYTES != 0 {
68        log::warn!(
69            "EEG packet {packet_index}: {} bytes payload is not a multiple of {EEG_FRAME_BYTES}; \
70             dropping {} trailing byte(s)",
71            payload.len(),
72            payload.len() % EEG_FRAME_BYTES,
73        );
74    }
75
76    (0..n_complete)
77        .map(|sample_idx| {
78            let offset = sample_idx * EEG_FRAME_BYTES;
79            let mut channels = [0.0_f64; EEG_NUM_CHANNELS];
80            for ch in 0..EEG_NUM_CHANNELS {
81                let raw = decode_i24_be(&payload[offset + ch * 3..]);
82                channels[ch] = ads1299_to_microvolts(raw, ADS1299_GAIN, ADS1299_VREF);
83            }
84            EegSample {
85                packet_index,
86                sample_index: sample_idx,
87                timestamp,
88                channels,
89            }
90        })
91        .collect()
92}
93
94/// Detect missing packets between `last` and `current` in the 0–127 ring.
95///
96/// Returns a list of the missing packet indices.  Returns an empty list when
97/// `last` is `None` (first packet) or `current` is the expected successor.
98///
99/// # Example
100///
101/// ```
102/// # use hermes_ble::parse::detect_missing_packets;
103/// assert!(detect_missing_packets(None, 5).is_empty());
104/// assert!(detect_missing_packets(Some(5), 6).is_empty());
105/// assert_eq!(detect_missing_packets(Some(5), 8), vec![6, 7]);
106/// assert_eq!(detect_missing_packets(Some(126), 1), vec![127, 0]);
107/// ```
108pub fn detect_missing_packets(last: Option<u8>, current: u8) -> Vec<u8> {
109    let Some(last) = last else {
110        return vec![];
111    };
112    let expected = (last.wrapping_add(1)) % 128;
113    if expected == current {
114        return vec![];
115    }
116    let mut missing = Vec::new();
117    let mut idx = expected;
118    while idx != current {
119        missing.push(idx);
120        idx = (idx.wrapping_add(1)) % 128;
121    }
122    missing
123}
124
125// ── Motion ────────────────────────────────────────────────────────────────────
126
127/// Parse a 9-DOF motion notification into a [`MotionData`].
128///
129/// Wire format: 18 bytes = 9 × `i16` little-endian:
130/// `[ax, ay, az, gx, gy, gz, mx, my, mz]`
131///
132/// Returns `None` if the payload is shorter than 18 bytes.
133pub fn parse_motion(data: &[u8], timestamp: f64) -> Option<MotionData> {
134    if data.len() < 18 {
135        return None;
136    }
137    let i16_at = |off: usize| -> i16 { i16::from_le_bytes([data[off], data[off + 1]]) };
138
139    let ax = i16_at(0) as f32 * ACC_SENSITIVITY;
140    let ay = i16_at(2) as f32 * ACC_SENSITIVITY;
141    let az = i16_at(4) as f32 * ACC_SENSITIVITY;
142
143    let gx = i16_at(6) as f32 * GYRO_SENSITIVITY;
144    let gy = i16_at(8) as f32 * GYRO_SENSITIVITY;
145    let gz = i16_at(10) as f32 * GYRO_SENSITIVITY;
146
147    let mx = i16_at(12) as f32 * MAG_SENSITIVITY;
148    let my = i16_at(14) as f32 * MAG_SENSITIVITY;
149    let mz = i16_at(16) as f32 * MAG_SENSITIVITY;
150
151    Some(MotionData {
152        timestamp,
153        accel: XyzSample {
154            x: ax,
155            y: ay,
156            z: az,
157        },
158        gyro: XyzSample {
159            x: gx,
160            y: gy,
161            z: gz,
162        },
163        mag: XyzSample {
164            x: mx,
165            y: my,
166            z: mz,
167        },
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn decode_i24_be_positive() {
177        assert_eq!(decode_i24_be(&[0x00, 0x03, 0xE8]), 1000);
178    }
179
180    #[test]
181    fn decode_i24_be_negative() {
182        assert_eq!(decode_i24_be(&[0xFF, 0xFF, 0xFF]), -1);
183        assert_eq!(decode_i24_be(&[0xFF, 0xFC, 0x18]), -1000);
184    }
185
186    #[test]
187    fn detect_missing_none() {
188        assert!(detect_missing_packets(None, 0).is_empty());
189    }
190
191    #[test]
192    fn detect_missing_sequential() {
193        assert!(detect_missing_packets(Some(0), 1).is_empty());
194        assert!(detect_missing_packets(Some(127), 0).is_empty());
195    }
196
197    #[test]
198    fn detect_missing_gap() {
199        assert_eq!(detect_missing_packets(Some(5), 8), vec![6, 7]);
200    }
201
202    #[test]
203    fn detect_missing_wrap() {
204        assert_eq!(detect_missing_packets(Some(126), 1), vec![127, 0]);
205    }
206
207    #[test]
208    fn ads1299_scale() {
209        let uv = ads1299_to_microvolts(1, 12.0, 4.5);
210        // LSB = (2 * 4.5 * 1e6) / (12 * 2^24) ≈ 0.04470 µV
211        assert!((uv - 0.04470).abs() < 0.001);
212    }
213}