mendi 0.0.2

Rust client for the Mendi neurofeedback headband over BLE using btleplug
Documentation
//! Protobuf decoders for Mendi BLE notification payloads.
//!
//! Each characteristic sends protobuf-encoded messages as defined in
//! `device_v4.proto`. This module decodes the raw bytes into typed events.

use std::time::{SystemTime, UNIX_EPOCH};

use prost::Message;

use crate::types::{BatteryReading, CalibrationReading, DiagnosticsReading, FrameReading, SensorReading};
use crate::wire;

/// Current wall-clock time in milliseconds since Unix epoch.
fn now_ms() -> f64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock before epoch")
        .as_secs_f64()
        * 1000.0
}

/// Validate a raw BLE notification from the Frame characteristic.
///
/// An empty notification or one that fails protobuf
/// decode is considered invalid.
///
/// Note: In proto3, empty bytes `[]` decode to all-default (zero) values.
/// This function explicitly rejects empty data to match the APK behavior.
pub fn is_valid_frame(data: &[u8]) -> bool {
    if data.is_empty() {
        return false;
    }
    wire::Frame::decode(data).is_ok()
}

/// Decode a Frame characteristic (0xABB1) notification.
///
/// Returns `None` if:
/// - `data` is empty (proto3 would decode to all-zeros, but that's not a real frame)
/// - The protobuf decode fails (corrupt data)
///
/// This matches the APK's `isValidFrame` → decode pipeline. Library users
/// do **not** need to call [`is_valid_frame`] separately before this function.
pub fn parse_frame(data: &[u8]) -> Option<FrameReading> {
    if data.is_empty() {
        return None;
    }
    let frame = wire::Frame::decode(data).ok()?;
    Some(FrameReading {
        timestamp: now_ms(),
        acc_x: frame.acc_x,
        acc_y: frame.acc_y,
        acc_z: frame.acc_z,
        ang_x: frame.ang_x,
        ang_y: frame.ang_y,
        ang_z: frame.ang_z,
        temperature: frame.temp,
        ir_left: frame.ir_l,
        red_left: frame.red_l,
        amb_left: frame.amb_l,
        ir_right: frame.ir_r,
        red_right: frame.red_r,
        amb_right: frame.amb_r,
        ir_pulse: frame.ir_p,
        red_pulse: frame.red_p,
        amb_pulse: frame.amb_p,
    })
}

/// Decode an ADC / Battery characteristic (0xABB4) notification.
pub fn parse_adc(data: &[u8]) -> Option<BatteryReading> {
    let adc = wire::Adc::decode(data).ok()?;
    Some(BatteryReading {
        timestamp: now_ms(),
        voltage_mv: adc.voltage,
        charging: adc.charging,
        usb_connected: adc.usb,
    })
}

/// Decode a Calibration characteristic (0xABB6) notification.
pub fn parse_calibration(data: &[u8]) -> Option<CalibrationReading> {
    let cal = wire::Calibration::decode(data).ok()?;
    Some(CalibrationReading {
        timestamp: now_ms(),
        offset_left: cal.offset_l,
        offset_right: cal.offset_r,
        offset_pulse: cal.offset_p,
        auto_calibration: cal.enable,
        low_power_mode: cal.low_power_mode,
    })
}

/// Decode a Diagnostics characteristic (0xABB5) notification.
pub fn parse_diagnostics(data: &[u8]) -> Option<DiagnosticsReading> {
    let diag = wire::Diagnostics::decode(data).ok()?;
    let adc = diag.adc.map(|a| BatteryReading {
        timestamp: now_ms(),
        voltage_mv: a.voltage,
        charging: a.charging,
        usb_connected: a.usb,
    });
    Some(DiagnosticsReading {
        timestamp: now_ms(),
        adc,
        imu_ok: diag.imu_ok,
        sensor_ok: diag.sensor_ok,
    })
}

/// Decode a Sensor characteristic (0xABB2) notification (register read response).
pub fn parse_sensor(data: &[u8]) -> Option<SensorReading> {
    let sensor = wire::Sensor::decode(data).ok()?;
    Some(SensorReading {
        timestamp: now_ms(),
        address: sensor.address,
        data: sensor.data,
    })
}