use uuid::Uuid;
pub const GAIA_VENDOR_QTIL_V1V2: u16 = 0x000A;
pub const GAIA_VENDOR_QTIL_V3: u16 = 0x001D;
pub const GAIA_VENDOR_MD: u16 = 0x099A;
pub const GAIA_VENDOR_OFFSET: usize = 0;
pub const GAIA_COMMAND_DESC_OFFSET: usize = 2;
pub const GAIA_COMMAND_TYPE_OFFSET: usize = 3;
pub const GAIA_PAYLOAD_OFFSET: usize = 4;
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GaiaCommand {
RegisterBattery = 0x06,
ProductId = 0x10,
WorldVolume = 0x11,
AutoOff = 0x12,
InEarDetection = 0x13,
GetBatteryLevel = 0x14,
DeviceName = 0x15,
FindDevice = 0x17,
EegMode = 0x60,
RawMode = 0x41,
RfcommStatus = 0x88,
BatteryNotification82 = 0x82,
VolumeButtonPress = 0x91,
UnknownE0 = 0xE0,
BatteryNotification = 0xF1,
}
impl GaiaCommand {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x06 => Some(Self::RegisterBattery),
0x10 => Some(Self::ProductId),
0x11 => Some(Self::WorldVolume),
0x12 => Some(Self::AutoOff),
0x13 => Some(Self::InEarDetection),
0x14 => Some(Self::GetBatteryLevel),
0x15 => Some(Self::DeviceName),
0x17 => Some(Self::FindDevice),
0x41 => Some(Self::RawMode),
0x60 => Some(Self::EegMode),
0x82 => Some(Self::BatteryNotification82),
0x88 => Some(Self::RfcommStatus),
0x91 => Some(Self::VolumeButtonPress),
0xE0 => Some(Self::UnknownE0),
0xF1 => Some(Self::BatteryNotification),
_ => None,
}
}
}
pub const GAIA_QUERY: u8 = 0xFF;
pub mod gaia_payload {
pub const WV_OFF: u8 = 0x01;
pub const WV_ANC_HIGH: u8 = 0x02;
pub const WV_ANC_LOW: u8 = 0x03;
pub const WV_AMBIENT_VOICE: u8 = 0x04;
pub const WV_AMBIENT_AWARENESS: u8 = 0x05;
pub const WV_ANC_ADAPTIVE: u8 = 0x06;
pub const AUTO_OFF_NEVER: u8 = 0x01;
pub const AUTO_OFF_30_MIN: u8 = 0x02;
pub const AUTO_OFF_1_HOUR: u8 = 0x03;
pub const AUTO_OFF_3_HOURS: u8 = 0x04;
pub const IN_EAR_DISABLE: u8 = 0x01;
pub const IN_EAR_ENABLE: u8 = 0x02;
pub const FIND_TONE_DISABLE: u8 = 0x01;
pub const FIND_TONE_ENABLE: u8 = 0x02;
pub const REGISTER_BATTERY: u8 = 0x01;
}
pub mod gaia_response {
pub const SET_FAILED: u8 = 0xF0;
pub const SET_SUCCESS: u8 = 0xF1;
pub const WV_ALL_OFF: u8 = 0x01;
pub const WV_ANC_HIGH: u8 = 0x02;
pub const WV_ANC_LOW: u8 = 0x03;
pub const WV_AMBIENT_VOICE: u8 = 0x04;
pub const WV_AMBIENT_AWARENESS: u8 = 0x05;
pub const WV_ANC_ADAPTIVE: u8 = 0x06;
pub const AUTO_OFF_NEVER: u8 = 0x01;
pub const AUTO_OFF_30_MIN: u8 = 0x02;
pub const AUTO_OFF_1_HOUR: u8 = 0x03;
pub const AUTO_OFF_3_HOURS: u8 = 0x04;
pub const IN_EAR_DISABLE: u8 = 0x01;
pub const IN_EAR_ENABLE: u8 = 0x02;
pub const FIND_TONE_DISABLE: u8 = 0x01;
pub const FIND_TONE_ENABLE: u8 = 0x02;
pub const BTN_WV_OFF: u8 = 0x01;
pub const BTN_WV_ANC_MAX: u8 = 0x02;
pub const BTN_WV_AMBIENT_AWARENESS: u8 = 0x04;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GaiaAncMode {
Off,
AncHigh,
AncLow,
AmbientVoice,
AmbientAwareness,
AncAdaptive,
}
impl GaiaAncMode {
pub fn to_payload(self) -> u8 {
match self {
Self::Off => gaia_payload::WV_OFF,
Self::AncHigh => gaia_payload::WV_ANC_HIGH,
Self::AncLow => gaia_payload::WV_ANC_LOW,
Self::AmbientVoice => gaia_payload::WV_AMBIENT_VOICE,
Self::AmbientAwareness => gaia_payload::WV_AMBIENT_AWARENESS,
Self::AncAdaptive => gaia_payload::WV_ANC_ADAPTIVE,
}
}
pub fn from_response(b: u8) -> Option<Self> {
match b {
gaia_response::WV_ALL_OFF => Some(Self::Off),
gaia_response::WV_ANC_HIGH => Some(Self::AncHigh),
gaia_response::WV_ANC_LOW => Some(Self::AncLow),
gaia_response::WV_AMBIENT_VOICE => Some(Self::AmbientVoice),
gaia_response::WV_AMBIENT_AWARENESS => Some(Self::AmbientAwareness),
gaia_response::WV_ANC_ADAPTIVE => Some(Self::AncAdaptive),
_ => None,
}
}
pub fn from_button_press(b: u8) -> Option<Self> {
match b {
gaia_response::BTN_WV_OFF => Some(Self::Off),
gaia_response::BTN_WV_ANC_MAX => Some(Self::AncHigh),
gaia_response::BTN_WV_AMBIENT_AWARENESS => Some(Self::AmbientAwareness),
_ => None,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Off => "Off",
Self::AncHigh => "ANC High",
Self::AncLow => "ANC Low",
Self::AmbientVoice => "Ambient (Voice)",
Self::AmbientAwareness => "Ambient (Awareness)",
Self::AncAdaptive => "ANC Adaptive",
}
}
}
impl std::fmt::Display for GaiaAncMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GaiaAutoOff {
Never,
ThirtyMinutes,
OneHour,
ThreeHours,
}
impl GaiaAutoOff {
pub fn to_payload(self) -> u8 {
match self {
Self::Never => gaia_payload::AUTO_OFF_NEVER,
Self::ThirtyMinutes => gaia_payload::AUTO_OFF_30_MIN,
Self::OneHour => gaia_payload::AUTO_OFF_1_HOUR,
Self::ThreeHours => gaia_payload::AUTO_OFF_3_HOURS,
}
}
pub fn from_response(b: u8) -> Option<Self> {
match b {
gaia_response::AUTO_OFF_NEVER => Some(Self::Never),
gaia_response::AUTO_OFF_30_MIN => Some(Self::ThirtyMinutes),
gaia_response::AUTO_OFF_1_HOUR => Some(Self::OneHour),
gaia_response::AUTO_OFF_3_HOURS => Some(Self::ThreeHours),
_ => None,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Never => "Never",
Self::ThirtyMinutes => "30 minutes",
Self::OneHour => "1 hour",
Self::ThreeHours => "3 hours",
}
}
}
impl std::fmt::Display for GaiaAutoOff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GaiaEqPreset {
Eq1,
Eq2,
Eq3,
Eq4,
Eq5,
Custom,
}
pub const fn build_gaia_command(command: GaiaCommand, payload: u8) -> [u8; 5] {
[0x09, 0x9A, 0x03, command as u8, payload]
}
pub fn build_gaia_command_bytes(command: GaiaCommand, payload: &[u8]) -> Vec<u8> {
let mut cmd = vec![0x09, 0x9A, 0x03, command as u8];
cmd.extend_from_slice(payload);
cmd
}
pub const MW75_SERVICE_UUID: Uuid = Uuid::from_u128(0x00001100_d102_11e1_9b23_00025b00a5a5);
pub const MW75_COMMAND_CHAR: Uuid = Uuid::from_u128(0x00001101_d102_11e1_9b23_00025b00a5a5);
pub const MW75_STATUS_CHAR: Uuid = Uuid::from_u128(0x00001102_d102_11e1_9b23_00025b00a5a5);
pub const MW75_DATA_CHAR: Uuid = Uuid::from_u128(0x00001103_d102_11e1_9b23_00025b00a5a5);
pub const MW75_STATUS_CHAR_ALT: Uuid = Uuid::from_u128(0x00001105_d102_11e1_9b23_00025b00a5a6);
pub const MW75_DATA_CHAR_ALT: Uuid = Uuid::from_u128(0x00001107_d102_11e1_9b23_00025b00a5a5);
pub const SPP_UUID: Uuid = Uuid::from_u128(0x00001101_0000_1000_8000_00805F9B34FB);
pub const MW75_SPP_UUID: Uuid = Uuid::from_u128(0x00001101_d102_11e1_9b23_00025b00a5a5);
pub const ENABLE_EEG_CMD: [u8; 5] = build_gaia_command(GaiaCommand::EegMode, 0x01);
pub const DISABLE_EEG_CMD: [u8; 5] = build_gaia_command(GaiaCommand::EegMode, 0x00);
pub const ENABLE_RAW_MODE_CMD: [u8; 5] = build_gaia_command(GaiaCommand::RawMode, 0x01);
pub const DISABLE_RAW_MODE_CMD: [u8; 5] = build_gaia_command(GaiaCommand::RawMode, 0x00);
pub const BATTERY_CMD: [u8; 5] = build_gaia_command(GaiaCommand::GetBatteryLevel, GAIA_QUERY);
pub const GET_WORLD_VOLUME_CMD: [u8; 5] = build_gaia_command(GaiaCommand::WorldVolume, GAIA_QUERY);
pub const GET_AUTO_OFF_CMD: [u8; 5] = build_gaia_command(GaiaCommand::AutoOff, GAIA_QUERY);
pub const GET_IN_EAR_DETECTION_CMD: [u8; 5] =
build_gaia_command(GaiaCommand::InEarDetection, GAIA_QUERY);
pub const GET_FIND_DEVICE_CMD: [u8; 5] = build_gaia_command(GaiaCommand::FindDevice, GAIA_QUERY);
pub const GET_PRODUCT_ID_CMD: [u8; 5] = build_gaia_command(GaiaCommand::ProductId, GAIA_QUERY);
pub const REGISTER_BATTERY_CMD: [u8; 5] =
build_gaia_command(GaiaCommand::RegisterBattery, gaia_payload::REGISTER_BATTERY);
pub const FETCH_ALL_COMMANDS: [[u8; 5]; 4] = [
GET_WORLD_VOLUME_CMD,
GET_AUTO_OFF_CMD,
GET_IN_EAR_DETECTION_CMD,
GET_FIND_DEVICE_CMD,
];
pub const EEG_EVENT_ID: u8 = 239;
pub const PACKET_SIZE: usize = 63;
pub const SYNC_BYTE: u8 = 0xAA;
pub const EEG_SCALING_FACTOR: f32 = 0.023842;
pub const SENTINEL_VALUE: i32 = 8388607;
pub const NUM_EEG_CHANNELS: usize = 12;
pub const RFCOMM_CHANNEL: u8 = 25;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SampleRate {
Hz256,
Hz500,
}
impl SampleRate {
pub fn hz(self) -> f64 {
match self {
Self::Hz256 => 256.0,
Self::Hz500 => 500.0,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Hz256 => "256 Hz",
Self::Hz500 => "500 Hz",
}
}
pub fn needs_raw_mode(self) -> bool {
match self {
Self::Hz256 => false,
Self::Hz500 => true,
}
}
pub fn interval_micros(self) -> u64 {
match self {
Self::Hz256 => 3906, Self::Hz500 => 2000, }
}
}
impl Default for SampleRate {
fn default() -> Self {
Self::Hz500
}
}
impl std::fmt::Display for SampleRate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
pub const BLE_ACTIVATION_DELAY_MS: u64 = 100;
pub const BLE_COMMAND_DELAY_MS: u64 = 500;
pub const BLE_DISCOVERY_TIMEOUT_SECS: u64 = 4;
pub const DATA_PACKET_TIMEOUT_SECS: f64 = 8.0;
pub const BLE_SUCCESS_CODE: u8 = gaia_response::SET_SUCCESS;
pub const BLE_SUCCESS_CODE_ALT: u8 = gaia_response::SET_FAILED;
pub const BLE_EEG_COMMAND: u8 = GaiaCommand::EegMode as u8;
pub const BLE_RAW_MODE_COMMAND: u8 = GaiaCommand::RawMode as u8;
pub const BLE_BATTERY_COMMAND: u8 = GaiaCommand::GetBatteryLevel as u8;
pub const BLE_UNKNOWN_E0_COMMAND: u8 = GaiaCommand::UnknownE0 as u8;
pub const BLE_RFCOMM_STATUS_COMMAND: u8 = GaiaCommand::RfcommStatus as u8;
pub const MW75_DEVICE_NAME_PATTERN: &str = "MW75";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MdDeviceModel {
Mw75Neuro,
Mw08,
Mw08Sport,
Mw08SportInternal,
Mw09,
Mg20,
Mh40Wireless,
Ma880,
}
pub const EEG_CHANNEL_NAMES: [&str; 12] = [
"Ch1", "Ch2", "Ch3", "Ch4", "Ch5", "Ch6", "Ch7", "Ch8", "Ch9", "Ch10", "Ch11", "Ch12",
];
#[derive(Debug, Clone)]
pub struct GaiaResponse {
pub command: GaiaCommand,
pub payload: Vec<u8>,
}
pub fn parse_gaia_response(data: &[u8]) -> Option<GaiaResponse> {
if data.len() < 5 {
return None;
}
if data[0] != 0x09 || data[1] != 0x9A || data[2] != 0x03 {
return None;
}
let command = GaiaCommand::from_byte(data[3])?;
let payload = data[4..].to_vec();
Some(GaiaResponse { command, payload })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_uuid_format() {
let s = MW75_SERVICE_UUID.to_string();
assert!(
s.contains("00001100"),
"UUID should contain service base: {s}"
);
}
#[test]
fn command_char_uuid_format() {
let s = MW75_COMMAND_CHAR.to_string();
assert!(s.contains("00001101"));
}
#[test]
fn status_char_uuid_format() {
let s = MW75_STATUS_CHAR.to_string();
assert!(s.contains("00001102"));
}
#[test]
fn uuids_are_distinct() {
assert_ne!(MW75_SERVICE_UUID, MW75_COMMAND_CHAR);
assert_ne!(MW75_SERVICE_UUID, MW75_STATUS_CHAR);
assert_ne!(MW75_COMMAND_CHAR, MW75_STATUS_CHAR);
}
#[test]
fn gaia_vendor_id_values() {
assert_eq!(GAIA_VENDOR_QTIL_V1V2, 0x000A);
assert_eq!(GAIA_VENDOR_QTIL_V3, 0x001D);
assert_eq!(GAIA_VENDOR_MD, 0x099A);
}
#[test]
fn gaia_vendor_md_matches_command_prefix() {
assert_eq!((GAIA_VENDOR_MD >> 8) as u8, 0x09);
assert_eq!((GAIA_VENDOR_MD & 0xFF) as u8, 0x9A);
}
#[test]
fn build_command_basic() {
let cmd = build_gaia_command(GaiaCommand::GetBatteryLevel, GAIA_QUERY);
assert_eq!(cmd, [0x09, 0x9A, 0x03, 0x14, 0xFF]);
}
#[test]
fn build_command_matches_prebuilt() {
assert_eq!(
ENABLE_EEG_CMD,
build_gaia_command(GaiaCommand::EegMode, 0x01)
);
assert_eq!(
DISABLE_EEG_CMD,
build_gaia_command(GaiaCommand::EegMode, 0x00)
);
assert_eq!(
ENABLE_RAW_MODE_CMD,
build_gaia_command(GaiaCommand::RawMode, 0x01)
);
assert_eq!(
DISABLE_RAW_MODE_CMD,
build_gaia_command(GaiaCommand::RawMode, 0x00)
);
assert_eq!(
BATTERY_CMD,
build_gaia_command(GaiaCommand::GetBatteryLevel, GAIA_QUERY)
);
}
#[test]
fn build_command_bytes_variable_length() {
let payload = b"TestName";
let mut expected_payload = vec![payload.len() as u8];
expected_payload.extend_from_slice(payload);
let cmd = build_gaia_command_bytes(GaiaCommand::DeviceName, &expected_payload);
assert_eq!(cmd[0], 0x09);
assert_eq!(cmd[1], 0x9A);
assert_eq!(cmd[2], 0x03);
assert_eq!(cmd[3], GaiaCommand::DeviceName as u8);
assert_eq!(cmd[4], payload.len() as u8);
assert_eq!(&cmd[5..], payload);
}
#[test]
fn enable_eeg_cmd_structure() {
assert_eq!(ENABLE_EEG_CMD.len(), 5);
assert_eq!(ENABLE_EEG_CMD[0], 0x09);
assert_eq!(ENABLE_EEG_CMD[3], 0x60); assert_eq!(ENABLE_EEG_CMD[4], 0x01); }
#[test]
fn disable_eeg_cmd_structure() {
assert_eq!(DISABLE_EEG_CMD.len(), 5);
assert_eq!(DISABLE_EEG_CMD[3], 0x60);
assert_eq!(DISABLE_EEG_CMD[4], 0x00); }
#[test]
fn enable_disable_eeg_differ_only_in_last_byte() {
assert_eq!(ENABLE_EEG_CMD[..4], DISABLE_EEG_CMD[..4]);
assert_ne!(ENABLE_EEG_CMD[4], DISABLE_EEG_CMD[4]);
}
#[test]
fn enable_raw_mode_cmd_structure() {
assert_eq!(ENABLE_RAW_MODE_CMD.len(), 5);
assert_eq!(ENABLE_RAW_MODE_CMD[3], 0x41); assert_eq!(ENABLE_RAW_MODE_CMD[4], 0x01);
}
#[test]
fn disable_raw_mode_cmd_structure() {
assert_eq!(DISABLE_RAW_MODE_CMD[3], 0x41);
assert_eq!(DISABLE_RAW_MODE_CMD[4], 0x00);
}
#[test]
fn battery_cmd_structure() {
assert_eq!(BATTERY_CMD.len(), 5);
assert_eq!(BATTERY_CMD[3], 0x14); assert_eq!(BATTERY_CMD[4], 0xFF);
}
#[test]
fn all_commands_share_prefix() {
for cmd in [
&ENABLE_EEG_CMD,
&DISABLE_EEG_CMD,
&ENABLE_RAW_MODE_CMD,
&DISABLE_RAW_MODE_CMD,
&BATTERY_CMD,
&GET_WORLD_VOLUME_CMD,
&GET_AUTO_OFF_CMD,
&GET_IN_EAR_DETECTION_CMD,
&GET_FIND_DEVICE_CMD,
&GET_PRODUCT_ID_CMD,
®ISTER_BATTERY_CMD,
] {
assert_eq!(cmd[0], 0x09, "Wrong prefix byte 0");
assert_eq!(cmd[1], 0x9A, "Wrong prefix byte 1");
assert_eq!(cmd[2], 0x03, "Wrong prefix byte 2");
}
}
#[test]
fn fetch_all_has_four_commands() {
assert_eq!(FETCH_ALL_COMMANDS.len(), 4);
assert_eq!(FETCH_ALL_COMMANDS[0], GET_WORLD_VOLUME_CMD);
assert_eq!(FETCH_ALL_COMMANDS[1], GET_AUTO_OFF_CMD);
assert_eq!(FETCH_ALL_COMMANDS[2], GET_IN_EAR_DETECTION_CMD);
assert_eq!(FETCH_ALL_COMMANDS[3], GET_FIND_DEVICE_CMD);
}
#[test]
fn gaia_command_from_byte_known() {
assert_eq!(GaiaCommand::from_byte(0x11), Some(GaiaCommand::WorldVolume));
assert_eq!(
GaiaCommand::from_byte(0x14),
Some(GaiaCommand::GetBatteryLevel)
);
assert_eq!(GaiaCommand::from_byte(0x60), Some(GaiaCommand::EegMode));
assert_eq!(
GaiaCommand::from_byte(0x91),
Some(GaiaCommand::VolumeButtonPress)
);
}
#[test]
fn gaia_command_from_byte_unknown() {
assert_eq!(GaiaCommand::from_byte(0x00), None);
assert_eq!(GaiaCommand::from_byte(0x99), None);
assert_eq!(GaiaCommand::from_byte(0xFE), None);
}
#[test]
fn gaia_command_roundtrip() {
let commands = [
GaiaCommand::RegisterBattery,
GaiaCommand::ProductId,
GaiaCommand::WorldVolume,
GaiaCommand::AutoOff,
GaiaCommand::InEarDetection,
GaiaCommand::GetBatteryLevel,
GaiaCommand::DeviceName,
GaiaCommand::FindDevice,
GaiaCommand::EegMode,
GaiaCommand::RawMode,
GaiaCommand::RfcommStatus,
GaiaCommand::BatteryNotification82,
GaiaCommand::VolumeButtonPress,
GaiaCommand::UnknownE0,
GaiaCommand::BatteryNotification,
];
for cmd in commands {
let byte = cmd as u8;
let parsed = GaiaCommand::from_byte(byte).unwrap();
assert_eq!(parsed, cmd);
}
}
#[test]
fn anc_mode_payload_roundtrip() {
let modes = [
GaiaAncMode::Off,
GaiaAncMode::AncHigh,
GaiaAncMode::AncLow,
GaiaAncMode::AmbientVoice,
GaiaAncMode::AmbientAwareness,
GaiaAncMode::AncAdaptive,
];
for mode in modes {
let payload = mode.to_payload();
let parsed = GaiaAncMode::from_response(payload).unwrap();
assert_eq!(parsed, mode, "Roundtrip failed for {:?}", mode);
}
}
#[test]
fn anc_mode_from_button_press() {
assert_eq!(
GaiaAncMode::from_button_press(gaia_response::BTN_WV_OFF),
Some(GaiaAncMode::Off)
);
assert_eq!(
GaiaAncMode::from_button_press(gaia_response::BTN_WV_ANC_MAX),
Some(GaiaAncMode::AncHigh)
);
assert_eq!(
GaiaAncMode::from_button_press(gaia_response::BTN_WV_AMBIENT_AWARENESS),
Some(GaiaAncMode::AmbientAwareness)
);
assert_eq!(GaiaAncMode::from_button_press(0x99), None);
}
#[test]
fn anc_mode_display() {
assert_eq!(GaiaAncMode::Off.to_string(), "Off");
assert_eq!(GaiaAncMode::AncHigh.to_string(), "ANC High");
assert_eq!(GaiaAncMode::AncAdaptive.to_string(), "ANC Adaptive");
assert_eq!(
GaiaAncMode::AmbientAwareness.to_string(),
"Ambient (Awareness)"
);
}
#[test]
fn auto_off_payload_roundtrip() {
let values = [
GaiaAutoOff::Never,
GaiaAutoOff::ThirtyMinutes,
GaiaAutoOff::OneHour,
GaiaAutoOff::ThreeHours,
];
for val in values {
let payload = val.to_payload();
let parsed = GaiaAutoOff::from_response(payload).unwrap();
assert_eq!(parsed, val);
}
}
#[test]
fn auto_off_display() {
assert_eq!(GaiaAutoOff::Never.to_string(), "Never");
assert_eq!(GaiaAutoOff::ThirtyMinutes.to_string(), "30 minutes");
assert_eq!(GaiaAutoOff::OneHour.to_string(), "1 hour");
assert_eq!(GaiaAutoOff::ThreeHours.to_string(), "3 hours");
}
#[test]
fn parse_battery_response() {
let data = [0x09, 0x9A, 0x03, 0x14, 85];
let resp = parse_gaia_response(&data).unwrap();
assert_eq!(resp.command, GaiaCommand::GetBatteryLevel);
assert_eq!(resp.payload, &[85]);
}
#[test]
fn parse_anc_response() {
let data = [0x09, 0x9A, 0x03, 0x11, 0x02]; let resp = parse_gaia_response(&data).unwrap();
assert_eq!(resp.command, GaiaCommand::WorldVolume);
let mode = GaiaAncMode::from_response(resp.payload[0]).unwrap();
assert_eq!(mode, GaiaAncMode::AncHigh);
}
#[test]
fn parse_auto_off_response() {
let data = [0x09, 0x9A, 0x03, 0x12, 0x03]; let resp = parse_gaia_response(&data).unwrap();
assert_eq!(resp.command, GaiaCommand::AutoOff);
let auto_off = GaiaAutoOff::from_response(resp.payload[0]).unwrap();
assert_eq!(auto_off, GaiaAutoOff::OneHour);
}
#[test]
fn parse_success_response() {
let data = [0x09, 0x9A, 0x03, 0x11, 0xF1]; let resp = parse_gaia_response(&data).unwrap();
assert_eq!(resp.payload[0], gaia_response::SET_SUCCESS);
}
#[test]
fn parse_failure_response() {
let data = [0x09, 0x9A, 0x03, 0x11, 0xF0]; let resp = parse_gaia_response(&data).unwrap();
assert_eq!(resp.payload[0], gaia_response::SET_FAILED);
}
#[test]
fn parse_rejects_short_data() {
assert!(parse_gaia_response(&[0x09, 0x9A, 0x03]).is_none());
assert!(parse_gaia_response(&[]).is_none());
}
#[test]
fn parse_rejects_wrong_prefix() {
let data = [0xFF, 0xFF, 0xFF, 0x14, 85];
assert!(parse_gaia_response(&data).is_none());
}
#[test]
fn parse_rejects_unknown_command() {
let data = [0x09, 0x9A, 0x03, 0x99, 0xFF];
assert!(parse_gaia_response(&data).is_none());
}
#[test]
fn parse_multi_byte_payload() {
let data = [0x09, 0x9A, 0x03, 0x10, 0xAB, 0xCD]; let resp = parse_gaia_response(&data).unwrap();
assert_eq!(resp.command, GaiaCommand::ProductId);
assert_eq!(resp.payload, &[0xAB, 0xCD]);
}
#[test]
fn protocol_constants() {
assert_eq!(EEG_EVENT_ID, 239);
assert_eq!(PACKET_SIZE, 63);
assert_eq!(SYNC_BYTE, 0xAA);
assert_eq!(NUM_EEG_CHANNELS, 12);
assert_eq!(RFCOMM_CHANNEL, 25);
}
#[test]
fn scaling_factor_reasonable() {
let uv = EEG_SCALING_FACTOR * 1000.0;
assert!(uv > 20.0 && uv < 30.0, "Unexpected µV: {uv}");
}
#[test]
fn channel_names_count() {
assert_eq!(EEG_CHANNEL_NAMES.len(), NUM_EEG_CHANNELS);
}
#[test]
fn channel_names_format() {
for (i, name) in EEG_CHANNEL_NAMES.iter().enumerate() {
let expected = format!("Ch{}", i + 1);
assert_eq!(*name, expected.as_str());
}
}
#[test]
fn response_codes_match_gaia() {
assert_eq!(BLE_SUCCESS_CODE, gaia_response::SET_SUCCESS);
assert_eq!(BLE_SUCCESS_CODE_ALT, gaia_response::SET_FAILED);
assert_eq!(BLE_EEG_COMMAND, GaiaCommand::EegMode as u8);
assert_eq!(BLE_RAW_MODE_COMMAND, GaiaCommand::RawMode as u8);
assert_eq!(BLE_BATTERY_COMMAND, GaiaCommand::GetBatteryLevel as u8);
assert_eq!(BLE_RFCOMM_STATUS_COMMAND, GaiaCommand::RfcommStatus as u8);
}
#[test]
fn sentinel_value() {
assert_eq!(SENTINEL_VALUE, 8388607);
assert_eq!(SENTINEL_VALUE, (1 << 23) - 1);
}
#[test]
fn payload_and_response_values_match() {
assert_eq!(gaia_payload::WV_OFF, gaia_response::WV_ALL_OFF);
assert_eq!(gaia_payload::WV_ANC_HIGH, gaia_response::WV_ANC_HIGH);
assert_eq!(gaia_payload::WV_ANC_LOW, gaia_response::WV_ANC_LOW);
assert_eq!(
gaia_payload::WV_AMBIENT_VOICE,
gaia_response::WV_AMBIENT_VOICE
);
assert_eq!(
gaia_payload::WV_AMBIENT_AWARENESS,
gaia_response::WV_AMBIENT_AWARENESS
);
assert_eq!(
gaia_payload::WV_ANC_ADAPTIVE,
gaia_response::WV_ANC_ADAPTIVE
);
assert_eq!(gaia_payload::AUTO_OFF_NEVER, gaia_response::AUTO_OFF_NEVER);
assert_eq!(
gaia_payload::AUTO_OFF_30_MIN,
gaia_response::AUTO_OFF_30_MIN
);
assert_eq!(
gaia_payload::AUTO_OFF_1_HOUR,
gaia_response::AUTO_OFF_1_HOUR
);
assert_eq!(
gaia_payload::AUTO_OFF_3_HOURS,
gaia_response::AUTO_OFF_3_HOURS
);
assert_eq!(gaia_payload::IN_EAR_DISABLE, gaia_response::IN_EAR_DISABLE);
assert_eq!(gaia_payload::IN_EAR_ENABLE, gaia_response::IN_EAR_ENABLE);
assert_eq!(
gaia_payload::FIND_TONE_DISABLE,
gaia_response::FIND_TONE_DISABLE
);
assert_eq!(
gaia_payload::FIND_TONE_ENABLE,
gaia_response::FIND_TONE_ENABLE
);
}
#[test]
fn sample_rate_hz_values() {
assert!((SampleRate::Hz256.hz() - 256.0).abs() < f64::EPSILON);
assert!((SampleRate::Hz500.hz() - 500.0).abs() < f64::EPSILON);
}
#[test]
fn sample_rate_needs_raw_mode() {
assert!(!SampleRate::Hz256.needs_raw_mode());
assert!(SampleRate::Hz500.needs_raw_mode());
}
#[test]
fn sample_rate_default_is_500() {
assert_eq!(SampleRate::default(), SampleRate::Hz500);
}
#[test]
fn sample_rate_display() {
assert_eq!(SampleRate::Hz256.to_string(), "256 Hz");
assert_eq!(SampleRate::Hz500.to_string(), "500 Hz");
}
#[test]
fn sample_rate_interval_micros() {
assert_eq!(SampleRate::Hz500.interval_micros(), 2000);
assert_eq!(SampleRate::Hz256.interval_micros(), 3906);
}
#[test]
fn query_commands_use_0xff() {
for cmd in [
GET_WORLD_VOLUME_CMD,
GET_AUTO_OFF_CMD,
GET_IN_EAR_DETECTION_CMD,
GET_FIND_DEVICE_CMD,
GET_PRODUCT_ID_CMD,
BATTERY_CMD,
] {
assert_eq!(
cmd[4], GAIA_QUERY,
"Query command {:02X} should use 0xFF",
cmd[3]
);
}
}
}