aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorjamie <git@skilet.ro>2026-08-16 21:20:57 +0100
committerjamie <git@skilet.ro>2026-08-29 22:06:00 +0100
commitbe625dc771273289d2d9681c02e8e767c3ffbc2f (patch)
treefefe0acaa9c5a8307d5a2ad91dcfa507cc54e969 /src
chore: init
Diffstat (limited to 'src')
-rw-r--r--src/bin/probe.rs215
-rw-r--r--src/hid.rs250
-rw-r--r--src/lib.rs2
-rw-r--r--src/main.rs46
-rw-r--r--src/protocol.rs23
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;
+}