diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/probe.rs | 215 | ||||
| -rw-r--r-- | src/hid.rs | 250 | ||||
| -rw-r--r-- | src/lib.rs | 2 | ||||
| -rw-r--r-- | src/main.rs | 46 | ||||
| -rw-r--r-- | src/protocol.rs | 23 |
5 files changed, 536 insertions, 0 deletions
diff --git a/src/bin/probe.rs b/src/bin/probe.rs new file mode 100644 index 0000000..d250faa --- /dev/null +++ b/src/bin/probe.rs @@ -0,0 +1,215 @@ +//! mmx150-probe — reverse-engineering tool for the MMX 150 dongle protocol. +//! +//! Phase 1 workflow: send candidate output reports to the dongle and observe +//! what input reports come back, correlating fields with headset on/off, +//! battery charge level and button presses. + +use std::process::ExitCode; +use std::time::Duration; + +use mmx150::hid::{find_devices_by_vendor, Hidraw, BEYERDYNAMIC_VID}; + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("list") => cmd_list(&args[1..]), + Some("scan") => cmd_scan(), + Some("send") => cmd_send(&args[1..]), + _ => { + eprintln!("usage: mmx150-probe <list|scan|send <report_id> <hex>...>"); + ExitCode::FAILURE + } + } +} + +fn cmd_list(_rest: &[String]) -> ExitCode { + match find_devices_by_vendor(BEYERDYNAMIC_VID) { + Ok(devices) => { + for d in &devices { + println!( + "{:04x}:{:04x} {} {}", + d.vid, + d.pid, + d.devnode.display(), + d.name + ); + } + if devices.is_empty() { + eprintln!("no beyerdynamic hidraw devices found"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +fn cmd_scan() -> ExitCode { + let Some(dev) = mmx150::hid::find_mmx150().unwrap_or(None) else { + eprintln!("MMX 150 dongle not found"); + return ExitCode::FAILURE; + }; + println!("device: {} at {}", dev.name, dev.devnode.display()); + let hid = match Hidraw::open(&dev.devnode) { + Ok(h) => h, + Err(e) => { + eprintln!("open failed: {e}"); + return ExitCode::FAILURE; + } + }; + + // Known report sizes from the report descriptor: + // 62-byte reports: 0x24/0x27, 0x80/0x81, 0x34/0x37 + // 60-byte reports: 0x2a/0x2b, 0x3a/0x3b + // 16-byte reports: 0x2c/0x2d, 0x3c/0x3d + for (report_id, len) in [ + (0x24, 63usize), + (0x34, 63), + (0x80, 63), + (0x3a, 61), + (0x3b, 61), + (0x2a, 61), + (0x2b, 61), + (0x3d, 17), + (0x2d, 17), + ] { + match hid.get_feature(report_id, len) { + Ok(buf) => println!("FEATURE 0x{report_id:02x}: {}", hex(&buf)), + Err(e) => println!("FEATURE 0x{report_id:02x}: {e}"), + } + } + for (report_id, len) in [ + (0x27, 63usize), + (0x37, 63), + (0x81, 63), + (0x3c, 17), + (0x3d, 17), + (0x2c, 17), + (0x2d, 17), + ] { + match hid.get_input(report_id, len) { + Ok(buf) => println!("GINPUT 0x{report_id:02x}: {}", hex(&buf)), + Err(e) => println!("GINPUT 0x{report_id:02x}: {e}"), + } + } + ExitCode::SUCCESS +} + +fn cmd_send(rest: &[String]) -> ExitCode { + if rest.len() < 2 { + eprintln!("usage: mmx150-probe send <report_id> <hex bytes...>"); + return ExitCode::FAILURE; + } + let Ok(report_id) = u8::from_str_radix(rest[0].trim_start_matches("0x"), 16) else { + eprintln!("bad report id"); + return ExitCode::FAILURE; + }; + let mut data = vec![report_id]; + for part in &rest[1..] { + let bytes = parse_hex(part); + if bytes.is_empty() { + eprintln!("bad hex: {part}"); + return ExitCode::FAILURE; + } + data.extend(bytes); + } + + let Some(dev) = mmx150::hid::find_mmx150().unwrap_or(None) else { + eprintln!("MMX 150 dongle not found"); + return ExitCode::FAILURE; + }; + let hid = match Hidraw::open(&dev.devnode) { + Ok(h) => h, + Err(e) => { + eprintln!("open failed: {e}"); + return ExitCode::FAILURE; + } + }; + let mut hid = hid; + + println!( + ">> 0x{report_id:02x} [{}B] {}", + data.len() - 1, + hex(&data[1..]) + ); + + // Try both the interrupt OUT endpoint and the control-transfer output + // report, then watch for a response on the interrupt IN endpoint. + match hid.write(&data) { + Ok(n) => println!("write() ok ({n}B)"), + Err(e) => println!("write() failed: {e}"), + } + match hid.send_output(&data) { + Ok(n) => println!("HIDIOCGSOUTPUT ok ({n}B)"), + Err(e) => println!("HIDIOCGSOUTPUT failed: {e}"), + } + + read_responses(&mut hid); + ExitCode::SUCCESS +} + +/// Poll the interrupt IN endpoint for a few seconds, printing reports. +fn read_responses(hid: &mut Hidraw) { + let deadline = std::time::Instant::now() + Duration::from_millis(3000); + loop { + let now = std::time::Instant::now(); + if now >= deadline { + break; + } + let mut set = unsafe { std::mem::zeroed::<libc::fd_set>() }; + unsafe { libc::FD_ZERO(&mut set) }; + unsafe { libc::FD_SET(hid.as_raw_fd(), &mut set) }; + let remaining = deadline.saturating_duration_since(now).as_millis(); + let mut timeout = libc::timeval { + tv_sec: (remaining / 1000) as i64, + tv_usec: ((remaining % 1000) * 1000) as i64, + }; + let n = unsafe { + libc::select( + hid.as_raw_fd() + 1, + &mut set, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut timeout, + ) + }; + if n <= 0 { + break; + } + let mut buf = [0u8; 256]; + match hid.read(&mut buf) { + Ok(len) => println!("<< [{}B] {}", len, hex(&buf[..len])), + Err(e) => { + println!("read failed: {e}"); + break; + } + } + } +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::<Vec<_>>() + .join(" ") +} + +fn parse_hex(s: &str) -> Vec<u8> { + let s = s.trim_start_matches("0x"); + if s.len() % 2 != 0 { + return Vec::new(); + } + s.as_bytes() + .chunks(2) + .map(|c| { + let hi = (c[0] as char).to_digit(16)?; + let lo = (c[1] as char).to_digit(16)?; + Some((hi * 16 + lo) as u8) + }) + .collect::<Option<Vec<u8>>>() + .unwrap_or_default() +} diff --git a/src/hid.rs b/src/hid.rs new file mode 100644 index 0000000..da70d81 --- /dev/null +++ b/src/hid.rs @@ -0,0 +1,250 @@ +//! Direct hidraw access for the beyerdynamic MMX 150 wireless dongle. +//! +//! The dongle exposes a vendor HID interface (`2e50:0035`) alongside its USB +//! audio interfaces. The kernel binds it to `hid-generic`, which shows up as +//! `/dev/hidrawN`. We talk to it with the standard `HIDIOCGFEATURE`, +//! `HIDIOCSFEATURE`, `HIDIOCGINPUT`, `HIDIOCGSOUTPUT` ioctls plus plain +//! `read`/`write` on the interrupt endpoints. + +use std::fs; +use std::io; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; + +/// beyerdynamic USB vendor ID. +pub const BEYERDYNAMIC_VID: u16 = 0x2e50; +/// MMX 150 wireless low-latency dongle product ID. +pub const MMX150_DONGLE_PID: u16 = 0x0035; + +const HIDRAW_SYSFS: &str = "/sys/class/hidraw"; + +const IOC_NRSHIFT: u32 = 0; +const IOC_TYPESHIFT: u32 = 8; +const IOC_SIZESHIFT: u32 = 16; +const IOC_DIRSHIFT: u32 = 30; +const IOC_READ: u32 = 2; +const IOC_WRITE: u32 = 1; + +fn ioc(dir: u32, ty: u8, nr: u32, size: u32) -> libc::c_ulong { + ((dir << IOC_DIRSHIFT) + | ((ty as u32) << IOC_TYPESHIFT) + | (nr << IOC_NRSHIFT) + | (size << IOC_SIZESHIFT)) as libc::c_ulong +} + +const HIDIOCGFEATURE_NR: u32 = 0x06; +const HIDIOCSFEATURE_NR: u32 = 0x07; +const HIDIOCGINPUT_NR: u32 = 0x0a; +const HIDIOCGSOUTPUT_NR: u32 = 0x0d; + +fn hid_get_feature_cmd(size: u32) -> libc::c_ulong { + ioc(IOC_READ, b'H', HIDIOCGFEATURE_NR, size) +} +fn hid_set_feature_cmd(size: u32) -> libc::c_ulong { + ioc(IOC_WRITE, b'H', HIDIOCSFEATURE_NR, size) +} +fn hid_get_input_cmd(size: u32) -> libc::c_ulong { + ioc(IOC_READ, b'H', HIDIOCGINPUT_NR, size) +} +fn hid_get_output_cmd(size: u32) -> libc::c_ulong { + ioc(IOC_WRITE, b'H', HIDIOCGSOUTPUT_NR, size) +} + +/// A HID device discovered on the system. +#[derive(Debug, Clone)] +pub struct HidDevice { + pub vid: u16, + pub pid: u16, + pub name: String, + pub devnode: PathBuf, +} + +/// Enumerate hidraw devices matching `vid`/`pid` by reading the sysfs `uevent` +/// of each `/sys/class/hidraw/hidrawN`. +pub fn find_devices(vid: u16, pid: u16) -> io::Result<Vec<HidDevice>> { + let mut out = Vec::new(); + for entry in fs::read_dir(HIDRAW_SYSFS)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("hidraw") { + continue; + } + let Some(uevent) = read_sysfs(&entry.path().join("device/uevent")) else { + continue; + }; + let Some((bus, v, p)) = parse_hid_id(&uevent) else { + continue; + }; + if bus != 3 { + continue; // USB only + } + if v == vid && p == pid { + let dev_name = uevent + .lines() + .find(|l| l.starts_with("HID_NAME=")) + .map(|l| l.trim_start_matches("HID_NAME=").to_string()) + .unwrap_or_default(); + out.push(HidDevice { + vid: v, + pid: p, + name: dev_name, + devnode: PathBuf::from("/dev").join(name), + }); + } + } + Ok(out) +} + +/// Find the first (only) MMX 150 dongle HID device. +pub fn find_mmx150() -> io::Result<Option<HidDevice>> { + Ok(find_devices(BEYERDYNAMIC_VID, MMX150_DONGLE_PID)? + .into_iter() + .next()) +} + +/// Enumerate all hidraw devices belonging to a vendor. +pub fn find_devices_by_vendor(vid: u16) -> io::Result<Vec<HidDevice>> { + let mut out = Vec::new(); + for entry in fs::read_dir(HIDRAW_SYSFS)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("hidraw") { + continue; + } + let Some(uevent) = read_sysfs(&entry.path().join("device/uevent")) else { + continue; + }; + let Some((bus, v, p)) = parse_hid_id(&uevent) else { + continue; + }; + if bus != 3 || v != vid { + continue; + } + let dev_name = uevent + .lines() + .find(|l| l.starts_with("HID_NAME=")) + .map(|l| l.trim_start_matches("HID_NAME=").to_string()) + .unwrap_or_default(); + out.push(HidDevice { + vid: v, + pid: p, + name: dev_name, + devnode: PathBuf::from("/dev").join(name), + }); + } + Ok(out) +} + +/// An open hidraw file descriptor. +pub struct Hidraw { + file: fs::File, + pub devnode: PathBuf, +} + +impl Hidraw { + pub fn open(devnode: impl AsRef<Path>) -> io::Result<Hidraw> { + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .open(devnode.as_ref())?; + Ok(Hidraw { + file, + devnode: devnode.as_ref().to_path_buf(), + }) + } + + /// Returns the underlying raw file descriptor. + pub fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.file.as_raw_fd() + } + + /// `HIDIOCGFEATURE`: fetch `report_id` into `data` (report length + 1 + /// bytes including the report-id byte). Returns bytes received. + pub fn get_feature(&self, report_id: u8, len: usize) -> io::Result<Vec<u8>> { + let mut buf = vec![0u8; len]; + buf[0] = report_id; + self.raw_ioctl(hid_get_feature_cmd(len as u32), &mut buf)?; + Ok(buf) + } + + /// `HIDIOCSFEATURE`: set a feature report (`data[0]` is the report id). + pub fn set_feature(&self, data: &[u8]) -> io::Result<usize> { + self.raw_ioctl(hid_set_feature_cmd(data.len() as u32), &mut data.to_vec()) + } + + /// `HIDIOCGINPUT`: fetch an input report by id. + pub fn get_input(&self, report_id: u8, len: usize) -> io::Result<Vec<u8>> { + let mut buf = vec![0u8; len]; + buf[0] = report_id; + self.raw_ioctl(hid_get_input_cmd(len as u32), &mut buf)?; + Ok(buf) + } + + /// `HIDIOCGSOUTPUT`: send an output report (`data[0]` is the report id) + /// via a control transfer, without going through the interrupt endpoint. + pub fn send_output(&self, data: &[u8]) -> io::Result<usize> { + self.raw_ioctl(hid_get_output_cmd(data.len() as u32), &mut data.to_vec()) + } + + /// Read from the interrupt IN endpoint (asynchronous input reports). + pub fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { + use std::io::Read; + self.file.read(buf) + } + + /// Write to the interrupt OUT endpoint. + pub fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + use std::io::Write; + self.file.write(buf) + } + + fn raw_ioctl(&self, request: libc::c_ulong, buf: &mut [u8]) -> io::Result<usize> { + // SAFETY: buf is owned and valid for the duration of the call. + let ret = unsafe { + libc::ioctl( + self.file.as_raw_fd(), + request, + buf.as_mut_ptr() as *mut libc::c_void, + ) + }; + if ret < 0 { + return Err(io::Error::last_os_error()); + } + Ok(ret as usize) + } +} + +fn read_sysfs(path: &Path) -> Option<String> { + fs::read_to_string(path).ok() +} + +/// Parse the `HID_ID=<bus>:<vendor>:<product>` line (hex values). +fn parse_hid_id(uevent: &str) -> Option<(u32, u16, u16)> { + let line = uevent.lines().find(|l| l.starts_with("HID_ID="))?; + let id = line.trim_start_matches("HID_ID="); + let mut parts = id.split(':'); + let bus = u32::from_str_radix(parts.next()?, 16).ok()?; + let vid = u16::from_str_radix(parts.next()?, 16).ok()?; + let pid = u16::from_str_radix(parts.next()?, 16).ok()?; + Some((bus, vid, pid)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hid_id() { + let uevent = "MAJOR=240\nMINOR=1\nDEVNAME=hidraw1\nHID_ID=0003:00002E50:00000035\nHID_NAME=beyerdynamic Dongle MMX 150 wireless\n"; + let (bus, vid, pid) = parse_hid_id(uevent).unwrap(); + assert_eq!(bus, 3); + assert_eq!(vid, 0x2e50); + assert_eq!(pid, 0x0035); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4a255f7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod hid; +pub mod protocol; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4648631 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,46 @@ +//! mmx150 — battery and connection monitoring for the beyerdynamic MMX 150 +//! wireless dongle. + +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("status") => cmd_status(), + Some("daemon") => cmd_daemon(), + Some("probe") => cmd_probe(), + _ => { + eprintln!("usage: mmx150 <status|daemon|probe>"); + ExitCode::FAILURE + } + } +} + +fn cmd_status() -> ExitCode { + match mmx150::hid::find_mmx150() { + Ok(Some(dev)) => { + println!("dongle: {} ({}:{:04x})", dev.name, dev.vid, dev.pid); + println!("connection: unknown (protocol reverse-engineering in progress)"); + println!("battery: unknown"); + ExitCode::SUCCESS + } + Ok(None) => { + eprintln!("dongle not found"); + ExitCode::FAILURE + } + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +fn cmd_daemon() -> ExitCode { + eprintln!("daemon not implemented yet"); + ExitCode::FAILURE +} + +fn cmd_probe() -> ExitCode { + eprintln!("use `mmx150-probe` binary instead"); + ExitCode::FAILURE +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..5402e16 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,23 @@ +//! Status protocol for the MMX 150 wireless dongle (reverse-engineered). +//! +//! Placeholder: the dongle rejects `GET_FEATURE`/`GET_INPUT` control +//! transfers, so the status query is a write-then-read exchange over the +//! vendor output/feature reports. The exact command bytes and the response +//! layout for battery level and headset link state are being determined in +//! Phase 1 (see `src/bin/probe.rs`). + +/// Vendor output report IDs available on the dongle. +pub mod reports { + /// 60-byte output/feature report. + pub const OUT_60_A: u8 = 0x2a; + /// 60-byte feature report. + pub const FEAT_60_B: u8 = 0x2b; + /// 60-byte output/feature report. + pub const OUT_60_C: u8 = 0x3a; + /// 60-byte feature report. + pub const FEAT_60_D: u8 = 0x3b; + /// 16-byte output report. + pub const OUT_16_A: u8 = 0x2d; + /// 16-byte output report. + pub const OUT_16_B: u8 = 0x3d; +} |
