mw75 0.0.7

Rust client for MW75 Neuro EEG headphones over BLE + RFCOMM using btleplug
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# mw75

Async Rust library and CLI tools for streaming EEG data from
[Master & Dynamic MW75 Neuro](https://www.masterdynamic.com/) headphones
over Bluetooth.

[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)

## Overview 

The MW75 Neuro headphones contain a 12-channel EEG sensor array developed by
[Arctop](https://arctop.com). Data is streamed at **500 Hz** over Bluetooth
Classic (RFCOMM channel 25) after an initial BLE activation handshake.

This crate provides:

- **BLE activation** — scan, connect, enable EEG & raw mode, query battery
- **RFCOMM transport** — platform-native Bluetooth Classic data streaming
- **Packet parsing** — sync-byte alignment, checksum validation, 12-channel EEG decoding
- **Simulation** — synthetic 500 Hz EEG packets (random or deterministic sinusoidal)
- **TUI** — real-time 4-channel waveform viewer with smooth overlay and auto-scale
- **Audio** — automatic A2DP pairing, sink routing, and file playback (Linux)

## Platform support

| Capability | Linux | macOS | Windows |
|-----------|-------|-------|---------|
| BLE activation | ✓ (BlueZ) | ✓ (CoreBluetooth) | ✓ (WinRT) |
| RFCOMM streaming | ✓ (bluer) | ✓ (IOBluetooth) | ✓ (WinRT) |
| A2DP audio | ✓ (bluer + pactl) |||
| Simulation ||||
| TUI ||||

## Pairing

Before using this software, pair the MW75 Neuro headphones with your computer:

1. **Enter pairing mode** — Press and hold the power button for ~4 seconds until you hear the pairing tone
2. **Pair via OS Bluetooth settings** — Open your system's Bluetooth settings and pair the MW75 as you would normal headphones
3. **Verify connection** — The headphones should appear as a paired audio device

Once paired, this library uses:
- **BLE (GATT)** — for control commands (enable EEG, query battery, etc.)
- **Bluetooth Classic (RFCOMM)** — for streaming EEG data at 500 Hz

> **Note:** The headphones must be paired at the OS level first. BLE activation
> commands only work after the device is paired and connected as a standard
> Bluetooth audio device.

> **Discovery gotcha:** The MW75 only advertises over BLE while it has an
> **active connection** (e.g. it's the current audio output). When idle it stops
> advertising and a scan won't find it — if discovery times out, make sure the
> headphones are connected and playing audio, then retry.

### macOS: Bluetooth permission for RFCOMM

On macOS (Sonoma/Sequoia and later) opening a Classic Bluetooth RFCOMM channel
requires the **Bluetooth privacy permission**, which is only granted to a real
`.app` bundle — a bare `cargo run` binary fails with
`kIOReturnNotPermitted (0xe00002bc)`, even when ad-hoc signed. Build a signed
bundle and launch it once so macOS records the grant:

```bash
./macos/make-app.sh mw75      # build + bundle + sign build/MW75.app
open build/MW75.app           # first launch → click "Allow" on the prompt
```

BLE activation (CoreBluetooth) works without this; only RFCOMM streaming
(IOBluetooth) needs the bundle. After approval the grant persists by bundle id.

## Quick start

### Library

```toml
[dependencies]
mw75 = { version = "0.0.7", features = ["rfcomm"] }
```

```rust
use mw75::prelude::*;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = Mw75Client::new(Mw75ClientConfig::default());
    let (mut rx, handle) = client.connect().await?;
    handle.start().await?;

    // Disconnect BLE, then start RFCOMM data stream
    let addr = handle.peripheral_id();
    handle.disconnect_ble().await?;
    let handle = Arc::new(handle);
    let rfcomm = start_rfcomm_stream(handle.clone(), &addr).await?;

    while let Some(event) = rx.recv().await {
        match event {
            Mw75Event::Eeg(pkt) => {
                println!("counter={} ch1={:.1} µV", pkt.counter, pkt.channels[0]);
            }
            Mw75Event::Disconnected => break,
            _ => {}
        }
    }

    rfcomm.shutdown();
    Ok(())
}
```

### CLI

```bash
# Headless — print EEG events to stdout
cargo run --features rfcomm

# TUI — real-time waveform viewer (hardware)
cargo run --bin mw75-tui --features rfcomm

# TUI — simulated data (no hardware needed)
cargo run --bin mw75-tui -- --simulate

# Audio — play music through MW75 headphones (Linux)
cargo run --bin mw75-audio --features audio -- music.mp3
```

## Cargo features

| Feature | Default | Description |
|---------|---------|-------------|
| `tui` || Terminal UI binary (`mw75-tui`) with ratatui + crossterm |
| `rfcomm` | | RFCOMM data transport (Linux: BlueZ, macOS: IOBluetooth, Windows: WinRT) |
| `audio` | | Bluetooth A2DP audio + rodio playback (Linux only) |

```bash
# Build only the library (no extras)
cargo build --no-default-features

# Build everything
cargo build --features rfcomm,audio
```

## Architecture

```
┌──────────────────────────────────────────────────────────────┐
│  BLE Activation (btleplug)                                   │
│  scan → connect → enable EEG → enable raw mode → battery    │
└──────────────────┬───────────────────────────────────────────┘
                   │ disconnect BLE
┌──────────────────────────────────────────────────────────────┐
│  RFCOMM Transport (rfcomm feature)                           │
│  Linux: bluer::rfcomm::Stream                                │
│  macOS: IOBluetoothDevice.openRFCOMMChannelAsync             │
│  Windows: StreamSocket + RfcommDeviceService                 │
│                                                              │
│  async read loop → Mw75Handle::feed_data()                   │
└──────────────────┬───────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│  PacketProcessor                                             │
│  63-byte packet framing · sync recovery · checksum · f32 LE │
│  12 × EEG channels scaled to µV (×0.023842)                 │
└──────────────────┬───────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│  Mw75Event::Eeg(EegPacket)  →  mpsc::Receiver               │
│  500 Hz · 12 channels · REF · DRL · feature status           │
└──────────────────────────────────────────────────────────────┘
```

## Protocol

### Connection flow

1. BLE scan for device name containing `"MW75"` (case-insensitive)
2. Connect to GATT service `00001100-d102-11e1-9b23-00025b00a5a5`
3. Subscribe to status characteristic `00001102-…`
4. Write activation commands to command characteristic `00001101-…`:
   - `ENABLE_EEG``[0x09, 0x9A, 0x03, 0x60, 0x01]`
   - `ENABLE_RAW_MODE``[0x09, 0x9A, 0x03, 0x41, 0x01]`
   - `BATTERY``[0x09, 0x9A, 0x03, 0x14, 0xFF]`
5. Verify status responses (success code `0xF1`)
6. Disconnect BLE
7. Connect RFCOMM channel 25
8. Read 63-byte packets at 500 Hz

### Packet format (63 bytes)

```
Offset  Size  Field
──────  ────  ─────
  0       1   Sync byte (0xAA)
  1       1   Event ID (239 = EEG)
  2       1   Data length (0x3C = 60)
  3       1   Counter (0–255, wrapping)
  4       4   REF electrode (f32 LE)
  8       4   DRL electrode (f32 LE)
 12      48   12 × EEG channels (f32 LE, raw ADC)
 60       1   Feature status byte
 61       2   Checksum (u16 LE = sum of bytes[0..61] & 0xFFFF)
```

Channel values: `µV = raw_adc × 0.023842`

## Modules

### `mw75_client`

BLE scanning, connection, and activation via btleplug.

```rust
let client = Mw75Client::new(Mw75ClientConfig::default());

// Scan for all nearby MW75 devices
let devices = client.scan_all().await?;

// Or connect to the first one found
let (rx, handle) = client.connect().await?;
handle.start().await?;    // activation sequence
handle.stop().await?;     // disable sequence
handle.disconnect().await?;
```

Key types:
- `Mw75Client` — scanner and connector
- `Mw75Handle` — commands, `feed_data()`, stats
- `Mw75Device` — discovered device info
- `Mw75ClientConfig` — scan timeout, name pattern

### `rfcomm`

Platform-native RFCOMM data transport (requires `rfcomm` feature).

```rust
use mw75::rfcomm::start_rfcomm_stream;

let handle = Arc::new(handle);
handle.disconnect_ble().await?;  // required before RFCOMM

// Spawns an async reader task — data arrives on the event channel
let task = start_rfcomm_stream(handle.clone(), "AA:BB:CC:DD:EE:FF").await?;

// To stop:
task.shutdown();
```

### `parse`

Packet parsing and buffered stream processing.

```rust
use mw75::parse::{PacketProcessor, validate_checksum, parse_eeg_packet};

// Validate a raw 63-byte packet
let (valid, calc, recv) = validate_checksum(&raw_bytes);

// Parse into structured EegPacket
if let Some(pkt) = parse_eeg_packet(&raw_bytes) {
    println!("{} channels, counter={}", pkt.channels.len(), pkt.counter);
}

// Continuous stream processing (handles split delivery, sync recovery)
let mut proc = PacketProcessor::new(false);
let events = proc.process_data(&chunk);  // returns Vec<Mw75Event>
```

### `simulate`

Synthetic packet generation for testing and development.

```rust
use mw75::simulate::{build_eeg_packet, build_sim_packet, spawn_simulator};

// Random EEG packet
let pkt = build_eeg_packet(counter);

// Deterministic sinusoidal packet (alpha + beta + theta bands)
let pkt = build_sim_packet(counter, time_secs);

// Full 500 Hz simulator task
let (tx, mut rx) = tokio::sync::mpsc::channel(256);
let sim = spawn_simulator(tx, true);  // true = deterministic
```

### `types`

All event and data types.

- `EegPacket` — 12-channel EEG sample with timestamp, REF, DRL
- `BatteryInfo` — battery level (0–100%)
- `ActivationStatus` — EEG/raw mode confirmation
- `ChecksumStats` — valid/invalid/total packet counts + error rate
- `Mw75Event``Eeg`, `Battery`, `Activated`, `Connected`, `Disconnected`, `RawData`, `OtherEvent`

### `protocol`

Wire-format constants and GATT UUIDs.

```rust
use mw75::protocol::*;

assert_eq!(SYNC_BYTE, 0xAA);
assert_eq!(PACKET_SIZE, 63);
assert_eq!(EEG_EVENT_ID, 239);
assert_eq!(NUM_EEG_CHANNELS, 12);
assert_eq!(RFCOMM_CHANNEL, 25);
assert_eq!(EEG_SCALING_FACTOR, 0.023842);
assert_eq!(EEG_CHANNEL_NAMES.len(), 12);
```

### `audio`

Bluetooth A2DP audio management (Linux only, requires `audio` feature).

```rust
use mw75::audio::{Mw75Audio, AudioConfig};

let mut audio = Mw75Audio::new(AudioConfig::default());
let device = audio.connect().await?;   // discover → pair → A2DP → set sink
audio.play_file("music.mp3").await?;   // rodio playback
audio.disconnect().await?;             // restore previous sink
```

## TUI

The `mw75-tui` binary provides a real-time EEG waveform viewer:

```
 MW75 EEG Monitor │ ● MW75 Neuro │ Bat 85% │ 500 Hz │ ±200 µV │ 42K smp │ 0 drop
┌─ Ch1  min:-45.2  max:+52.1  rms: 28.3 µV [SMOOTH] ──────────────────────────────┐
│  ⡀⠀⠀⠀⣀⠀⠀⠀⢀⠀⠀⠀⡀⠀⠀⠀⣀⠀⠀⠀⢀⠀⠀⠀⡀⠀⠀⠀⣀⠀⠀⠀⢀⠀⠀⠀⡀⠀⠀⠀⣀⠀⠀⠀⢀⠀⠀⠀⡀⠀⠀⠀⣀⠀⠀⠀⢀⠀⠀⠀⡀│
│  ⠀⠀⠁⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀⠀⠁⠀⠀│
├─ Ch2 ...                                                                         ┤
├─ Ch3 ...                                                                         ┤
├─ Ch4 ...                                                                         ┤
└──────────────────────────────────────────────────────────────────────────────────┘
 [+/-]Scale  [a]Auto  [v]Smooth  [p]Pause  [r]Resume  [c]Clear  [q]Quit
```

**Keys:**

| Key | Action |
|-----|--------|
| `+` / `=` | Zoom out (increase µV scale) |
| `-` | Zoom in (decrease µV scale) |
| `a` | Auto-scale Y axis to peak amplitude |
| `v` | Toggle smooth overlay (moving average) |
| `p` / `r` | Pause / Resume streaming |
| `c` | Clear waveform buffers |
| `q` / `Esc` | Quit |

## Logging

All binaries share one logging setup ([`mw75::logging`](src/logging.rs)),
controlled entirely by environment variables — no recompile needed:

| Variable | Effect |
|----------|--------|
| `MW75_LOG` | Level filter (e.g. `info`, `mw75=debug`, `off`). Takes precedence over `RUST_LOG`. |
| `RUST_LOG` | Standard fallback level filter. |
| `MW75_LOG_FILE` | Append logs to this file instead of stderr (works for any binary). |

```bash
MW75_LOG=mw75=debug cargo run --features rfcomm            # verbose to stderr
MW75_LOG=warn MW75_LOG_FILE=/tmp/mw75.log cargo run --features rfcomm
MW75_LOG=off cargo run --features rfcomm                   # silence logging
```

Library users can also redirect to an arbitrary sink (socket, buffer, …) via
`mw75::logging::init_with(level, LogTarget::Pipe(writer))`.

## Testing

```bash
# Hardware-feature tests (110 unit + 19 doc-tests)
cargo test --features rfcomm

# Library-only tests (no Bluetooth backends)
cargo test
```

## Project structure

```
mw75/
├── Cargo.toml
├── README.md
├── src/
│   ├── lib.rs              # Module declarations, prelude, crate docs
│   ├── protocol.rs         # GATT UUIDs, BLE commands, wire-format constants
│   ├── types.rs            # EegPacket, Mw75Event, BatteryInfo, ChecksumStats
│   ├── parse.rs            # Checksum validation, packet parsing, PacketProcessor
│   ├── mw75_client.rs      # BLE scanning, connection, activation (btleplug)
│   ├── rfcomm.rs           # RFCOMM transport: Linux/macOS/Windows (rfcomm feature)
│   ├── simulate.rs         # Synthetic packet generator + 500 Hz simulator task
│   ├── logging.rs          # Shared env-controlled logging (file/pipe redirect)
│   ├── audio.rs            # A2DP audio: BlueZ + pactl + rodio (audio feature)
│   ├── main.rs             # Headless CLI binary (mw75)
│   └── bin/
│       ├── tui.rs          # Real-time EEG waveform TUI (tui feature)
│       ├── audio.rs        # Audio playback CLI binary (audio feature)
│       ├── ble_probe.rs    # BLE GATT enumeration / activation probe (rfcomm)
│       ├── rfcomm_debug.rs # IOBluetooth SDP + RFCOMM connection diagnostics (rfcomm)
│       └── rfcomm_probe.rs # RFCOMM data-streaming probe (rfcomm)
├── examples/
│   └── scan_dump.rs        # Dump every BLE peripheral seen during a scan
└── macos/
    ├── make-app.sh         # Build a signed .app bundle (Bluetooth TCC grant)
    ├── entitlements.plist  # com.apple.security.device.bluetooth
    └── Info.plist          # Bundle id + NSBluetoothAlwaysUsageDescription
```

## Credits

Based on the Python [mw75-streamer](https://github.com/arctop/mw75-streamer) by Arctop / Eitan Kay.

Architecture follows [muse-rs](https://github.com/eugenehp/muse-rs) by Eugene Hauptmann.

## License

[GPL-3.0](LICENSE)