mendi 0.0.2

Rust client for the Mendi neurofeedback headband over BLE using btleplug
Documentation
//! Data types for events emitted by the Mendi BLE client.
//!
//! These mirror the protobuf wire types but add timestamps and are
//! decoupled from the wire format for downstream ergonomics.

use std::fmt;

/// Minimum battery voltage in mV (maps to 0%).
pub const BATTERY_VOLTAGE_MIN_MV: u32 = 3600;
/// Maximum battery voltage in mV (maps to 100%).
pub const BATTERY_VOLTAGE_MAX_MV: u32 = 4100;

/// Default accelerometer scale: ±2G full-scale, 16-bit ADC.
/// `g = raw * ACCEL_SCALE`
pub const ACCEL_SCALE: f32 = 2.0 / 32768.0;

/// Default gyroscope scale: ±125°/s full-scale, 16-bit ADC.
/// `dps = raw * GYRO_SCALE`
pub const GYRO_SCALE: f32 = 125.0 / 32768.0;

/// A single fNIRS sensor frame from the headband.
///
/// Streamed in real-time from the Frame characteristic (0xABB1).
/// Contains IMU data, temperature, and optical readings from three channels.
#[derive(Debug, Clone, PartialEq)]
pub struct FrameReading {
    /// Wall-clock timestamp in milliseconds since Unix epoch.
    pub timestamp: f64,

    // ── IMU ──────────────────────────────────────────────────────────────
    /// Accelerometer X-axis (raw int16, ±2G default).
    pub acc_x: i32,
    /// Accelerometer Y-axis.
    pub acc_y: i32,
    /// Accelerometer Z-axis.
    pub acc_z: i32,
    /// Gyroscope X-axis (raw int16, ±125°/s default).
    pub ang_x: i32,
    /// Gyroscope Y-axis.
    pub ang_y: i32,
    /// Gyroscope Z-axis.
    pub ang_z: i32,

    // ── Temperature ──────────────────────────────────────────────────────
    /// Temperature in degrees Celsius.
    pub temperature: f32,

    // ── Optical: Left channel ────────────────────────────────────────────
    /// Infrared reading, left channel.
    pub ir_left: i32,
    /// Red LED reading, left channel.
    pub red_left: i32,
    /// Ambient light reading, left channel.
    pub amb_left: i32,

    // ── Optical: Right channel ───────────────────────────────────────────
    /// Infrared reading, right channel.
    pub ir_right: i32,
    /// Red LED reading, right channel.
    pub red_right: i32,
    /// Ambient light reading, right channel.
    pub amb_right: i32,

    // ── Optical: Pulse channel ───────────────────────────────────────────
    /// Infrared reading, pulse channel.
    pub ir_pulse: i32,
    /// Red LED reading, pulse channel.
    pub red_pulse: i32,
    /// Ambient light reading, pulse channel.
    pub amb_pulse: i32,
}

impl FrameReading {
    /// Accelerometer X in g (±2G default scale).
    pub fn accel_x_g(&self) -> f32 {
        self.acc_x as f32 * ACCEL_SCALE
    }
    /// Accelerometer Y in g.
    pub fn accel_y_g(&self) -> f32 {
        self.acc_y as f32 * ACCEL_SCALE
    }
    /// Accelerometer Z in g.
    pub fn accel_z_g(&self) -> f32 {
        self.acc_z as f32 * ACCEL_SCALE
    }
    /// Gyroscope X in degrees per second (±125°/s default scale).
    pub fn gyro_x_dps(&self) -> f32 {
        self.ang_x as f32 * GYRO_SCALE
    }
    /// Gyroscope Y in °/s.
    pub fn gyro_y_dps(&self) -> f32 {
        self.ang_y as f32 * GYRO_SCALE
    }
    /// Gyroscope Z in °/s.
    pub fn gyro_z_dps(&self) -> f32 {
        self.ang_z as f32 * GYRO_SCALE
    }
}

/// Battery and power status from the ADC characteristic (0xABB4).
#[derive(Debug, Clone, PartialEq)]
pub struct BatteryReading {
    /// Wall-clock timestamp in milliseconds since Unix epoch.
    pub timestamp: f64,
    /// Battery/power voltage in millivolts (e.g. 3510 = 3.51V).
    pub voltage_mv: u32,
    /// `true` when the battery charger circuit is actively charging.
    pub charging: bool,
    /// `true` when a USB cable is connected.
    pub usb_connected: bool,
}

