Skip to main content
The axol tracker.* commands are the CLI side of Mantis Tracking: the control panel’s Mantis settings → Tracking flow runs the same commands on the host. This page is the flag and file-format reference; the setup walkthrough for each source lives on the tracking page. Direct axol collect-data --mantis true runs inherit the source, channel map, bindings, and (for Quest) calibration key saved by the panel, own their bridge, and enforce the same readiness gates. Do not launch a second tracker.bridge beside a managed run — it stays view-only and cannot win pose control. (axol teleop --mantis never tracks: it drives the grippers from the rig triggers over CAN and starts no bridge.)
Quest uses none of these commands: the headset’s WebXR client is the pose source, and its calibration key is selected in the panel. Mantis is not supported by Collect DAgger or Run Policy.

axol tracker.bridge

Streams a hardware tracker backend’s poses (and each side’s trigger value) to the VR server in the standard VRFrame format. With the survive backend, an unexpected survive-cli exit (a crash) no longer ends the bridge: it is restarted after about a second, and since its calibration is saved both trackers report again a few seconds later; the rigs freeze meanwhile as for any tracking loss. More than 3 exits within a minute is treated as a crash loop and fails the source, and the exit report names the signal and the last lines of survive-cli’s stderr. libsurvive only enumerates USB devices at startup, so when a tracker drops off the bus (or a fresh process leaves one silent) the bridge restarts survive-cli and re-checks every 5 seconds, for up to 60 seconds, until every tracker seen earlier reports again. A side whose tracker loses tracking (occlusion, SLAM relocalising) holds its last good pose and logs a warning rather than feeding IK a glitch. A stale trigger node (no frames for ~250 ms) likewise holds the last grip command and logs the dropout. A managed bridge freezes motion, waits for both trackers to become fresh, then requires the two-trigger start gesture before it re-anchors; the standalone bridge keeps its manual controls: Enter toggles engage, r returns to rest, q quits. A standalone bridge warns and continues fully open when a configured trigger is absent; a managed production run fails readiness instead.

Hardware-free protocol demo

To exercise the absolute-mapping protocol without a Mantis source, run a generic sim server and a standalone static bridge:
Press Enter in the bridge terminal to engage; the simulated arms remain at the static poses. Prefer static over synthetic when isolating engage/reset plumbing. This proves only the JSON/IK/input path — it selects no Mantis source, resolves no tracker→TCP transform, and is not acceptance. Both hardware-free backends bind their own devices and need no tracker.identify.

axol tracker.identify

Discovers the powered-on trackers, then asks you to move each rig in turn; the device with the most motion is bound to that side. For Lighthouse it runs the same channel check as tracker.lighthouse.check after discovery and refuses to bind while a clash is present. Bindings are kept separately per family so the panel can switch between Lighthouse and Ultimate. Saves ~/.almond/tracker/config.json:
Lighthouse trackers are bound by serial number. libsurvive’s own device names (WM0, WM1) number the Watchman dongles in the order they are opened and can swap between runs, so releases before 0.2.25, which saved those names, could bind each rig to the other’s tracker. A saved binding that is still a libsurvive name is cleared on load; run Identify trackers once to bind by serial.

axol tracker.pair

Pairs one VIVE Tracker 3.0 with its Watchman dongle through survive-cli --pair-device 1 (which needs the libusb backend tracker.install builds). The tracker must be unplugged from USB and blinking blue. Run it once per tracker/dongle pair. libsurvive only opens pairing mode on a dongle that has no tracker connected. If every connected dongle already brings up a powered tracker, the command says they are already paired instead of waiting; to pair a different tracker to a dongle, switch off the tracker currently paired to it first.

axol tracker.install

