anybox-rs 0.7.0

one API for the most popular passthrough boxes
Documentation

anybox

one API for the most popular passthrough boxes

what it does

anybox talks to the passthrough box plugged into your PC, so you write your app once and it works with all the supported boxes the same way. when you connect, it figures out which box it is, the firmware, and what it can do, so you don't have to

if a box can't do something, you get a clear error telling you so. nothing is faked or changed behind your back, anybox reproduces each box's native behavior as closely as it can, and the differences i found are all written down in box-specific behavior

some boxes can do things the others can't, and anybox doesn't hide those. you get that box's own API from the device, so you can use them next to everything else

boxes

KMBox B Pro and Net might get added if enough people ask for it, otherwise i won't spend time on it

firmware mouse wheel pan keys media keys locks scale events
Medius 3.4.2+ ✓ ✓ ✓ ✓ ✓ ✓ ✓ all
MAKCU V4.085+ ✓ ✓ ✗ ✓ ✗ ✓ ✗ buttons, keys
Ferrum docs only ✓ ✓ ✗ app ✗ ✓ ✗ all but media
YSC8K docs only ✓ ✓ ✗ ✓ ✗ ✓ ✗ buttons, motion

Ferrum and YSC8K are written from the vendor docs and reference code, nobody has run these backends on real ones yet, so expect surprises and tell me about them. "app" means only through the Ferrum app's port

not every box can do everything, and what each box can do has every method with the small differences. what you actually get also depends on what's plugged into the box, a mouse without horizontal scroll means no pan for example, so device.snapshot().capabilities is what tells you for sure, at runtime

install

cargo add anybox-rs

it's still use anybox::... in your code. by default every box is built in, if you only ever talk to one kind you can leave the others out:

cargo add anybox-rs --no-default-features --features medius

on Linux it needs libudev to find the boxes, that's libudev-dev and pkg-config on Debian and Ubuntu, systemd-devel on Fedora. your user also has to be allowed to open serial ports, the dialout group on most distros (uucp on Arch)

to test your own code without a box plugged in, turn on the mock feature. Device::mock_medius gives you a device over the medius crate's mock box, running the exact same code a real Medius gets. it only proves your code works though, not how the hardware behaves

if a box acts weird and you want to see what actually goes over the wire, turn on the trace feature and set ANYBOX_TRACE to a file path. every byte anybox sends and gets back ends up in that file with a timestamp, the Medius is the one exception since it talks through the medius crate

usage

use anybox::{Button, Key};

fn main() -> anybox::Result<()> {
    let device = anybox::connect()?;
    println!("{:?}", device.snapshot().info);

    device.move_by(100, 0)?;
    device.click(Button::Left)?;
    device.tap_key(Key::A)?;
    Ok(())
}

connect() takes the first box it finds. anybox::scan() gives every port it looks at with what's on it, the box and what it can do or why you can't use it (busy, not allowed, too old, nothing there etc), which is what a box picker wants. Connect::port("COM18") or Connect::auto().family(Family::Medius) opens the one you want, and Connect::auto().can(Action::Move) opens the first one that can move, so a box with only a keyboard behind it gets skipped. a scan opens every box, so don't run it every second, candidates() below is the cheap one. a Ferrum isn't found on its own yet, open it with Connect::port or .family(Family::Ferrum)

a YSC8K (the V1, the only one sold so far) sits behind the same USB chip as a Medius and a MAKCU, so it only gets asked with .family(Family::Ysc), and that asks every one of those ports at up to 9 rates (about 2 s each when nothing YSC is there). a YSC or a Ferrum only comes back on the same port after a reconnect, so if its port name changes, connect again

to notice a box getting plugged in, anybox::candidates() lists the ports a search would look at without opening any of them, so you can call it every second and only run scan() when the list changes. snapshot().info.peripheral has the USB ids of the device plugged into the box, which is what the box shows the PC, on a Medius and on a Ferrum's app port. a MAKCU has no way to say it

what happens on the box comes in as events:

use anybox::{Button, Event};

fn main() -> anybox::Result<()> {
    let device = anybox::connect()?;
    for event in device.events() {
        match event {
            Event::Button { button: Button::Side1, pressed: true, .. } => println!("side button down"),
            Event::Disconnected => println!("the box is gone, anybox keeps looking for it"),
            Event::Reconnected => println!("it's back"),
            _ => {}
        }
    }
    Ok(())
}

Event::Changed means the snapshot changed without a reconnect, a mouse set in the Ferrum app for example, so read snapshot() again. a MAKCU gives a Reconnected for a mouse or keyboard coming or going instead, so watch both

a receiver can ask for only some kinds, device.events_of(EventKinds::BUTTONS) for a hotkey for example, and then the box doesn't stream motion or keys just for it. the box only streams while at least one receiver wants something. just so you know, a receiver of keys sees every key typed on the keyboard behind the box, passwords included