impl BatteryReading {
    /// Voltage in volts.
    pub fn voltage(&self) -> f32 {
        self.voltage_mv as f32 / 1000.0
    }

    /// Estimated battery percentage (0–100).
    ///
    /// Linearly maps voltage from 3600 mV (0%) to 4100 mV (100%),
    /// matching the Mendi app's `calculatePercentFromVolt` logic.
    pub fn percentage(&self) -> u8 {
        if self.voltage_mv <= BATTERY_VOLTAGE_MIN_MV {
            return 0;
        }
        if self.voltage_mv >= BATTERY_VOLTAGE_MAX_MV {
            return 100;
        }
        let range = (BATTERY_VOLTAGE_MAX_MV - BATTERY_VOLTAGE_MIN_MV) as f32;
        let pct = (self.voltage_mv - BATTERY_VOLTAGE_MIN_MV) as f32 / range * 100.0;
        pct.round() as u8
    }
}

/// Calibration data from the Calibration characteristic (0xABB6).
#[derive(Debug, Clone, PartialEq)]
pub struct CalibrationReading {
    /// Wall-clock timestamp in milliseconds since Unix epoch.
    pub timestamp: f64,
    /// Left channel LED current offset in mA (-127 to 127).
    pub offset_left: f32,
    /// Right channel LED current offset in mA (-127 to 127).
    pub offset_right: f32,
    /// Pulse channel LED current offset in mA (-127 to 127).
    pub offset_pulse: f32,
    /// Whether automatic calibration is enabled.
    pub auto_calibration: bool,
    /// Whether low-power mode is active.
    pub low_power_mode: bool,
}

/// Diagnostics self-test results from the Diagnostics characteristic (0xABB5).
#[derive(Debug, Clone, PartialEq)]
pub struct DiagnosticsReading {
    /// Wall-clock timestamp in milliseconds since Unix epoch.
    pub timestamp: f64,
    /// ADC snapshot taken at power-on.
    pub adc: Option<BatteryReading>,
    /// `true` if the IMU self-test passed.
    pub imu_ok: bool,
    /// `true` if the optical sensor self-test passed.
    pub sensor_ok: bool,
}

/// A sensor register read response from the Sensor characteristic (0xABB2).
#[derive(Debug, Clone, PartialEq)]
pub struct SensorReading {
    /// Wall-clock timestamp in milliseconds since Unix epoch.
    pub timestamp: f64,
    /// Register address that was read.
    pub address: u32,
    /// Register data (24-bit value in lowest bytes).
    pub data: u32,
}

/// FCC ID for the Mendi headband.
pub const MENDI_FCC_ID: &str = "RYYEYSHJN";

/// Device information read from standard BLE characteristics.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceInfo {
    /// Firmware revision string (from 0x2A26), if available.
    pub firmware_version: Option<String>,
    /// Hardware revision string (from 0x2A27), if available.
    pub hardware_version: Option<String>,
    /// BLE MAC address or platform identifier.
    pub id: String,
    /// Advertised device name (e.g. "Mendi").
    pub name: String,
    /// FCC ID. The official Mendi app hardcodes this as [`MENDI_FCC_ID`]
    /// (`"RYYEYSHJN"`). Populated automatically at connection time.
    pub fcc_id: String,
}

impl fmt::Display for DeviceInfo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} [{}]", self.name, self.id)?;
        if let Some(fw) = &self.firmware_version {
            write!(f, " fw={fw}")?;
        }
        if let Some(hw) = &self.hardware_version {
            write!(f, " hw={hw}")?;
        }
        if !self.fcc_id.is_empty() {
            write!(f, " fcc={}", self.fcc_id)?;
        }
        Ok(())
    }
}

/// All events emitted by [`crate::mendi_client::MendiClient`].
///
/// Consumers receive these through the `mpsc::Receiver` returned by
/// [`crate::mendi_client::MendiClient::connect`].
#[derive(Debug, Clone, PartialEq)]
pub enum MendiEvent {
    /// BLE connection established and GATT services discovered.
    Connected(DeviceInfo),

    /// A real-time fNIRS sensor frame.
    Frame(FrameReading),

    /// Battery / power status update.
    Battery(BatteryReading),

    /// Calibration data update.
    Calibration(CalibrationReading),

    /// Diagnostics self-test results (typically once at connection).
    Diagnostics(DiagnosticsReading),

    /// A sensor register read response.
    SensorRead(SensorReading),

    /// The BLE connection was lost.
    Disconnected,
}