use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Result};
#[cfg(target_os = "macos")]
use btleplug::api::CentralState;
use btleplug::api::{
Central, CentralEvent, Characteristic, Manager as _, Peripheral as _, ScanFilter, WriteType,
};
use btleplug::platform::{Adapter, Manager, Peripheral};
use futures::StreamExt;
use log::{debug, info, warn};
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::parse::PacketProcessor;
use crate::protocol::{
SampleRate, BATTERY_CMD, BLE_ACTIVATION_DELAY_MS, BLE_BATTERY_COMMAND, BLE_COMMAND_DELAY_MS,
BLE_DISCOVERY_TIMEOUT_SECS, BLE_EEG_COMMAND, BLE_RAW_MODE_COMMAND, BLE_RFCOMM_STATUS_COMMAND,
BLE_SUCCESS_CODE, BLE_UNKNOWN_E0_COMMAND, DISABLE_EEG_CMD, DISABLE_RAW_MODE_CMD,
ENABLE_EEG_CMD, ENABLE_RAW_MODE_CMD, MW75_COMMAND_CHAR, MW75_DEVICE_NAME_PATTERN,
MW75_SERVICE_UUID, MW75_STATUS_CHAR,
};
use crate::types::{ActivationStatus, BatteryInfo, Mw75Event};
#[derive(Clone, Debug)]
pub struct Mw75Device {
pub name: String,
pub id: String,
pub(crate) peripheral: Peripheral,
pub(crate) adapter: Adapter,
}
#[derive(Debug, Clone)]
pub struct Mw75ClientConfig {
pub scan_timeout_secs: u64,
pub name_pattern: String,
pub sample_rate: SampleRate,
}
impl Default for Mw75ClientConfig {
fn default() -> Self {
Self {
scan_timeout_secs: BLE_DISCOVERY_TIMEOUT_SECS,
name_pattern: MW75_DEVICE_NAME_PATTERN.into(),
sample_rate: SampleRate::default(),
}
}
}
pub struct Mw75Client {
config: Mw75ClientConfig,
}
impl Mw75Client {
pub fn new(config: Mw75ClientConfig) -> Self {
Self { config }
}
pub async fn scan_all(&self) -> Result<Vec<Mw75Device>> {
let manager = Manager::new().await?;
let adapters = manager.adapters().await?;
let adapter = adapters
.into_iter()
.next()
.ok_or_else(|| anyhow!("No Bluetooth adapter found"))?;
#[cfg(target_os = "macos")]
wait_for_adapter_ready(&adapter).await;
info!(
"scan_all: scanning for {} s …",
self.config.scan_timeout_secs
);
let scan_filter = ScanFilter {
services: vec![MW75_SERVICE_UUID],
};
adapter.start_scan(scan_filter).await?;
tokio::time::sleep(Duration::from_secs(self.config.scan_timeout_secs)).await;
adapter.stop_scan().await.ok();
let upper_pattern = self.config.name_pattern.to_uppercase();
let pattern_bytes = upper_pattern.as_bytes();
let mut found = vec![];
for p in adapter.peripherals().await? {
if let Ok(Some(props)) = p.properties().await {
let name = props.local_name.clone().unwrap_or_default();
let id = p.id().to_string();
debug!("scan_all: saw peripheral: name={name:?} id={id}");
let matched = (!name.is_empty() && name.to_uppercase().contains(&upper_pattern))
|| props.services.contains(&MW75_SERVICE_UUID)
|| props.manufacturer_data.values().any(|data| {
let upper: Vec<u8> = data.iter().map(|b| b.to_ascii_uppercase()).collect();
upper
.windows(pattern_bytes.len())
.any(|w| w == pattern_bytes)
});
if matched {
let display_name = if name.is_empty() {
"MW75 (matched by UUID/mfg)".to_owned()
} else {
name
};
info!("scan_all: found {display_name} id={id}");
found.push(Mw75Device {
name: display_name,
id,
peripheral: p,
adapter: adapter.clone(),
});
}
}
}
info!("scan_all: {} device(s) found", found.len());
Ok(found)
}
pub async fn connect_to(
&self,
device: Mw75Device,
) -> Result<(mpsc::Receiver<Mw75Event>, Mw75Handle)> {
self.setup_peripheral(device.peripheral, device.name, device.adapter)
.await
}
pub async fn connect(&self) -> Result<(mpsc::Receiver<Mw75Event>, Mw75Handle)> {
let manager = Manager::new().await?;
let adapters = manager.adapters().await?;
let adapter = adapters
.into_iter()
.next()
.ok_or_else(|| anyhow!("No Bluetooth adapter found"))?;
#[cfg(target_os = "macos")]
wait_for_adapter_ready(&adapter).await;
let timeout = self.config.scan_timeout_secs;
let pattern = &self.config.name_pattern;
info!("Scanning for MW75 devices (timeout: {timeout} s) …");
adapter.start_scan(ScanFilter::default()).await?;
let peripheral = self.find_first(&adapter, pattern, timeout).await?;
adapter.stop_scan().await.ok();
let props = peripheral.properties().await?.unwrap_or_default();
let device_name = props
.local_name
.unwrap_or_else(|| format!("MW75 ({})", peripheral.id()));
info!("Found device: {device_name}");
self.setup_peripheral(peripheral, device_name, adapter)
.await
}
async fn setup_peripheral(
&self,
peripheral: Peripheral,
device_name: String,
adapter: Adapter,
) -> Result<(mpsc::Receiver<Mw75Event>, Mw75Handle)> {
tokio::time::timeout(Duration::from_secs(10), peripheral.connect())
.await
.map_err(|_| anyhow!("BLE connect() timed out after 10 s"))??;
#[cfg(target_os = "linux")]
tokio::time::sleep(Duration::from_millis(600)).await;
tokio::time::timeout(Duration::from_secs(15), peripheral.discover_services())
.await
.map_err(|_| anyhow!("discover_services() timed out after 15 s"))??;
info!("Connected and services discovered: {device_name}");
let chars: BTreeSet<Characteristic> = peripheral.characteristics();
for c in &chars {
info!(
" characteristic: uuid={} properties={:?}",
c.uuid, c.properties
);
}
let find_char = |uuid: Uuid| -> Result<Characteristic> {
chars
.iter()
.find(|c| c.uuid == uuid)
.cloned()
.ok_or_else(|| anyhow!("Characteristic {uuid} not found"))
};
let command_char = find_char(MW75_COMMAND_CHAR)?;
let status_char = find_char(MW75_STATUS_CHAR)?;
use btleplug::api::CharPropFlags;
let mut subscribed_chars = Vec::new();
for c in &chars {
if c.properties.contains(CharPropFlags::NOTIFY)
|| c.properties.contains(CharPropFlags::INDICATE)
{
match peripheral.subscribe(c).await {
Ok(()) => {
info!("BLE notifications enabled on {}", c.uuid);
subscribed_chars.push(c.uuid);
}
Err(e) => {
warn!("⚠ Failed to subscribe to {}: {e}", c.uuid);
}
}
}
}
if !subscribed_chars.contains(&status_char.uuid) {
peripheral.subscribe(&status_char).await?;
info!("BLE notifications enabled on status characteristic");
subscribed_chars.push(status_char.uuid);
}
info!(
"Subscribed to {} characteristic(s): {}",
subscribed_chars.len(),
subscribed_chars
.iter()
.map(|u| {
let s = u.to_string();
if s.contains("1102") {
"STATUS".to_string()
} else if s.contains("1103") {
"DATA_1103".to_string()
} else if s.contains("1105") {
"STATUS_ALT".to_string()
} else if s.contains("1106") {
"DATA_1106".to_string()
} else {
s[..8].to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
);
let (tx, rx) = mpsc::channel::<Mw75Event>(256);
let _ = tx.send(Mw75Event::Connected(device_name.clone())).await;
let ble_disconnect_expected = Arc::new(AtomicBool::new(false));
let disconnect_tx = tx.clone();
let peripheral_id = peripheral.id();
let expected_flag = ble_disconnect_expected.clone();
tokio::spawn(async move {
match adapter.events().await {
Ok(mut events) => {
while let Some(event) = events.next().await {
if let CentralEvent::DeviceDisconnected(id) = event {
if id == peripheral_id {
if expected_flag.load(Ordering::SeqCst) {
info!("Disconnect watcher: BLE disconnect expected (pre-RFCOMM), suppressing event");
} else {
info!("Disconnect watcher: MW75 disconnected unexpectedly");
let _ = disconnect_tx.send(Mw75Event::Disconnected).await;
}
break;
}
}
}
}
Err(e) => {
warn!("Disconnect watcher: could not subscribe to adapter events: {e}");
}
}
});
let notification_tx = tx.clone();
let peripheral_clone = peripheral.clone();
let expected_flag2 = ble_disconnect_expected.clone();
let status_char_uuid = status_char.uuid;
let ble_processor = Arc::new(std::sync::Mutex::new(PacketProcessor::new(false)));
let ble_processor_clone = ble_processor.clone();
tokio::spawn(async move {
let mut notifications = match peripheral_clone.notifications().await {
Ok(n) => n,
Err(e) => {
warn!("Could not get BLE notifications stream: {e}");
return;
}
};
info!("BLE notification stream active, waiting for data…");
let mut notif_count: u64 = 0;
let mut notif_counts_by_char: std::collections::HashMap<Uuid, u64> =
std::collections::HashMap::new();
while let Some(notif) = notifications.next().await {
let data = ¬if.value;
notif_count += 1;
let char_count = notif_counts_by_char.entry(notif.uuid).or_insert(0);
*char_count += 1;
let hex_str: String = if data.len() <= 64 {
data.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
} else {
let head: String = data[..32]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ");
format!("{head} … ({} bytes total)", data.len())
};
let char_short = match notif.uuid {
u if u == status_char_uuid => "STATUS_1102",
u if u == Uuid::from_u128(0x00001103_d102_11e1_9b23_00025b00a5a5) => {
"DATA_1103"
}
u if u == Uuid::from_u128(0x00001105_d102_11e1_9b23_00025b00a5a6) => {
"STATUS_1105"
}
u if u == Uuid::from_u128(0x00001106_d102_11e1_9b23_00025b00a5a6) => {
"DATA_1106"
}
_ => "OTHER",
};
if *char_count <= 50 {
info!(
"📨 {char_short} #{char_count} ({} B): {hex_str}",
data.len()
);
} else if *char_count == 51 {
info!("📨 {char_short} — suppressing further raw logs (received 50+)");
}
if notif.uuid == status_char_uuid
&& data.len() >= 5
&& data[0] == 0x09
&& data[1] == 0x9A
{
let cmd_type = data[3];
let status = data[4];
if cmd_type == BLE_EEG_COMMAND {
if status == BLE_SUCCESS_CODE {
info!("EEG mode confirmed enabled");
} else {
warn!("EEG mode response: status=0x{status:02x} (expected 0xF1)");
}
} else if cmd_type == BLE_RAW_MODE_COMMAND {
if status == BLE_SUCCESS_CODE {
info!("Raw mode confirmed enabled");
} else {
warn!("Raw mode response: status=0x{status:02x} (0xF0=failed, may need retry)");
}
} else if cmd_type == BLE_BATTERY_COMMAND {
let level = if status == BLE_SUCCESS_CODE && data.len() >= 6 {
data[5]
} else {
status
};
info!("Battery level: {level}%");
let _ = notification_tx
.send(Mw75Event::Battery(BatteryInfo { level }))
.await;
} else if cmd_type == BLE_RFCOMM_STATUS_COMMAND {
let state = match status {
0x00 => "not connected",
0x01 => "connected",
_ => "unknown",
};
debug!("RFCOMM status: 0x{status:02x} ({state})");
} else if cmd_type == BLE_UNKNOWN_E0_COMMAND {
debug!("Unknown E0 command response: status=0x{status:02x}");
} else {
debug!(
"Unhandled BLE response: cmd=0x{cmd_type:02x} status=0x{status:02x} (feature_id=0x{:02x})",
data[2]
);
}
continue;
}
let events = {
let mut proc = ble_processor_clone.lock().unwrap();
proc.process_data(data)
};
if !events.is_empty() {
info!(
"✅ Parsed {} EEG packet(s) from {char_short} ({} bytes)",
events.len(),
data.len()
);
for event in events {
let _ = notification_tx.send(event).await;
}
} else if data.len() > 5 && *char_count <= 50 {
let first = data[0];
let annotation = if first == 0xAA {
format!(
"sync=0xAA event_id={} len={} counter={}",
data.get(1).unwrap_or(&0),
data.get(2).unwrap_or(&0),
data.get(3).unwrap_or(&0)
)
} else {
format!("first_byte=0x{first:02x} (not 0xAA sync)")
};
info!(" ↳ no packet parsed: {annotation}");
}
if notif_count.is_multiple_of(100) {
let summary: String = notif_counts_by_char
.iter()
.map(|(uuid, count)| {
let lbl = match *uuid {
u if u == status_char_uuid => "STATUS_1102",
u if u
== Uuid::from_u128(0x00001103_d102_11e1_9b23_00025b00a5a5) =>
{
"DATA_1103"
}
u if u
== Uuid::from_u128(0x00001105_d102_11e1_9b23_00025b00a5a6) =>
{
"STATUS_1105"
}
u if u
== Uuid::from_u128(0x00001106_d102_11e1_9b23_00025b00a5a6) =>
{
"DATA_1106"
}
_ => "OTHER",
};
format!("{lbl}={count}")
})
.collect::<Vec<_>>()
.join(", ");
info!("📊 Notification total: {notif_count} — {summary}");
}
}
info!("BLE notification stream ended");
if !expected_flag2.load(Ordering::SeqCst) {
let _ = notification_tx.send(Mw75Event::Disconnected).await;
}
});
let handle = Mw75Handle {
peripheral,
command_char,
processor: std::sync::Mutex::new(PacketProcessor::new(false)),
tx,
device_name: device_name.clone(),
ble_disconnect_expected,
sample_rate: self.config.sample_rate,
};
Ok((rx, handle))
}
async fn find_first(
&self,
adapter: &Adapter,
pattern: &str,
timeout_secs: u64,
) -> Result<Peripheral> {
let upper_pattern = pattern.to_uppercase();
let pattern_bytes = upper_pattern.as_bytes();
let mut logged_peripherals = std::collections::HashSet::new();
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async {
loop {
let peripherals = adapter.peripherals().await.unwrap_or_default();
for p in peripherals {
if let Ok(Some(props)) = p.properties().await {
let id = p.id().to_string();
let name = props.local_name.clone().unwrap_or_default();
let services = &props.services;
let mfg_data = &props.manufacturer_data;
if logged_peripherals.insert(id.clone()) {
let svc_summary: String = services
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(", ");
let mfg_summary: String = mfg_data
.iter()
.map(|(k, v)| format!("0x{k:04X}[{}B]", v.len()))
.collect::<Vec<_>>()
.join(", ");
debug!(
"scan: id={id} name={:?} services=[{svc_summary}] mfg=[{mfg_summary}]",
if name.is_empty() { "<none>" } else { &name }
);
}
if !name.is_empty()
&& name.to_uppercase().contains(&upper_pattern)
{
info!("Matched by name: {name} id={id}");
return p;
}
if services.contains(&MW75_SERVICE_UUID) {
info!(
"Matched by service UUID: name={:?} id={id}",
if name.is_empty() { "<none>" } else { &name }
);
return p;
}
for data in mfg_data.values() {
let upper_data: Vec<u8> =
data.iter().map(|b| b.to_ascii_uppercase()).collect();
if upper_data
.windows(pattern_bytes.len())
.any(|w| w == pattern_bytes)
{
info!(
"Matched by manufacturer data: name={:?} id={id}",
if name.is_empty() { "<none>" } else { &name }
);
return p;
}
}
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
})
.await;
if result.is_err() {
warn!(
"Scan saw {} peripheral(s) total but none matched '{pattern}' \
by name, service UUID, or manufacturer data",
logged_peripherals.len()
);
}
result.map_err(|_| anyhow!("Timed out scanning for an MW75 device after {timeout_secs} s"))
}
}
#[cfg(target_os = "macos")]
async fn wait_for_adapter_ready(adapter: &Adapter) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
loop {
match adapter.adapter_state().await {
Ok(CentralState::PoweredOn) => {
info!("macOS: adapter is PoweredOn");
break;
}
Ok(state) => {
if tokio::time::Instant::now() >= deadline {
warn!("macOS: adapter still in state {state:?} after 3 s — proceeding");
break;
}
debug!("macOS: adapter state = {state:?}, waiting…");
}
Err(e) => {
warn!("macOS: adapter_state() error: {e}");
break;
}
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
pub struct Mw75Handle {
peripheral: Peripheral,
command_char: Characteristic,
processor: std::sync::Mutex<PacketProcessor>,
tx: mpsc::Sender<Mw75Event>,
device_name: String,
ble_disconnect_expected: Arc<AtomicBool>,
sample_rate: SampleRate,
}
impl Mw75Handle {
pub async fn write_command(&self, cmd: &[u8]) -> Result<()> {
let connected = self.peripheral.is_connected().await.unwrap_or(false);
if connected {
self.peripheral
.write(&self.command_char, cmd, WriteType::WithResponse)
.await?;
} else {
self.ble_send_with_reconnect(&[cmd]).await?;
}
Ok(())
}
async fn ble_send_with_reconnect(&self, cmds: &[&[u8]]) -> Result<()> {
info!("BLE not connected — reconnecting temporarily to send command(s)…");
self.ble_disconnect_expected.store(true, Ordering::SeqCst);
tokio::time::timeout(Duration::from_secs(10), self.peripheral.connect())
.await
.map_err(|_| anyhow!("BLE reconnect timed out after 10 s"))??;
tokio::time::timeout(Duration::from_secs(10), self.peripheral.discover_services())
.await
.map_err(|_| anyhow!("discover_services() timed out after 10 s"))??;
let command_char = self
.peripheral
.characteristics()
.iter()
.find(|c| c.uuid == MW75_COMMAND_CHAR)
.cloned()
.ok_or_else(|| anyhow!("Command characteristic not found after BLE reconnect"))?;
info!("BLE reconnected, sending {} command(s)…", cmds.len());
for cmd in cmds {
self.peripheral
.write(&command_char, cmd, WriteType::WithResponse)
.await?;
tokio::time::sleep(Duration::from_millis(BLE_COMMAND_DELAY_MS)).await;
}
self.peripheral.disconnect().await.ok();
info!("BLE disconnected again after sending command(s)");
Ok(())
}
pub async fn start(&self) -> Result<()> {
let connected = self.peripheral.is_connected().await.unwrap_or(false);
let raw_mode = self.sample_rate.needs_raw_mode();
info!(
"Activation: sample_rate={}, raw_mode={}",
self.sample_rate, raw_mode
);
if !connected {
info!("Resuming EEG via temporary BLE reconnect…");
let mut cmds: Vec<&[u8]> = vec![&ENABLE_EEG_CMD];
if raw_mode {
cmds.push(&ENABLE_RAW_MODE_CMD);
}
cmds.push(&BATTERY_CMD);
self.ble_send_with_reconnect(&cmds).await?;
} else {
info!("Sending ENABLE_EEG…");
self.write_command(&ENABLE_EEG_CMD).await?;
tokio::time::sleep(Duration::from_millis(BLE_COMMAND_DELAY_MS)).await;
if raw_mode {
info!("Sending ENABLE_RAW_MODE…");
self.write_command(&ENABLE_RAW_MODE_CMD).await?;
tokio::time::sleep(Duration::from_millis(BLE_COMMAND_DELAY_MS)).await;
} else {
info!("Skipping ENABLE_RAW_MODE (256 Hz mode)");
}
info!("Getting battery level…");
self.write_command(&BATTERY_CMD).await?;
tokio::time::sleep(Duration::from_millis(BLE_COMMAND_DELAY_MS)).await;
}
let _ = self
.tx
.send(Mw75Event::Activated(ActivationStatus {
eeg_enabled: true,
raw_mode_enabled: raw_mode,
}))
.await;
info!("BLE activation sequence complete ({})", self.sample_rate);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
let connected = self.peripheral.is_connected().await.unwrap_or(false);
let raw_mode = self.sample_rate.needs_raw_mode();
if !connected {
info!("Pausing EEG via temporary BLE reconnect…");
let mut cmds: Vec<&[u8]> = Vec::new();
if raw_mode {
cmds.push(&DISABLE_RAW_MODE_CMD);
}
cmds.push(&DISABLE_EEG_CMD);
self.ble_send_with_reconnect(&cmds).await?;
} else {
if raw_mode {
info!("Sending DISABLE_RAW_MODE…");
self.write_command(&DISABLE_RAW_MODE_CMD).await?;
tokio::time::sleep(Duration::from_millis(BLE_ACTIVATION_DELAY_MS)).await;
}
info!("Sending DISABLE_EEG…");
self.write_command(&DISABLE_EEG_CMD).await?;
tokio::time::sleep(Duration::from_millis(BLE_COMMAND_DELAY_MS)).await;
}
info!("EEG streaming disabled");
Ok(())
}
pub async fn feed_data(&self, data: &[u8]) {
let events = {
let mut proc = self.processor.lock().unwrap();
proc.process_data(data)
};
for event in events {
let _ = self.tx.send(event).await;
}
}
pub fn get_stats(&self) -> crate::types::ChecksumStats {
self.processor.lock().unwrap().get_stats()
}
pub async fn is_connected(&self) -> bool {
self.peripheral.is_connected().await.unwrap_or(false)
}
pub async fn disconnect(&self) -> Result<()> {
self.stop().await.ok();
self.peripheral.disconnect().await?;
Ok(())
}
pub async fn send_disconnected(&self) {
let _ = self.tx.send(Mw75Event::Disconnected).await;
}
pub fn peripheral_id(&self) -> String {
self.peripheral.id().to_string()
}
pub fn device_name(&self) -> &str {
&self.device_name
}
pub fn sample_rate(&self) -> SampleRate {
self.sample_rate
}
pub async fn disconnect_ble(&self) -> Result<()> {
info!("Disconnecting BLE (pre-RFCOMM)…");
self.ble_disconnect_expected.store(true, Ordering::SeqCst);
self.peripheral.disconnect().await?;
info!("BLE disconnected");
Ok(())
}
}