every event has a stamp. received is when anybox got it, which is what you want next to a capture or your own timing. arrived is anybox's best guess at when it happened, and on a Medius that's the box clock mapped onto the PC's and smoothed, so it's NOT when the PC got it

when you drop the device, anybox tries to let go of everything you still hold and unlock everything you locked. if the box is unplugged right then it can't, so call close() instead when you want to know whether it worked. close() also works on a device you share between threads, and after it every command fails with Error::Closed

when a box can't do something

anybox never makes up something a box can't do, that's your responsibility. if you want it anyway, put it in your own trait on Device and build it out of what the box can do

anybox has no type_text for example, so here it is made of key taps:

use anybox::{Device, Key};

trait TypeText {
    fn type_text(&self, text: &str) -> anybox::Result<()>;
}

impl TypeText for Device {
    fn type_text(&self, text: &str) -> anybox::Result<()> {
        for c in text.chars() {
            // a us layout and only the basics, add whatever your app needs
            let (key, shift) = match c {
                'a'..='z' => (Key(Key::A.0 + (c as u8 - b'a')), false),
                'A'..='Z' => (Key(Key::A.0 + (c as u8 - b'A')), true),
                '1'..='9' => (Key(Key::NUM1.0 + (c as u8 - b'1')), false),
                '0' => (Key::NUM0, false),
                ' ' => (Key::SPACE, false),
                '\n' => (Key::ENTER, false),
                _ => continue,
            };
            if shift {
                self.press_key(Key::LEFT_SHIFT)?;
            }
            let tapped = self.tap_key(key);
            if shift {
                self.release_key(Key::LEFT_SHIFT)?;
            }
            tapped?;
        }
        Ok(())
    }
}

fn main() -> anybox::Result<()> {
    let device = anybox::connect()?;
    device.type_text("Hello there\n")
}

moving from another library

if your app already talks to a box directly, moving over is mostly swapping your calls for anybox's. a few things that help:

  • anybox opens the port itself and a port only opens once, so your own serial code goes away completely
  • let connect() find the box instead of a hardcoded port, device.snapshot() tells you which box and firmware it found and what it can do
  • delete your reconnect loop and your "release everything on exit" code, anybox does both
  • handle Error::Unsupported where a box might not have something, don't fake it
  • on a Medius, whatever only the medius crate does stays reachable through device.medius(), so use that instead of a second dependency
I'm moving this project from talking to a passthrough box directly to anybox, a library that drives
these boxes through one API. Its core is the Rust crate `anybox-rs` (imported as `anybox`), and the
README lists the bindings for other languages (a C ABI for C and C++, Python and so on).

Before changing anything, read:
- the README at https://github.com/etherealrl/anybox (API overview, which languages are supported and
  how to install each, box-specific behavior, the pattern for adding things a box can't do)
- docs/boxes.md in that repo (what each box supports, method by method)
- the API docs for this project's language: https://docs.rs/anybox-rs for Rust, or the docs the README
  links for the binding

Use the Rust crate if this project is Rust, otherwise the binding for its language. If the README has
no binding for this language yet, stop and tell me instead of writing one.

Then:
1. Find every place this code talks to the box (opening the port, sending commands, reading input,
   reconnecting, cleanup on exit) and list them.
2. Replace each with its anybox equivalent. Where anybox has none, keep the box's own API (e.g. on a Medius
   through anybox's access to the medius library) or tell me it's missing. Never emulate a feature the
   box doesn't have.
3. Remove our own reconnect and release-on-exit logic, anybox handles both.
4. Build and run the tests, and tell me what couldn't be checked without the box plugged in.

box-specific behavior

YSC8K

  • the YSC8K you can buy is the V1, one chip behind a USB serial chip. none of this was checked on a real one yet, it all comes from the vendor docs and the vendor's own app
  • the vendor also documents a V2 with two chips and its own USB ports. it isn't out yet, so its support only comes from their docs, and a V2 gets found on its own like a Medius. on a V2 anybox talks to its TOWMCULEFT port, the other two ports of the box never get a single byte
  • a V1 can sit at any of 9 rates, so anybox looks for it the way the vendor's app does, 4M first, and never changes the rate. its firmware is the build date it gives back, and if it's in its bootloader you get told to finish the firmware update first
  • the vendor says V1 and V2 take the same commands, though keys aren't in their V1 list, so i don't know yet if keys work on a V1
  • clicks and key taps are timed on your PC, and the wheel takes one notch at a time
  • keys need a keyboard behind the box. if the box says there's none, keys turn off until you connect again
  • anybox can't tell yet whether a mouse is behind the box, so moves, presses and button events say they work even when nothing is plugged in
  • if the box is in debug mode, leave it in the vendor tool first, anybox won't connect otherwise
  • locks cover the five buttons and a whole X or Y axis, not one direction of it
  • buttons and motion come in one stream, and anybox doesn't know what was already held when you start listening, so a button that's already down when the first report comes in never shows its press, only its release
  • the box's curve and interpolation settings can shape your moves, anybox leaves them alone
  • i don't know yet whether events keep coming while something is locked, or what stays held when the port closes or the box loses power

