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 |
= { = "1.6", = ["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.
Mouse control
device.move_rel?; // relative move (+x right, +y down)
device.wheel?; // scroll
device.press?; // force down
device.soft_release?; // release our press (a physical hold stays)
device.force_release?; // force up, masking a physical hold
device.button?; // 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.
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
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
let info = device.query_mouse_info?; // cloned mouse identity (vid:pid, bcd, serial/BOS flags)
let caps = device.query_caps?; // semantic caps; caps.is_composite(), caps.n_buttons
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 mouse input — buttons, wheel, and X/Y — 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. Dropping the stream unsubscribes.
use ;
let events = device.catch_events?; // or MOTION | WHEEL | BUTTONS
while let Ok = events.recv
The mask picks which classes trigger an event; each event carries the full snapshot (buttons, dx, dy, wheel), so diff buttons 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.
See docs/architecture.md for the deeper design notes.
License
MIT, see LICENSE.