- Rust 99.7%
- Nix 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Adds the spnav-config crate: a simplified, spnavrc-inspired key=value config file parser (sensitivity, per-axis sensitivity, dead-zone, invert-trans/rot, swap-yz, socket path), a default search path (~/.config/spacenavd-rs/config.conf, else /etc/spacenavd-rs.conf), and a thiserror ConfigError where a missing file falls back to defaults but a malformed existing file logs per-line warnings without aborting. Wires it into bin/spacenavd-rs via a new --config flag: sensitivity is applied as a multiplier on every emitted MotionEvent axis, and the config's socket path is used as the --socket-path default (CLI flag still wins if both given). Module doc comments document exactly which C daemon cfgfile.c options are covered vs. deferred (profiles, per-axis dead-zone/axis/button maps, kbmap, led/grab/serial/device-id). README updated accordingly. Claude-Session: https://claude.ai/code/session_01XX7nhvqCdu37uKzXndazX2 |
||
| bin/spacenavd-rs | ||
| crates | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
| shell.nix | ||
spacenavd-rs
A new, standalone, cross-platform Rust reimplementation of the SpaceMouse USB/HID
driver + daemon — not a replacement of ../spacenavd (the existing C daemon stays
as-is; see ../RESEARCH.md §6 for why). This is a separate project
aimed at platforms the C daemon serves poorly today (its dev_usb_darwin.c is 97
lines and multiple community forks report "macOS support, not working"; FreeBSD support
exists but is clearly secondary to Linux).
Read these first
../RESEARCH.md— background on the whole SpaceMouse stack and why the existing C daemon isn't being rewritten in place (§6), plus the siblingspacenav-ws-rsRust bridge project (§7) — that project's own README documents useful Rust ecosystem findings (nusb,hidapi, prior art) that fed into the decisions here.../spacenavd/src/dev.c— the known-device table (usbdb[]) and blacklist this project'sspnav-devicescrate ports. See §1 below for the full table reproduced inline (don't re-derive it from the C source — it's already extracted).../spacenavd/src/proto.h— the wire protocolspnav-protoin this workspace ports to the server side. Note the fork-local profile/LCD requests live at0x6000+in the C daemon (post-renumbering, see RESEARCH.md §3) — not relevant here, this project doesn't carry that fork feature (it's a from-scratch reimplementation of the upstream FreeSpacenav protocol only).
Architecture decisions (Opus research, 2026-08-20)
HID/USB access: hidapi, not nusb. nusb (favored in earlier bridge-project
research) turns out to be the wrong layer for HID-class devices specifically: its own
docs say so directly, it requires WinUSB rebinding on Windows (replacing the OS's own
HID handling — a non-starter for a device you want normal OS coexistence with), and it
cannot detach the kernel HID driver on macOS/Windows at all. hidapi (the
hidapi/hidapi-rs crate wrapping the well-established cross-platform C library) is
the only ecosystem option that genuinely covers Linux (hidraw), macOS (IOHIDManager),
Windows (hid.dll), AND FreeBSD (via its libusb backend) — one crate, all four target
families. Cost: one vendored C dependency, statically built by the crate (no system
package required). Strong precedent: PySpaceMouse drives every SpaceMouse model on
all three major platforms through raw hidapi with per-device axis specs — the same
design this project follows.
This means reading raw HID reports, not evdev (which is what the C daemon does on
Linux, via dev_usb_linux.c's linux/input.h — Linux-specific and not portable). The
per-device HID report decode (report ID 1 = translation, 2 = rotation, 3 = buttons on
older devices; a single combined report on newer models) becomes the one thing shared
across all four platforms instead of being reimplemented per-OS four times — that's the
actual architectural payoff of doing this in Rust at all.
Protocol: stay wire-compatible with proto.h on Unix platforms. On Linux/BSD/macOS,
serve the same Unix-socket protocol the C daemon does (v0 raw packets + v1
negotiation via REQ_CHANGE_PROTO) — this is free compatibility with the existing
consumer ecosystem (Blender, FreeCAD, OpenSCAD, KiCad, Solvespace, ROS's spacenav_node
— see RESEARCH.md §4 for the full list) and the only way to meaningfully validate
correctness, since libspnav-based apps become a real conformance test. On Windows
(which has no existing Unix-socket-protocol consumers to be compatible with), use a TCP
loopback listener instead of a named pipe — simplest, most portable, same event/request
semantics. The spnav-server crate is transport-agnostic internally so both listener
types share one core.
Prior art: buumotfa4djz6-tech/spnav-rs has a from-scratch Rust implementation of
the v0/v1 wire framing plus the REQ_* config API — the best reference (and a ready
conformance-test client) for spnav-proto. tophcodes/space-elevator doesn't touch
device I/O at all (it's a libspnav client that only owns the Enterprise LCD) but its
transport-agnostic message-handler pattern is a useful reference for spnav-server.
No cross-platform Rust 6DOF HID driver exists anywhere (verified via broad gh search repos across spacemouse/3dconnexion/spacenav) — this project is the first.
Hotplug: no crate gives this to you for HID devices. hidapi-rs has no hotplug
support (upstream RFC/PRs still open as of this writing). Phase 1 uses a polling
enumeration loop (1-2s interval) — genuinely adequate for this device class and ships
on all four platforms immediately; real push-based hotplug (udev netlink on Linux,
IOKit notifications on macOS, WM_DEVICECHANGE on Windows, devd on FreeBSD) is
explicit follow-on work, designed for behind the same DeviceEvent stream interface
so it's additive later, not a rewrite.
Workspace layout
spacenavd-rs/
├── crates/
│ ├── spnav-devices/ — known-device table (VID/PID → name/flags), blacklist,
│ │ DF_SWAPYZ/DF_INVYZ-equivalent axis normalization, per-device
│ │ HID report layout + button-remap hooks (bnhack_smpro/
│ │ bnhack_sment equivalents)
│ ├── spnav-hid/ — HID backend trait + `hidapi` implementation: enumerate
│ │ devices matching spnav-devices' table, open, read reports
│ ├── spnav-proto/ — proto.h-compatible wire protocol, SERVER side (this is a
│ │ separate crate from spacenav-ws-rs's client-side spnav-proto
│ │ — see "Relationship to spacenav-ws-rs" below)
│ └── spnav-server/ — client registry, event fan-out, per-client sensitivity/
│ evmask state; Unix socket listener (Linux/BSD/macOS) + TCP
│ loopback listener (Windows), sharing one transport-agnostic
│ core
└── bin/spacenavd-rs/ — the daemon binary: polling hotplug loop + HID read loop +
server, CLI, logging
Dependency order: spnav-devices, spnav-hid (depends on spnav-devices for the
device table), and spnav-proto are the foundation. spnav-server depends on
spnav-proto. The binary depends on all four.
Relationship to spacenav-ws-rs
../spacenav-ws-rs/crates/spnav-proto is a client of spacenavd's socket (used by
the websocket bridge). This workspace's spnav-proto is a server implementation of
the same wire format. They are deliberately kept as separate crates in separate
projects for now (this project is meant to "stand alone" per its own brief) rather than
factored into a shared crate — but the byte-level wire format must stay identical
between them, since both ultimately need to interoperate with the same real-world
ecosystem (a spacenav-ws-rs bridge should be able to talk to a spacenavd-rs daemon
exactly as it talks to the C one). If they drift, that's a bug in one of them, not an
intentional divergence. Unifying them into one shared crate is reasonable future work
once both have stabilized.
Phase 1 scope (this implementation pass)
Linux only. hidraw via hidapi, proto v0+v1 Unix-socket server, polling hotplug,
the known-device table with axis-normalization flags and button-remap hooks. No
LCD/profile support (not part of upstream FreeSpacenav's protocol, that's a fork-only
feature on the C daemon side per RESEARCH.md, out of scope for a from-scratch
reimplementation), no daemonization/service integration (systemd unit, launchd plist,
Windows service, rc.d — all explicit follow-on work per platform).
Explicit follow-on phases, each additive against the trait/transport boundaries designed in Phase 1, not a rewrite:
- macOS (IOHIDManager backend already covered by
hidapi; add IOKit hotplug notifications to replace polling) - Windows (hid.dll backend already covered by
hidapi; add the TCP loopback listener; addWM_DEVICECHANGEhotplug) - FreeBSD (hidapi's libusb backend; add devd-socket hotplug)
- Per-application profiles, live REQ_SCFG_/REQ_GCFG_ remote config protocol, X11 Magellan protocol support, uinput/keyboard-emu support, per-platform service integration, LCD/profile parity with the C fork if ever wanted
Config file parsing (Phase 2 follow-on, implemented 2026-08-20)
The spnav-config crate adds file-based configuration, loaded once at daemon startup
by bin/spacenavd-rs. It is deliberately a much smaller, line-based key = value
parser than the C daemon's cfgfile.c tokenizer — see crates/spnav-config/src/ lib.rs's module doc comment for the exhaustive comparison. Summary:
Covered: global sensitivity (f32, default 1.0, applied as a multiplier to
every emitted MotionEvent axis), per-class/per-axis sensitivity-translation[-x/y/z]
and sensitivity-rotation[-x/y/z], a single global dead-zone (not per-axis),
invert-trans/invert-rot (letter combinations of x/y/z), swap-yz, and socket
(the Unix socket path — used as the default --socket-path when the CLI flag isn't
given explicitly; --socket-path always wins if both are set). # comments,
blank-line and whitespace tolerance, and the C daemon's own boolean spellings
(true/false, yes/no, on/off) are all supported.
Deferred (explicit future work): per-application profile "Name" class=... end
blocks, per-axis dead-zoneN/axismapN/bnmapN/bnactN/kbmapN tables, and the
led/grab/serial/device-id/repeat-interval/kbmap_use_x11 settings — none of
these have any representation in spnav_config::Config yet. Also out of scope for this
pass: the daemon's live REQ_SCFG_*/REQ_GCFG_* remote-config wire protocol (only
file-based config loaded once at startup is implemented; spnav-server's
ClientState::sensitivity still defaults to 1.0 per-client rather than being seeded
from the loaded config — wiring the global config default into new client connections
is a small additive follow-up, not done in this pass).
Default search path (spnav_config::default_config_path()): per-user
~/.config/spacenavd-rs/config.conf if it exists, else /etc/spacenavd-rs.conf — a
deliberately different filename from the C daemon's own /etc/spnavrc/~/.spnavrc,
since this parser does not understand that file's full grammar and silently
partially-parsing an existing C-daemon config would be misleading. Override with
spacenavd-rs run --config <path>. A missing config file is not an error (logged at
info, daemon runs with built-in defaults); a malformed existing file logs a
warn per bad line and keeps parsing, mirroring the C daemon's own line-skipping
behavior.
Status
Phase 1 implemented (2026-08-20) — all four crates plus the spacenavd-rs binary
have real implementations. cargo test --workspace passes (65 unit/integration tests
across spnav-devices/spnav-hid/spnav-proto/spnav-server/spacenavd-rs),
cargo fmt/cargo clippy -- -D warnings are clean, and cargo build --workspace
links a real spacenavd-rs binary. Verified smoke tests (no real hardware available in
this environment):
spacenavd-rs list-devicesruns cleanly and reports "No known SpaceMouse devices found." (not a crash) with nothing plugged in.spacenavd-rs run --socket-path <tmp path>starts, binds and creates the Unix socket file, logs startup; sending itSIGINT(Ctrl-C) logs a clean shutdown message, the process exits promptly (no hang), and the socket file is removed automatically.
Not yet verified — needs a human with a real SpaceMouse before this is trustworthy as a daemon:
- No real HID device was ever opened or read in this sandbox —
HidBackend::open,OpenDevice::read_decoded, the per-devicestd::threadread loop, and the hotplug-detect/cleanup logic inbin/spacenavd-rs/src/main.rs'shotplug_loopare only exercised bylist_devices()returning an empty list. Plugging in an actual device and confirming motion/button events arrive at a connected client (libspnav-based app, or a raw socket reader) is the first thing to check. - The translation/rotation report-pairing heuristic (
MotionStateinmain.rs: caches the last-seen translation and rotation per device, pairing whichever half just arrived with the other half's cached value to build a combinedMotionEvent) is unit-tested for its own internal logic but unverified against real report timing — it assumes split-device translation/rotation reports arrive close enough together that pairing "current + last cached" never visibly lags, which is only provable by feel on real hardware. - The button bitmask-diffing logic (
MotionState::diff_buttons, emitting press/release edges between successive raw bitmasks) is unit-tested but the actual HID bit-position-to-button mapping for any given device, and whetherspnav_devices::ButtonRemap(evdevBTN_*-numbered) ever needs to be bridged to the raw-HID-bit numberingDecodedReport::Buttonsuses (spnav-devices' own doc comment flags this as unresolved — no known public source maps one to the other), are both unverified. spnav_devices::report_layout()'s guessed "split" layout for several devices not present in PySpaceMouse'sdevices.toml(see that crate's doc comment for the exact list) is unverified for any of them.- The 1.5s hotplug poll interval is a documented judgment call, not measured against real plug/unplug responsiveness.
- No
libspnav-based client (Blender, FreeCAD, etc.) was connected to a runningspacenavd-rsinstance in this pass — protocol-level interop is only exercised byspnav-server's andspnav-proto's own unit/integration tests, not a real consumer.
Nothing published/pushed anywhere — local commits only.