medius
Custom firmware for MAKCU mouse-passthrough boxes, and the Rust library that drives it.
A MAKCU box sits inline between a mouse and a PC: the real mouse passes through to the PC while a control program injects movement, buttons, and scroll over USB-serial. medius replaces the stock firmware with a clean binary protocol; this crate binds its commands 1:1 and adds what you need to run the box reliably (handshake, keepalive, reconnect). Each call sends one firmware frame.
Flash and test a box from your browser at medius.k4tech.net/dashboard: no drivers, nothing to install. Full documentation is at medius.k4tech.net.
Why medius vs stock firmware
Same MAKCU box, different firmware. Both clone your mouse's USB descriptor byte for byte, since that's the hardware. What changes is how the firmware behaves:
| medius | stock MAKCU | |
|---|---|---|
| Your motion | Injection adds to your real movement. Both go through, nothing lost. | Injection overwrites it. At 1 kHz your real motion never arrives. |
| Detection | Measured against the native mouse and matched: timing, control values, USB conformance. | Copies the descriptor; no published native-behaviour audit. |
| Reliability | Clears all injection after 1 s of host silence, so a crashed controller never leaves a button held. | No silence release documented; a forced button stays held until you clear it. |
| Link | Binary frames with CRC and request IDs, at a fixed baud. | An ASCII command prompt, replies matched by arrival order, behind a baud handshake that doesn't persist a power cycle. |
Quick start
[]
= "1.6"
use ;
Features
The base crate is the lean sync core. Optional features:
| Feature | Description |
|---|---|
async |
AsyncDevice, async queries over the same core, runtime-agnostic (no tokio) |
mock |
MockBox, an in-process fake box for tests without hardware |
flash |
esptool reboot-to-download + firmware flash handoff |
tracing |
per-frame TX/RX tracing instrumentation |
= { = "3.0", = ["async", "mock"] }
API
Connect
let device = find?; // first box by VID/PID (0x1A86:0x55D3)
let device = open?; // a specific port
open/find run a version handshake and reject a mismatched protocol.
Multiple boxes
for b in list
let m = find_mouse_box?; // the box cloning a mouse
let k = find_keyboard_box?; // the box cloning a keyboard
let d = open_by_id?; // by device MAC (or CH343 serial)
Each box's identity is its device-chip MAC; a reopened box reconnects to the same physical unit even after ports renumber.
Mouse control
device.move_rel?; // relative move (+x right, +y down)
device.wheel?; // scroll
device.press?; // force down
device.release?; // release our press (a physical hold stays)
device.force_release?; // force up, masking a physical hold
device.inject?; // the generic form
device.reset?; // clear all injection → passthrough
Buttons are Left, Right, Middle, Side1, Side2. Move and wheel take a full i16; the firmware clamps to the mouse's descriptor with carry, so move_rel(2000, 0) lands as exactly 2000.
Keyboard & media
use ;
device.press?; // hold a key (a modifier like Key::LEFT_SHIFT folds in)
device.release?; // release our press (a physical hold stays)
device.force_release?; // force up, masking a physical hold
device.inject?; // the generic form
device.press?; // a media key by 16-bit Consumer usage
device.release?;
Keys are HID keycodes (Key::A, Key::ENTER, the eight modifiers, F-keys, arrows…) or any usage via Key::new(0x04); media keys are Consumer usages (MediaKey::VOLUME_UP, PLAY_PAUSE, MUTE…). The tri-state Action (press / soft-release / force-release) is shared with buttons. Held keys and media survive a reconnect, like buttons. Both are present-gated: a key the board can't report is a silent no-op; see caps().
Sustained motion
You drive sustained motion yourself, one fire-and-forget move_rel per tick. The firmware merges additively with no halving and carries the remainder, so a tight 1 kHz loop lands the full distance (the box paces the emitted reports to the mouse's native report rate):
for _ in 0..1000
Buffered clip playback
For jitter-free playback, preload per-frame input into a device-side ring and let the box drain one entry per native frame, box-clocked, so it carries none of the host's scheduling jitter and none of the per-command send floor. Motion is a per-frame delta, edges (buttons/keys/media) are sticky until changed, and a gap run emits nothing for N frames. Pace top-ups off query_status().free.
use ;
let mut b = new;
for _ in 0..1000 // 1000 frames of +1 dx, box-timed
b.press.gap.release;
let clip = device.clip;
clip.append?;
clip.start?; // or a trigger: clip.bind(ClipTrigger::new(Button::Side1, Edge::Press, ClipAction::Start))?
Queries
let v = device.query_version?; // proto_ver + fw_major / fw_minor / fw_patch
let h = device.query_health?; // link_up, mouse_attached, clone_configured, injection_active, rate_confident, lock_on, catch_on, kbd_attached
let info = device.device_info?; // cloned device identity: vid:pid, bcd, flags, kind, product
let caps = device.caps?; // unified caps; caps.is_composite(), caps.mouse.n_buttons, caps.keyboard.nkro, caps.keyboard.has_consumer, caps.keyboard.n_keys
let rate = device.query_rate?; // live native report rate; rate.native_hz()
let stats = device.query_stats?; // delivery counters; stats.tx_drops / stats.tx_wedges
let locks = device.query_locks?; // active input locks; locks.is_locked(target, direction)
let catch = device.query_catch?; // active catch mask + box-side dropped count
Catch (physical input events)
Subscribe to the user's real input (mouse buttons/wheel/motion, keyboard keys, and media keys) as it happens. The box reports each physical report before any lock suppression or injection, so you can intercept an input (lock it) and rebind it (catch it) in one loop. One device-class-generic stream yields a CatchEvent; match on the variant. Dropping the stream unsubscribes.
use ;
let events = device.catch_events?; // MOTION | WHEEL | BUTTONS | KEYS | MEDIA
while let Ok = events.recv
The mask picks which classes stream; each CatchEvent is either Motion (relative dx/dy/dz) or Usages (a held-usage snapshot for one class: buttons, keys, or media); so diff successive snapshots for edges. The stream is bounded and lossy under back-pressure (events.dropped()), and the subscription is held alive by the keepalive and re-asserted across a reconnect. Under async, events.recv_async().await.
Box management
device.reboot?; // restart a chip (run / ROM-download × device / host)
device.reconnect?; // rescan VID/PID, reopen, re-assert held state
device.reapply?; // re-send currently-held overrides on demand
The reader also reconnects on its own if the link drops.
Observability
for line in device.logs
let c = device.counters; // frames_tx / frames_rx / crc_drops / reconnects
Async (feature = async)
The async wrapper is the same core. Only queries await; the fire-and-forget commands are identical:
let device = find?.into_async;
device.move_rel?; // instant, not async
let v = device.query_version.await?; // awaits the correlated reply
It uses flume's async recv, so there's no runtime dependency and it runs on any executor.
Mock (feature = mock)
use ;
let mock = new
.with_version
.with_health
.with_rate;
let device = with_mock; // the real stack over a fake box
assert_eq!;
assert_eq!;
device.press?;
assert!; // commands are recorded
Examples
Architecture
Four layers, protocol → transport → link → device, each depending only on the one below it. protocol is the wire codec: framed binary (SOF, type, rolling SEQ, length, payload, CRC16), no I/O. transport is the byte pipe over the serialport crate (cross-platform, no unsafe) plus VID/PID discovery. link runs the live connection: the reader thread, SEQ-correlated queries, keepalive, and reconnect. device is the typed API on top, where each command is one link.send(...).
Device takes &self, is Send + Sync, and clones cheaply. The link runs at a fixed 4 Mbaud in framed binary (no baud dance, no ASCII REPL), and queries correlate by SEQ rather than arrival order. If the host goes quiet for ~1 s the firmware clears all injection, so a crash never leaves a button stuck; a keepalive thread keeps an intentionally-held button alive. Tested on Linux and Windows.
Other languages
A C ABI (the medius-capi crate) exports the whole API for other languages. The
generated header compiles as C and C++, and a ctypes Python package rides on top.
See bindings/.
License
MIT, see LICENSE.