Ferrum

  • none of this was checked on a real Ferrum yet, it all comes from the vendor docs
  • anybox does the most on the Ferrum app's virtual port, which needs the app running on Windows. the box's own port (the legacy one, and the only way in from Linux) only moves, clicks, scrolls, locks buttons and whole axes, and reports buttons. Mouse Proxy, controllers and the app's network APIs aren't in anybox yet
  • connect() doesn't find it by itself since we don't know its USB ids yet, so use Connect::port or .family(Family::Ferrum). on Windows that search only takes the app's port, add .ferrum_api(FerrumApi::Legacy) if you really want the box's own one. device.snapshot().info.ferrum_api says which one you're on
  • the app has to answer km.has_mouse(), otherwise anybox can't tell what's plugged in and asks you to update it
  • by default a release, and the end of a click or a tap, forces it up for 125 to 175 ms before the physical button counts again. the app's No Button Release setting drops the software press right away instead, and anybox can't read that setting
  • a click on a button that's still in its press or forced release takes over from it, i don't know yet what two quick clicks in a row look like
  • the box lets the physical button win, so pressing and letting go of a button anybox holds really lets it go
  • clicks and taps hold for a random 75 to 125 ms, the box has no way to take a hold, so a click or a tap with one is a press and a release timed from your PC, which is what the vendor tells you to do too
  • the vendor says to scroll one notch at a time, bigger scrolls can look emulated
  • the app's Move Multiplier scales every move and anybox can't read it, so leave it at 1 if your moves have to be exact
  • the box never says no, so a command it doesn't take just does nothing. anybox only sends what the box says it has
  • a mouse or keyboard you set in the app after connecting shows up within a second
  • unplugging the box from this PC doesn't disconnect anybox, because it talks to the app's port and that stays open. the app just loses the mouse and keyboard, so they go away from the snapshot with an Event::Changed and come back the same way when you plug it back in (the app doing that was seen on a real box)
  • info.peripheral comes from the app's device.VID() and device.PID(), which name the first device plugged into the box, not always the mouse. with nothing plugged in the app answers a default pair that's also a real Logitech receiver, so anybox leaves it empty then
  • the legacy port answers exactly like a KMBox B+, so a B+ opens as a legacy Ferrum too. anybox only promises the legacy commands there, and nobody tried it on a B+
  • locking one way of an axis needs an app that has directional locks, otherwise (and on the legacy port) you can only lock a whole axis
  • i don't know yet whether the events include what anybox sends or only what you do yourself

MAKCU

  • on a MAKCU on V4.094 (measured with a USB capture, nobody touching the mouse) about 1,400 moves a second got through and the rest were refused, and an Event::Rejected counts the ones it dropped. that's one box and one firmware, so take it with a pinch of salt. if you move once per frame at a high frame rate, add up the deltas and send them every millisecond or two instead. moving the real mouse at the same time probably lowers that, i haven't measured it yet
  • a key tap without a hold lasts about one report, so some apps miss it, pass a hold with tap_key_with if it has to land. a click is fine, the box holds it 35 to 75 ms by itself
  • a key press right after a tap gets refused
  • a lock on a button that's already held still lets its release through
  • if your app gets killed, its locks stay on the box until something unlocks them
  • after a reconnect anybox lets go of what was held, press again if you still need it
  • right after a reconnect the mouse behind the box can take a second or so to show up again, so until then the snapshot has no mouse and mouse calls come back unsupported. you get a second Reconnected once it's there, so wait for that one
  • unplugging the box restarts it, so a button held at that moment stays down in Windows after the reconnect, because the box never sends it the release. anybox can't fix that without clicking for you, a real click on that button clears it

Medius

  • a lock on a button that's already held keeps it down until you unlock it
  • held buttons, keys and locks come back by themselves after the box reconnects or resets
  • device.medius() gives you the medius crate's own device, for clips, transforms, rendering, the polling rate (query_rate()) and the identity the box shows the PC (device_info())
  • anybox doesn't know what you press or lock through medius(), and its reset() lets go of and unlocks EVERYTHING, what anybox did too

support

if anybox saves you some time, you can sponsor my work, it genuinely helps

GitHub is still approving the sponsors page, so the link might not work yet. it should be up soon

license

MPL-2.0, see LICENSE. you can use anybox in any app, closed source too. if you change anybox itself and ship that, those changes have to be published under the same license with the credit kept. made by ethereal