# mendi
Async Rust library and CLI for streaming fNIRS neurofeedback data from
[Mendi](https://www.mendi.io/) headbands over Bluetooth Low Energy.
## Hardware
The Mendi headband is a consumer fNIRS (functional near-infrared spectroscopy)
device that measures blood oxygenation in the prefrontal cortex using infrared
and red LEDs. It communicates over BLE using protobuf-encoded messages on six
GATT characteristics:
| 0xABB1 | Frame | IMU (accel + gyro) + temperature + 3 optical channels |
| 0xABB2 | Sensor | Optical sensor register read/write |
| 0xABB3 | IMU | IMU register read/write |
| 0xABB4 | ADC | Battery voltage, charging status, USB status |
| 0xABB5 | Diagnostics | Self-test results (IMU ok, sensor ok) |
| 0xABB6 | Calibration | LED current offsets, auto-calibration, low-power mode |
Each optical channel (left, right, pulse) provides three readings:
- **IR** (infrared) — penetrates tissue to measure oxygenated hemoglobin
- **Red** — measures deoxygenated hemoglobin
- **Ambient** — background light for subtraction
## Quick start
```rust
use mendi::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let client = MendiClient::new(MendiClientConfig::default());
let (mut rx, handle) = client.connect().await?;
while let Some(event) = rx.recv().await {
match event {
MendiEvent::Frame(f) => {
println!("temp={:.1}°C IR: L={} R={} P={}",
f.temperature, f.ir_left, f.ir_right, f.ir_pulse);
}
MendiEvent::Battery(b) => {
println!("Battery: {:.2}V charging={}", b.voltage(), b.charging);
}
MendiEvent::Disconnected => break,
_ => {}
}
}
Ok(())
}
```
## CLI usage
```bash
# Run the CLI (scans, connects, and prints all data)
cargo run
# With debug logging
RUST_LOG=mendi=debug cargo run
```
Interactive commands once connected:
- `q` – quit
- `c` – write default calibration (auto-cal on)
- `e` – enable optical sensor
- `d` – disable optical sensor
### TUI (real-time charts)
```bash
# Real device
cargo run --features tui --bin mendi-tui
# Simulated (no hardware needed)
cargo run --features tui --bin mendi-tui -- --simulate
```
Keys: `1` IR view, `2` full view, `+/-` scale, `a` auto-scale,
`v` smooth toggle, `p` pause, `r` resume, `c` clear, `q` quit
## Using as a library
```toml
[dependencies]
mendi = "0.1.0"
# With simulation/mock support for testing:
mendi = { version = "0.1.0", features = ["simulate"] }
```
## Features
| `simulate` | No | Simulated and mock devices for testing without hardware |
| `tui` | No | Terminal UI with real-time charts (implies `simulate`) |
| `regenerate-proto` | No | Rebuild protobuf Rust types from `proto/device_v4.proto` (requires `protoc`) |
By default, no extra features are enabled and **no system dependencies** are
required beyond a Rust toolchain.
## Protocol details
The wire protocol is defined in `proto/device_v4.proto` (protobuf v3). All
characteristics use protobuf encoding. The Frame characteristic (0xABB1) is
the primary data stream, delivering real-time sensor readings at the headband's
native sample rate.
### Protobuf code generation
The Rust types for the wire protocol live in the `wire` module. By default,
**pre-generated code** is used (`src/wire_generated.rs`), so you do **not**
need `protoc` or the Protocol Buffers compiler installed to build this crate.
If you modify `proto/device_v4.proto` and want to regenerate the Rust types,
enable the `regenerate-proto` feature:
```bash
cargo build --features regenerate-proto
```
This requires `protoc` to be installed on your system ([install instructions](https://grpc.io/docs/protoc-installation/)).
After regenerating, copy the output from `target/debug/build/mendi-*/out/mendi.rs`
to `src/wire_generated.rs` to update the bundled version:
```bash
cp target/debug/build/mendi-*/out/mendi.rs src/wire_generated.rs
```
### Frame fields
| acc_x/y/z | i32 | Accelerometer (±2G default, raw int16) |
| ang_x/y/z | i32 | Gyroscope (±125°/s default, raw int16) |
| temp | f32 | Temperature in °C |
| ir_l/r/p | i32 | Infrared readings (left, right, pulse) |
| red_l/r/p | i32 | Red LED readings (left, right, pulse) |
| amb_l/r/p | i32 | Ambient light readings (left, right, pulse) |
Convenience methods on `FrameReading` convert raw IMU values to physical units:
- `accel_x_g()`, `accel_y_g()`, `accel_z_g()` — acceleration in g
- `gyro_x_dps()`, `gyro_y_dps()`, `gyro_z_dps()` — angular velocity in °/s
### UUID namespace
Mendi uses a vendor-specific UUID base: `fc3eXXXX-c6c4-49e6-922a-6e551c455af5`.
Use `mendi_uuid(short)` to construct UUIDs from 16-bit short codes.
## Simulation & testing (feature `simulate`)
The `simulate` feature adds two test utilities that don't require hardware:
### SimulatedDevice
Generates realistic fNIRS data with sinusoidal optical signals, IMU noise,
and periodic battery/calibration events:
```rust
use mendi::simulate::{SimulatedDevice, SimConfig};
use mendi::types::MendiEvent;
let config = SimConfig {
frame_rate_hz: 25.0,
disconnect_after_frames: Some(100),
..Default::default()
};
let (mut rx, handle) = SimulatedDevice::start(config);
while let Some(event) = rx.recv().await {
// Same MendiEvent stream as real hardware
}
```
Run the simulator binary:
```bash
cargo run --features simulate --bin mendi-sim
cargo run --features simulate --bin mendi-sim -- --frames 500 --hz 50
```
### MockDevice
Deterministic scripted mock for unit tests — no timing, no randomness:
```rust
use mendi::simulate::MockDevice;
use mendi::types::MendiEvent;
let (mut rx, mock) = MockDevice::with_frames(10, 32);
// Receives: Connected, 10 Frames, Disconnected
```
## MendiHandle API
Once connected, the `MendiHandle` provides:
| `write_calibration(...)` | Set LED current offsets and auto-calibration mode |
| `enable_sensor()` | Start optical sensor data (some FW versions require this) |
| `disable_sensor()` | Stop optical sensor data |
| `write_sensor_register(addr, data)` | Low-level optical sensor register write |
| `read_sensor_register(addr)` | Low-level optical sensor register read |
| `write_imu_register(addr, data)` | Low-level IMU register write |
| `read_imu_register(addr, n)` | Low-level IMU register read |
| `is_connected()` | Check BLE connection status |
| `disconnect()` | Gracefully disconnect |
## License
MIT