A Rust library for controlling MAKCU USB HID interceptor devices.
MAKCU devices are small USB dongles that sit between a mouse and a host PC, allowing programmatic mouse control — cursor movement, button presses, scroll, input locking, and more — via serial commands over USB.
use makcu::{Device, Button, Result};
fn main() -> Result<()> {
let device = Device::connect()?;
device.move_xy(100, 50)?;
device.button_down(Button::Left)?;
device.button_up(Button::Left)?;
device.wheel(-3)?;
device.disconnect();
Ok(())
}The base crate exposes only firmware-native commands. Optional features add higher-level functionality:
| Feature | Description |
|---|---|
async |
AsyncDevice with full async parity (requires tokio) |
batch |
BatchBuilder — fluent command sequencing with coalesced writes |
extras |
Software-implemented click, smooth movement, drag, and event callbacks |
profile |
Per-command timing profiler (zero overhead when disabled) |
mock |
In-process mock transport for testing without hardware |
[dependencies]
makcu = { version = "0.1", features = ["batch", "extras"] }// Auto-detect by USB VID/PID
let device = Device::connect()?;
// Specific port
let device = Device::connect_port("/dev/ttyUSB0")?;
// Custom config
let device = Device::with_config(DeviceConfig {
fire_and_forget: true,
reconnect: true,
..Default::default()
})?;device.move_xy(100, -50)?; // relative move
device.silent_move(10, 10)?; // left-down → move → left-up (two HID frames)
device.wheel(3)?; // scroll up
device.button_down(Button::Left)?; // press
device.button_up(Button::Left)?; // release
device.button_up_force(Button::Left)?; // force-release (unstick)
device.button_state(Button::Left)?; // query: true/falsedevice.set_lock(LockTarget::X, true)?; // lock X axis
device.lock_state(LockTarget::X)?; // query lock state
device.lock_states_all()?; // all 7 locks at oncedevice.version()?; // firmware version string
device.serial()?; // current serial number
device.set_serial("custom")?; // spoof serial
device.reset_serial()?; // restore factory serialBy default, every command waits for the device's >>> response prompt (~1ms round trip). For maximum throughput, use fire-and-forget:
let ff = device.ff();
ff.move_xy(10, 0)?; // returns immediately after serial write
ff.wheel(1)?;device.enable_button_stream()?;
let rx = device.button_events();
// rx.try_recv() returns ButtonMask with per-button accessors
if let Ok(mask) = rx.try_recv() {
println!("left={} right={}", mask.left(), mask.right());
}
device.disable_button_stream()?;Coalesces multiple commands into a single write_all() call:
device.batch()
.move_xy(10, 0)
.move_xy(0, 10)
.button_down(Button::Left)
.button_up(Button::Left)
.wheel(1)
.execute()?;The firmware processes commands sequentially from its serial buffer — batching doesn't skip or merge commands. It just eliminates the inter-command gap on the host side by delivering all the bytes in one write, so the firmware always has the next command ready to read immediately.
Software-implemented operations with timing control:
use std::time::Duration;
device.click(Button::Left, Duration::from_millis(50))?;
device.click_sequence(Button::Left, Duration::from_millis(50), 3, Duration::from_millis(100))?;
device.move_smooth(200, 0, 20, Duration::from_millis(10))?;
device.drag(Button::Left, 100, 0, 10, Duration::from_millis(15))?;
device.move_pattern(&[(100, 0), (0, 100), (-100, 0), (0, -100)], 10, Duration::from_millis(10))?;Event callbacks:
let _handle = device.on_button_press(Button::Left, |pressed| {
println!("left button: {}", if pressed { "down" } else { "up" });
});
// Callback unregisters when handle is droppedFull async parity — every sync method has an async equivalent:
let device = AsyncDevice::connect().await?;
device.move_xy(100, 50).await?;
device.click(Button::Left, Duration::from_millis(50)).await?;
device.batch().move_xy(10, 0).wheel(1).execute().await?;Zero-cost when disabled. Records timing for every command:
use makcu::profiler;
device.move_xy(100, 0)?;
device.move_xy(-100, 0)?;
for (name, stat) in profiler::stats() {
println!("{}: {}x avg={}us min={}us max={}us",
name, stat.count, stat.avg_us as u64, stat.min_us, stat.max_us);
}
profiler::reset();Test without hardware:
let (device, mock) = Device::mock();
// Register responses for query commands
mock.on_command(b"km.version()\r\n", b"km.version()\r\nkm.MAKCU_L_V3.2\r\n>>> ");
let version = device.version()?;
assert_eq!(version, "MAKCU_L_V3.2");
// Inspect what was sent
assert!(mock.sent_commands().iter().any(|c| c == b"km.version()\r\n"));Escape hatch for firmware commands the library doesn't wrap:
let response = device.send_raw(b"km.version()\r\n")?;# Basic usage with real hardware
cargo run --example basic
# All features demonstrated
cargo run --example comprehensive --features "async,batch,extras,profile"
# Mock transport (no hardware)
cargo run --example mock --features "mock"
# Performance benchmark
cargo run --example benchmark --release --features "batch,extras"The library uses a multi-threaded transport layer:
- Writer thread coalesces pending commands into single
write_all()calls - Reader thread runs a
StreamParserstate machine that routes responses and fans out button events - Monitor thread handles automatic reconnection with exponential backoff
All Device methods take &self — I/O goes through channels. Device is Send + Sync and can be shared via Arc.
Communication is at 4 Mbaud over USB-serial (CH340/CH343 chip). The library auto-detects devices by USB VID/PID and handles the baud rate upgrade sequence automatically.
All numbers are averages of 3 runs on the same device (Linux, CH340 USB-serial).
| Metric | makcu | makcu-cpp | makcu-rs |
|---|---|---|---|
| Baud rate | 4 Mbaud | 4 Mbaud | 115,200 |
| What is measured | Real serial I/O | Serial write+flush | Channel enqueue* |
| Confirmed round-trip (move) | 999 us | N/A | N/A |
| 100 rapid F&F moves | 1333 us total | 4647 us total | 27 us total* |
| Batch 10 cmds | 16 us total | 470 us total | 3 us total* |
| Batch 50 moves | 12 us total | 2635 us total | 6 us total* |
*makcu-rs measures channel enqueue time, not serial I/O — the timer stops before bytes reach the serial port, producing sub-microsecond figures that don't reflect actual device latency.
Run cargo run --example benchmark --release --features "batch,extras" to reproduce.
MIT