Installs the tested libsurvive commit f1e6eddb669320f2a30760f4b42936bdb4306da0 with seven fixes applied on top: build dependencies, survive-cli built with Eigen and the native Linux libusb backend (not OpenBLAS or HIDAPI), and the Vive udev rule. The fixes ship in the package (almond_axol/tracker/libsurvive_patches/; the timestamp, base-station, scene-solver and solver-stack fixes carry a regression test in libsurvive’s own suite):
  • Timestamps across the tracker clock rollover. The tracker’s 48 MHz clock wraps every ~89 s; a laser measurement taken just before the wrap was stamped ~350 ms in the future, which could stall pose output while tracker data kept arriving.
  • Solver buffer overflow with more base stations. Upstream libsurvive’s fix (df22f2d, backported verbatim): the MPFIT poser copied the solver’s covariance into a fixed 28×28 buffer, which overflows once the tracker pose, per-station corrections and stations being re-solved exceed 28 parameters. That is reachable with four base stations and can crash survive-cli.
  • Scene-solver crash. libsurvive’s global scene solver keeps 32 calibration scenes; once a long session filled that ring, the loops after each solve read one past its end and crashed survive-cli (SIGSEGV), ending the session. Setups with more base stations fill it sooner. Those loops are now bounded, and scenes of a disconnected tracker are compacted without mixing up the remaining scenes’ measurements.
  • Solver stack overflow. The optimizer (mpfit) put its work arrays, including the Jacobian, on the stack. A global solve over a full scene ring with four base stations needs several megabytes for it, which overflowed the solver thread’s stack and crashed survive-cli. They are now heap-allocated.
  • Smaller fixes. A data race on the scene solver’s “solve needed” flag, a mis-indexed measurement count in the optional per-station correction setup (off by default), and a read past the end of an empty config file’s parse.
  • Poses delivered as they are produced. survive-cli --record-stdout never flushed its output, so through a pipe the poses arrived in 4 KB blocks: about 50 ms late at the median and up to about 110 ms with two trackers. The stream is now line-buffered.
  • Base-station identity through every tracker. Each base station’s OOTX broadcast (its identity and calibration) was decoded only through the first tracker that saw it; if that tracker received it poorly, the other tracker’s clean copy was ignored and calibration could stall. Every tracker now decodes it, and the first valid packet wins.
A build from before these fixes is rebuilt, not re-attested. axol provision runs it automatically; rerun it directly to repair. When the machine-wide install proof under /var/lib/almond/ is missing or in an older format but the local build cache already holds the pinned build with the same options (for example after updating Axol), the command only refreshes the udev rule and re-attests the installed files instead of rebuilding. The proof is machine-wide, so root provisioning/serve and an operator CLI agree even though their writable build caches differ.
Manual fallback from the same upstream source:
On ARM, avoid linking libsurvive against OpenBLAS (known instability/CPU blowups); -DUSE_EIGEN=ON sidesteps it. Close SteamVR if one is somehow present — it competes for the devices.

axol tracker.lighthouse.check

Listens to libsurvive for about 20 seconds with both trackers powered and in view. When base stations are live but the trackers are not reporting yet, libsurvive is still calibrating: it reports no pose until it has every live station’s identity and position, which can take a minute or more on a new host or after stations are added or re-channelled. The check then keeps listening, up to 150 seconds in total, until both trackers report; keep them still and in view meanwhile. It then reports one line per base-station channel a tracker actually received (with the station serials once decoded) and the trackers that are reporting. It fails when two stations share a channel, fewer than two base stations are seen, or fewer than two trackers report; each failure line ends with the fix. Stations replayed from libsurvive’s saved calibration are not counted as seen, so a station that was re-channelled or unplugged since the last session cannot mask the problem; if the station now on a channel is not the one saved there last time, the check says so — with two stations that means both are on that channel, since libsurvive can only lock onto one station per channel. The survey is saved to ~/.almond/tracker/lighthouse_survey.json and gates the Trackers step of the panel flow. Every Axol survive-cli run (tracking, the check, pairing) uses libsurvive’s calibration file ~/.config/libsurvive/config.json (under $XDG_CONFIG_HOME when set) of the account it runs as; the root service uses /root/.config/libsurvive/config.json. Releases before 0.2.25 let the service, which runs without HOME, write it to /config.json; the first run adopts that file so existing base-station positions are kept.

axol tracker.ultimate.install

Installs pyvut commit fcfcd33f4c1f16b0d84f5f741dc1319abdc7942a, the hid>=1.0.5 Python binding (not the unrelated hidapi package), system HID libraries, and a persistent udev rule for dongle 0bb4:0350. Safe to rerun; do not replace it with an unpinned uv pip install or a one-off chmod /dev/hidraw*. The Windows pairing and mapping steps that must precede it are on the tracking page.

Ultimate Wi-Fi file

ultimate_wifi.json is the private shared-map AP configuration the panel’s Wi-Fi step writes. For a CLI/manual setup, create ~/.almond/tracker/ultimate_wifi.json (or $ALMOND_HOME/tracker/ultimate_wifi.json) with this shape and chmod 600 it:
All four fields are required by upstream pyvut. With the USB-dongle transport the tracker firmware reports the active SLAM-host SSID/password/frequency and pyvut relays those to the other tracker; only country is consumed directly, so the SSID/password/frequency are fallback configuration, not a promise of the active broadcast network. Choose a country and frequency valid for the workspace. The readiness check reports only validity, never credential values.

axol tracker.ultimate.check

Non-invasive dependency, USB, permission, binding, pose-convention, and redacted AP-config check — the same state the operation Start gate uses. Missing or insecure AP config, inaccessible HID, or absent operator access makes it fail. It deliberately does not open the dongle or claim live tracking; run tracker.identify again when you need to prove live poses.

Transform file

~/.almond/mantis/tcp_transform.json holds measured tracker→TCP transforms, keyed per side by the identified device. The built-in factory values for the standard Tracker 3.0 and Ultimate flat-back mounts apply automatically when no conflicting entry exists; a saved entry is a higher-priority override. Quest 3 Touch Plus controllers (quest:meta-quest-touch-plus:grip) have a factory value too, used only when no Quest entry is saved; other Quest controllers have none and need an entry for both sides.
Replace every placeholder with the exact identifiers from ~/.almond/tracker/config.json (or the Quest key the panel reports) and the measured values; the identity values above are schema placeholders, not a usable calibration. pos is the gripper TCP origin in the tracker’s local frame in metres; quat is the TCP orientation in that frame as [qx, qy, qz, qw], normalized on load. The file can also be written through PUT /api/tracker/calibration/<source>; the control panel does not edit it. Rules the loader enforces:
  • Quest production collection accepts only a quest:<reported-profile>:grip entry on both sides, or the built-in Quest 3 factory value for quest:meta-quest-touch-plus:grip. A bare quest key, a different Touch profile, missing profile metadata, or a target-ray entry fails closed. A saved Quest entry always takes precedence; the factory key is selected only when none is saved.
  • Ultimate entries must carry ultimate_pose_convention matching both the convention used during the measurement and the active ultimate_quat_order / ultimate_up_axis. A convention-less or mismatched entry is not authoritative and suppresses the factory fallback for that side until fixed or removed, because it may represent a non-standard mount.
  • An old unkeyed entry, a bare survive / ultimate key, or a same-family entry left behind after rebinding also suppresses the factory value for that side until it is re-keyed or deleted.
  • A standalone Advanced/CLI transform can support a bring-up capture, but production collect-data requires an active device-keyed override or an approved factory value on both sides; --mantis_allow_uncalibrated true is only for a calibration capture that will not be used for training.

Vive factory values

Both Vive families sit flat on top of the rig, behind the gripper motor, and share the mount rotation Ry(180°), stored as the quaternion [0, 1, 0, 0]: the bridge-reported tracker +z lies along the gripper’s finger direction (gripper −z) and the tracker’s +x along the gripper’s −x, with the y axis shared. In the gripper frame (origin at the centre of the gripper motor’s mounting face) the Tracker 3.0 origin is at [0, −35, 47] mm, 47 mm behind the motor face and 35 mm above it, so the stored value (the gripper origin in the tracker frame) is [0, 0.035, 0.047, 0, 1, 0, 0] for both sides.
axol ≤ 0.2.4 shipped Rx(+90°) ([0.7071068, 0, 0, 0.7071068]) here. That rotation is the mount-CAD frame convention, not the bridge’s device frame, so every recorded gripper orientation was pitched ~90° from where the operator actually held the rig and Axol could not replay Mantis datasets. Repair datasets recorded with it using axol migrate-dataset --mantis-tcp-rotation. Datasets recorded from 0.2.5 on carry a mantis_tcp_transform record in meta/axol.json, and collect-data refuses to append new episodes to an unrepaired dataset.
The Ultimate’s origin sits 11 mm higher on the same mount, at [0, −46, 47] mm in the gripper frame, so its value is [0, 0.046, 0.047, 0, 1, 0, 0] for both sides; the tracker-local axes and flat-back orientation match V3, so there is no additional rotation. axol 0.2.5–0.2.16 shipped [0, 0.0355, -0.092] / [0, 0.0465, -0.092] as the translation, which put the tracker 92 mm in front of the gripper instead of 47 mm behind it. Those datasets record vive-flat-back-ry180-v0.2.5 as their mantis_tcp_transform id, and collect-data refuses to append to them under the current constants.

Quest factory value

Quest 3 Touch Plus controllers (quest:meta-quest-touch-plus:grip) in the standard cradles have their WebXR grip origin at [0, −45, 62] mm in the gripper frame, 62 mm behind and 45 mm above the motor face, with the grip axes rotated 180° about the gripper’s z axis plus a ~10° cradle tilt. The left cradle is the right one’s mirror image, so the tilt mirrors between sides: Other controller generations still need a measured entry.

Deriving a missing constant

If a source transform is missing — a Quest controller other than the Quest 3 Touch Plus, or an Ultimate run under a non-factory parser convention — first pass the acceptance checks for side, scale, and recovery, then run a short capture with --mantis_allow_uncalibrated true, inspect it to derive the constant, install it under the exact source/device key, and repeat the loss/recovery check. Normal collect-data --mantis stays blocked until both transforms resolve; never mix an uncalibrated capture into training data.